diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts index b935a0f7a..585e1f729 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts @@ -21,9 +21,8 @@ import { } from "@web/ducks/events/slices/sync.slice"; import { useAppDispatch } from "@web/store/store.hooks"; import { toastDefaultOptions } from "@web/views/Day/components/Toasts"; -import { OnboardingStepProps } from "@web/views/Onboarding"; -export function useGoogleAuth(props?: OnboardingStepProps) { +export function useGoogleAuth() { const dispatch = useAppDispatch(); const { setAuthenticated } = useSession(); const { markSignupCompleted } = useIsSignupComplete(); @@ -69,8 +68,6 @@ export function useGoogleAuth(props?: OnboardingStepProps) { markSignupCompleted(); - props?.onNext?.(); - const syncResult = await syncLocalEvents(); if (syncResult.success && syncResult.syncedCount > 0) { diff --git a/packages/web/src/views/Onboarding/components/DynamicLogo.tsx b/packages/web/src/views/Onboarding/components/DynamicLogo.tsx deleted file mode 100644 index a82ce6a7b..000000000 --- a/packages/web/src/views/Onboarding/components/DynamicLogo.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import React, { useEffect } from "react"; - -export const DynamicLogo = () => { - useEffect(() => { - document.addEventListener("DOMContentLoaded", function () { - (function () { - const loadElements = function () { - const container = document.getElementById( - "ascii-canvas-1753449179897", - ).parentNode; - const canvas = document.getElementById("ascii-canvas-1753449179897"); - const ctx = canvas.getContext("2d"); - const sourceMedia = document.getElementById( - "source-image-1753449179897", - ); - if (!canvas || !sourceMedia) { - setTimeout(loadElements, 50); - return; - } - const config = { - mouseRadius: 50, - intensity: 3, - fontSize: 12, - charSpacing: 0.6, - lineHeight: 1, - mousePersistence: 0.97, - returnSpeed: 0.1, - returnWhenStill: true, - enableJiggle: true, - jiggleIntensity: 0.2, - detailFactor: 100, - contrast: 95, - brightness: 90, - saturation: 120, - useTransparentBackground: true, - backgroundColor: "transparent", - }; - const charSet = " .:-=+*#%@"; - const colorScheme = (r, g, b, brightness, saturation) => { - const sat = saturation / 100; - const gray = 0.2989 * r + 0.587 * g + 0.114 * b; - const rSat = Math.max(0, Math.min(255, gray + sat * (r - gray))); - const gSat = Math.max(0, Math.min(255, gray + sat * (g - gray))); - const bSat = Math.max(0, Math.min(255, gray + sat * (b - gray))); - return `rgb(${Math.round(rSat)},${Math.round(gSat)},${Math.round(bSat)})`; - }; - let mouseX = -1000; - let mouseY = -1000; - let lastMouseMoveTime = 0; - let isAnimating = false; - let chars = []; - let particles = []; - let velocities = []; - let originalPositions = []; - const isVideo = false; - function updateCanvasSize() { - const containerWidth = container.clientWidth || 300; - const containerHeight = container.clientHeight || 150; - const mediaRatio = isVideo - ? sourceMedia.videoHeight / sourceMedia.videoWidth - : sourceMedia.height / sourceMedia.width; - let width, height; - if (containerWidth * mediaRatio <= containerHeight) { - width = containerWidth; - height = width * mediaRatio; - } else { - height = containerHeight; - width = height / mediaRatio; - } - canvas.width = width; - canvas.height = height; - return { width, height }; - } - function applyContrastAndBrightness(imageData) { - const contrastPercent = config.contrast; - const brightnessPercent = config.brightness; - const data = imageData.data; - if (contrastPercent === 100 && brightnessPercent === 100) - return imageData; - let contrastFactor; - if (contrastPercent < 100) { - contrastFactor = contrastPercent / 100; - } else { - contrastFactor = 1 + ((contrastPercent - 100) / 100) * 0.8; - } - let brightnessFactor; - if (brightnessPercent < 100) { - brightnessFactor = (brightnessPercent / 100) * 1.2; - } else { - brightnessFactor = 1 + ((brightnessPercent - 100) / 100) * 0.8; - } - for (let i = 0; i < data.length; i += 4) { - let r = data[i]; - let g = data[i + 1]; - let b = data[i + 2]; - if (brightnessPercent !== 100) { - if (brightnessPercent < 100) { - r *= brightnessFactor; - g *= brightnessFactor; - b *= brightnessFactor; - } else { - r = r + (255 - r) * (brightnessFactor - 1); - g = g + (255 - g) * (brightnessFactor - 1); - b = b + (255 - b) * (brightnessFactor - 1); - } - } - if (contrastPercent !== 100) { - r = 128 + contrastFactor * (r - 128); - g = 128 + contrastFactor * (g - 128); - b = 128 + contrastFactor * (b - 128); - } - data[i] = Math.max(0, Math.min(255, r)); - data[i + 1] = Math.max(0, Math.min(255, g)); - data[i + 2] = Math.max(0, Math.min(255, b)); - } - return imageData; - } - function generateAsciiArt() { - const dimensions = updateCanvasSize(); - const columns = Math.round( - Math.max(20, (dimensions.width / 1200) * config.detailFactor * 3), - ); - const aspectRatio = isVideo - ? sourceMedia.videoHeight / sourceMedia.videoWidth - : sourceMedia.height / sourceMedia.width; - const rows = Math.ceil(columns * aspectRatio); - const tempCanvas = document.createElement("canvas"); - tempCanvas.width = columns; - tempCanvas.height = rows; - const tempCtx = tempCanvas.getContext("2d"); - tempCtx.drawImage(sourceMedia, 0, 0, columns, rows); - let imageData = tempCtx.getImageData(0, 0, columns, rows); - imageData = applyContrastAndBrightness(imageData); - tempCtx.putImageData(imageData, 0, 0); - const fontSizeX = dimensions.width / columns; - const fontSizeY = fontSizeX * config.lineHeight; - if (chars.length === 0) { - chars = []; - particles = []; - velocities = []; - originalPositions = []; - for (let y = 0; y < rows; y++) { - for (let x = 0; x < columns; x++) { - const posX = x * fontSizeX; - const posY = y * fontSizeY; - chars.push({ char: " ", x: posX, y: posY, color: "black" }); - particles.push({ x: posX, y: posY }); - velocities.push({ x: 0, y: 0 }); - originalPositions.push({ x: posX, y: posY }); - } - } - } - const pixels = imageData.data; - for (let y = 0; y < rows; y++) { - for (let x = 0; x < columns; x++) { - const index = (y * columns + x) * 4; - const r = pixels[index]; - const g = pixels[index + 1]; - const b = pixels[index + 2]; - const brightness = 0.299 * r + 0.587 * g + 0.114 * b; - const charIndex = Math.floor( - (brightness / 256) * charSet.length, - ); - const char = charSet[Math.min(charIndex, charSet.length - 1)]; - const color = colorScheme( - r, - g, - b, - brightness, - config.saturation, - ); - const charIdx = y * columns + x; - if (charIdx < chars.length) { - chars[charIdx].char = char; - chars[charIdx].color = color; - } - } - } - } - function animate() { - if (!isAnimating) return; - if (isVideo) { - generateAsciiArt(); - } - ctx.clearRect(0, 0, canvas.width, canvas.height); - if (!config.useTransparentBackground) { - ctx.fillStyle = config.backgroundColor; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - ctx.font = `${config.fontSize}px monospace`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - const mouseStillTime = Date.now() - lastMouseMoveTime; - const mouseIsStill = mouseStillTime > 500; - for (let i = 0; i < particles.length && i < chars.length; i++) { - const particle = particles[i]; - const velocity = velocities[i]; - const targetX = originalPositions[i].x; - const targetY = originalPositions[i].y; - const dx = particle.x - mouseX; - const dy = particle.y - mouseY; - const distance = Math.sqrt(dx * dx + dy * dy); - if ( - distance < config.mouseRadius && - (!mouseIsStill || !config.returnWhenStill) - ) { - const force = - (1 - distance / config.mouseRadius) * config.intensity; - const angle = Math.atan2(dy, dx); - velocity.x += Math.cos(angle) * force * 0.2; - velocity.y += Math.sin(angle) * force * 0.2; - } - if (config.enableJiggle) { - velocity.x += (Math.random() - 0.5) * config.jiggleIntensity; - velocity.y += (Math.random() - 0.5) * config.jiggleIntensity; - } - velocity.x *= config.mousePersistence; - velocity.y *= config.mousePersistence; - particle.x += velocity.x; - particle.y += velocity.y; - const springX = targetX - particle.x; - const springY = targetY - particle.y; - particle.x += springX * config.returnSpeed; - particle.y += springY * config.returnSpeed; - const charInfo = chars[i]; - ctx.fillStyle = charInfo.color; - ctx.fillText(charInfo.char, particle.x, particle.y); - } - requestAnimationFrame(animate); - } - canvas.addEventListener("mousemove", function (e) { - const rect = canvas.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - mouseY = e.clientY - rect.top; - lastMouseMoveTime = Date.now(); - }); - canvas.addEventListener("mouseleave", function () { - mouseX = -1000; - mouseY = -1000; - }); - function initializeAscii() { - if ( - (sourceMedia.complete || isVideo) && - (isVideo ? sourceMedia.readyState >= 2 : true) - ) { - updateCanvasSize(); - generateAsciiArt(); - isAnimating = true; - animate(); - if (isVideo) sourceMedia.play(); - } else { - sourceMedia.onload = function () { - updateCanvasSize(); - generateAsciiArt(); - isAnimating = true; - animate(); - }; - if (isVideo) { - sourceMedia.onloadeddata = function () { - updateCanvasSize(); - generateAsciiArt(); - isAnimating = true; - animate(); - sourceMedia.play(); - }; - } - } - } - window.addEventListener("resize", function () { - chars = []; - generateAsciiArt(); - }); - initializeAscii(); - }; - loadElements(); - })(); - }); - if ( - document.readyState === "complete" || - document.readyState === "interactive" - ) { - setTimeout(function () { - const event = document.createEvent("Event"); - event.initEvent("DOMContentLoaded", true, true); - document.dispatchEvent(event); - }, 100); - } - }, []); - - return ( - - - - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/components/FixedOnboardingFooter.tsx b/packages/web/src/views/Onboarding/components/FixedOnboardingFooter.tsx deleted file mode 100644 index 179b21961..000000000 --- a/packages/web/src/views/Onboarding/components/FixedOnboardingFooter.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import React from "react"; -import styled from "styled-components"; -import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; -import { - OnboardingButton, - OnboardingNextButton, - OnboardingPreviousButton, -} from "."; - -const FixedFooterContainer = styled.div` - position: fixed; - bottom: 5px; - left: 20px; - right: 20px; - width: calc(100% - 40px); - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px; - z-index: 1000; -`; - -const SkipButtonContainer = styled.div` - display: flex; -`; - -const NavigationButtonsContainer = styled.div` - display: flex; - gap: 16px; -`; - -export const FixedOnboardingFooter = ({ - onSkip, - onPrev, - onNext, - hideSkip, - nextBtnDisabled, - prevBtnDisabled, -}: { - onSkip: () => void; - onPrev: () => void; - onNext: () => void; - hideSkip?: boolean; - nextBtnDisabled?: boolean; - prevBtnDisabled?: boolean; -}) => { - return ( - - {/* Skip button on the left */} - {!hideSkip ? ( - - - Skip Intro - - - ) : ( -
// Empty div to maintain space when skip is hidden - )} - - {/* Navigation buttons on the right */} - - - - - - - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/components/GuideInstructionContent.test.tsx b/packages/web/src/views/Onboarding/components/GuideInstructionContent.test.tsx deleted file mode 100644 index 4d47ea68c..000000000 --- a/packages/web/src/views/Onboarding/components/GuideInstructionContent.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import { OnboardingInstructionPart } from "../types/onboarding.guide.types"; -import { - GuideInstructionContent, - GuideSuccessMessage, -} from "./GuideInstructionContent"; - -describe("GuideInstructionContent", () => { - it("should render text parts as spans", () => { - const parts: OnboardingInstructionPart[] = [ - { type: "text", value: "Hello world" }, - ]; - - render(); - - expect(screen.getByText("Hello world")).toBeInTheDocument(); - }); - - it("should render kbd parts as kbd elements", () => { - const parts: OnboardingInstructionPart[] = [{ type: "kbd", value: "c" }]; - - const { container } = render( - , - ); - - const kbd = container.querySelector("kbd"); - expect(kbd).toBeInTheDocument(); - expect(kbd).toHaveTextContent("c"); - }); - - it("should render mixed instruction parts correctly", () => { - const parts: OnboardingInstructionPart[] = [ - { type: "text", value: "Press " }, - { type: "kbd", value: "2" }, - { type: "text", value: " to go to the Day view" }, - ]; - - const { container } = render( - , - ); - - // Check full text content - expect(container.textContent).toBe("Press 2 to go to the Day view"); - - const kbd = container.querySelector("kbd"); - expect(kbd).toHaveTextContent("2"); - - // Check individual span elements - const spans = container.querySelectorAll("span"); - expect(spans).toHaveLength(2); - }); - - it("should apply correct styles to kbd elements", () => { - const parts: OnboardingInstructionPart[] = [{ type: "kbd", value: "c" }]; - - const { container } = render( - , - ); - - const kbd = container.querySelector("kbd"); - expect(kbd).toHaveClass("bg-bg-secondary"); - expect(kbd).toHaveClass("text-text-light"); - expect(kbd).toHaveClass("font-mono"); - }); - - it("should handle empty instruction parts", () => { - const { container } = render( - , - ); - - expect(container.textContent).toBe(""); - }); -}); - -describe("GuideSuccessMessage", () => { - it("should render success message text when no import results", () => { - render(); - - expect(screen.getByText(/You're all set!/)).toBeInTheDocument(); - }); - - it("should render import results with events count", () => { - render(); - - expect(screen.getByText("Imported 5 events")).toBeInTheDocument(); - }); - - it("should render import results with singular event count", () => { - render(); - - expect(screen.getByText("Imported 1 event")).toBeInTheDocument(); - }); - - it("should render import results with calendars count", () => { - render(); - - expect(screen.getByText("Imported 3 calendars")).toBeInTheDocument(); - }); - - it("should render import results with singular calendar count", () => { - render(); - - expect(screen.getByText("Imported 1 calendar")).toBeInTheDocument(); - }); - - it("should render import results with both events and calendars", () => { - render( - , - ); - - expect( - screen.getByText("Imported 10 events from 2 calendars"), - ).toBeInTheDocument(); - }); - - it("should render import results with local events synced", () => { - render(); - - expect( - screen.getByText("4 local events synced to the cloud"), - ).toBeInTheDocument(); - }); - - it("should render import results with singular local event synced", () => { - render(); - - expect( - screen.getByText("1 local event synced to the cloud"), - ).toBeInTheDocument(); - }); - - it("should render import results with both import and local sync messages", () => { - const { container } = render( - , - ); - - expect( - screen.getByText("Imported 10 events from 2 calendars"), - ).toBeInTheDocument(); - expect( - screen.getByText("4 local events synced to the cloud"), - ).toBeInTheDocument(); - // Verify there's a line break between the two lines - expect(container.querySelector("br")).toBeInTheDocument(); - }); - - it("should not show local sync message when count is 0", () => { - render( - , - ); - - expect(screen.getByText("Imported 5 events")).toBeInTheDocument(); - expect( - screen.queryByText(/local event.*synced to the cloud/), - ).not.toBeInTheDocument(); - }); - - it("should render fallback message when import results are empty", () => { - render(); - - expect( - screen.getByText("Your calendar has been synced successfully!"), - ).toBeInTheDocument(); - }); -}); diff --git a/packages/web/src/views/Onboarding/components/GuideProgressIndicator.test.tsx b/packages/web/src/views/Onboarding/components/GuideProgressIndicator.test.tsx deleted file mode 100644 index 74d9e389c..000000000 --- a/packages/web/src/views/Onboarding/components/GuideProgressIndicator.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import { ONBOARDING_STEPS } from "../constants/onboarding.constants"; -import { markStepCompleted } from "../utils/onboarding.storage.util"; -import { GuideProgressIndicator } from "./GuideProgressIndicator"; - -describe("GuideProgressIndicator", () => { - beforeEach(() => { - localStorage.clear(); - }); - - it("should render step text", () => { - render( - , - ); - - expect(screen.getByText("Step 1 of 5")).toBeInTheDocument(); - }); - - it("should not render step text when omitted", () => { - const { container } = render( - , - ); - - expect(screen.queryByText(/Step \d+ of \d+/)).not.toBeInTheDocument(); - expect( - container.querySelectorAll("div[class*='rounded-full']"), - ).toHaveLength(0); - }); - - it("should render 5 progress dots", () => { - render( - , - ); - - const dots = screen - .getByText("Step 1 of 5") - .parentElement?.querySelectorAll("div[class*='rounded-full']"); - expect(dots).toHaveLength(5); - }); - - it("should show completed steps with accent color", () => { - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - markStepCompleted(ONBOARDING_STEPS.CREATE_TASK); - - const { container } = render( - , - ); - - const dots = container.querySelectorAll("div[class*='rounded-full']"); - // First two dots should be completed (bg-accent-primary without opacity) - expect(dots[0]).toHaveClass("bg-accent-primary"); - expect(dots[0]).not.toHaveClass("opacity-50"); - expect(dots[1]).toHaveClass("bg-accent-primary"); - expect(dots[1]).not.toHaveClass("opacity-50"); - }); - - it("should show current step with opacity", () => { - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - - const { container } = render( - , - ); - - const dots = container.querySelectorAll("div[class*='rounded-full']"); - // Second dot should be current (bg-accent-primary with opacity-50) - expect(dots[1]).toHaveClass("bg-accent-primary"); - expect(dots[1]).toHaveClass("opacity-50"); - }); - - it("should show all steps as completed when showing success message", () => { - const { container } = render( - , - ); - - expect(screen.getByText("All steps completed")).toBeInTheDocument(); - - const dots = container.querySelectorAll("div[class*='rounded-full']"); - dots.forEach((dot) => { - expect(dot).toHaveClass("bg-accent-primary"); - expect(dot).not.toHaveClass("opacity-50"); - }); - }); - - it("should show incomplete steps with border color", () => { - const { container } = render( - , - ); - - const dots = container.querySelectorAll("div[class*='rounded-full']"); - // Last few dots should be incomplete (bg-border-primary) - expect(dots[4]).toHaveClass("bg-border-primary"); - }); -}); diff --git a/packages/web/src/views/Onboarding/components/GuideSkipButton.test.tsx b/packages/web/src/views/Onboarding/components/GuideSkipButton.test.tsx deleted file mode 100644 index 0b2d16a58..000000000 --- a/packages/web/src/views/Onboarding/components/GuideSkipButton.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { GuideSkipButton } from "./GuideSkipButton"; - -describe("GuideSkipButton", () => { - it("should call onClick when clicked", async () => { - const user = userEvent.setup(); - const handleClick = jest.fn(); - - render( - , - ); - - await user.click(screen.getByLabelText("Skip guide")); - - expect(handleClick).toHaveBeenCalledTimes(1); - }); - - it("should have 'Skip guide' aria-label when not showing success message", () => { - render( - , - ); - - expect(screen.getByLabelText("Skip guide")).toBeInTheDocument(); - }); - - it("should have 'Dismiss' aria-label when showing success message", () => { - render( - , - ); - - expect(screen.getByLabelText("Dismiss")).toBeInTheDocument(); - }); - - it("should render 'Skip' text for Now view overlay", () => { - render( - , - ); - - expect(screen.getByText("Skip")).toBeInTheDocument(); - }); - - it("should render X icon for non-Now view overlay", () => { - const { container } = render( - , - ); - - const svg = container.querySelector("svg"); - expect(svg).toBeInTheDocument(); - }); - - it("should not render X icon for Now view overlay", () => { - const { container } = render( - , - ); - - const svg = container.querySelector("svg"); - expect(svg).not.toBeInTheDocument(); - }); -}); diff --git a/packages/web/src/views/Onboarding/components/IconButtons.tsx b/packages/web/src/views/Onboarding/components/IconButtons.tsx deleted file mode 100644 index a4e06d54a..000000000 --- a/packages/web/src/views/Onboarding/components/IconButtons.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import styled, { css, keyframes } from "styled-components"; -import { OnboardingButton } from "./styled"; - -// Keyframes for pulsing animation -const pulse = keyframes` - 0% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.05); - opacity: 0.8; - } - 100% { - transform: scale(1); - opacity: 1; - } -`; - -const IconButton = styled(OnboardingButton)<{ $shouldPulse?: boolean }>` - padding: 0; - min-width: 0; - width: 24px; - height: 24px; - display: inline-flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease-in-out; - svg { - display: block; - } - - ${({ $shouldPulse }) => - $shouldPulse && - css` - animation: ${pulse} 2s ease-in-out infinite; - `} - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; - } -`; - -interface OnboardingNextButtonProps - extends React.ButtonHTMLAttributes { - shouldTrapFocus?: boolean; - shouldPulse?: boolean; -} - -export const OnboardingNextButton: React.FC = ({ - shouldTrapFocus = false, - shouldPulse = false, - ...props -}) => { - const buttonRef = useRef(null); - - useEffect(() => { - if (shouldTrapFocus && buttonRef.current) { - // Focus the button when focus trapping is enabled - buttonRef.current.focus(); - } - }, [shouldTrapFocus]); - - return ( - - - - - - ); -}; - -export const OnboardingPreviousButton: React.FC< - React.ButtonHTMLAttributes -> = (props) => { - return ( - - - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/components/Logo.tsx b/packages/web/src/views/Onboarding/components/Logo.tsx deleted file mode 100644 index 4068e1f39..000000000 --- a/packages/web/src/views/Onboarding/components/Logo.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from "react"; -import logo from "@web/assets/png/logo.png"; - -export const Logo = (props: React.ImgHTMLAttributes) => { - return Compass; -}; diff --git a/packages/web/src/views/Onboarding/components/Onboarding.tsx b/packages/web/src/views/Onboarding/components/Onboarding.tsx deleted file mode 100644 index 1728c058e..000000000 --- a/packages/web/src/views/Onboarding/components/Onboarding.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useState } from "react"; -import styled from "styled-components"; -import { useOnboardingShortcuts } from "../hooks/useOnboardingShortcuts"; - -export const OnboardingRoot = styled.div` - // background: rgba(0, 0, 0, 0.8); - position: relative; - height: 100vh; - width: 100vw; -`; - -interface OnboardingContainerProps { - fullWidth?: boolean; -} - -export const OnboardingContainer = styled.div` - background-color: #12151b; - position: absolute; - width: ${({ fullWidth }) => (fullWidth ? "100vw" : "900px")}; - height: ${({ fullWidth }) => (fullWidth ? "100vh" : "800px")}; - border-radius: ${({ fullWidth }) => (fullWidth ? "0" : "44px")}; - top: ${({ fullWidth }) => (fullWidth ? "0" : "50%")}; - left: ${({ fullWidth }) => (fullWidth ? "0" : "50%")}; - transform: ${({ fullWidth }) => - fullWidth ? "none" : "translate(-50%, -50%)"}; - display: flex; - align-items: center; - flex-direction: column; - justify-content: center; - box-shadow: ${({ fullWidth }) => - fullWidth ? "none" : "rgb(255 255 255 / 15%) 0px 9px 20px 1px"}; - - @keyframes flicker { - 0%, - 100% { - opacity: 1; - } - 2% { - opacity: 0.8; - } - 4% { - opacity: 1; - } - 8% { - opacity: 0.9; - } - 10% { - opacity: 1; - } - 12% { - opacity: 0.95; - } - 14% { - opacity: 1; - } - 16% { - opacity: 0.85; - } - 18% { - opacity: 1; - } - 20% { - opacity: 0.9; - } - 22% { - opacity: 1; - } - } - - animation: ${({ fullWidth }) => - fullWidth ? "none" : "flicker 0.5s infinite"}; - - &::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(transparent 50%, rgba(0, 255, 0, 0.03) 50%); - background-size: 100% 4px; - pointer-events: none; - z-index: 1; - border-radius: ${({ fullWidth }) => (fullWidth ? "0" : "44px")}; - } -`; - -export interface OnboardingStepProps { - currentStep: number; - totalSteps: number; - onNext: (data?: Record) => void; - onPrevious: () => void; - onComplete: () => void; - onSkip: () => void; - // New props for keyboard control - canNavigateNext?: boolean; - isNextBtnDisabled?: boolean; - onNavigationControlChange?: (shouldPrevent: boolean) => void; - isNavPrevented?: boolean; - handlesKeyboardEvents?: boolean; -} - -export interface OnboardingStep { - id: string; - component: React.ComponentType; - onNext?: (data?: Record) => void; - disablePrevious?: boolean; - disableRightArrow?: boolean; - // Navigation control properties - preventNavigation?: boolean; - nextButtonDisabled?: boolean; - canNavigateNext?: boolean; - handlesKeyboardEvents?: boolean; -} - -interface Props { - steps: OnboardingStep[]; - onComplete: (reason: "skip" | "complete") => void; - initialStepIndex?: number; -} - -export const Onboarding: React.FC = ({ - steps, - onComplete, - initialStepIndex = 0, -}) => { - const [currentStepIndex, setCurrentStepIndex] = useState(initialStepIndex); - const [isNavPrevented, setIsNavPrevented] = useState(false); - - const handleNext = (data?: Record) => { - // Call `onNext` if provided - steps[currentStepIndex].onNext?.(data); - - if (currentStepIndex < steps.length - 1) { - setCurrentStepIndex(currentStepIndex + 1); - } else { - onComplete("complete"); - } - }; - - const handlePrevious = () => { - // Check if the current step disables previous navigation - if (currentStep.disablePrevious) { - return; - } - - if (currentStepIndex > 0) { - setCurrentStepIndex(currentStepIndex - 1); - } - }; - - const handleComplete = () => { - onComplete("complete"); - }; - - const handleSkip = () => { - onComplete("skip"); - }; - - const currentStep = steps[currentStepIndex]; - // Get navigation control from step configuration - const preventNavigation = currentStep.preventNavigation || false; - const isNextBtnDisabled = currentStep.nextButtonDisabled || false; - const canNavigateNext = currentStep.canNavigateNext !== false; // Default to true - const handlesKeyboardEvents = currentStep.handlesKeyboardEvents || false; - const StepComponent = currentStep?.component; - - useOnboardingShortcuts({ - onNext: handleNext, - onPrevious: handlePrevious, - canNavigateNext, - shouldPreventNavigation: preventNavigation ? isNavPrevented : false, - handlesKeyboardEvents, - disablePrevious: currentStep.disablePrevious || false, - }); - - if (!StepComponent) { - return null; - } - - // Handle navigation control changes from steps - const handleNavigationControlChange = (shouldPrevent: boolean) => { - setIsNavPrevented(shouldPrevent); - }; - - const stepProps: OnboardingStepProps = { - currentStep: currentStepIndex + 1, - totalSteps: steps.length, - onNext: handleNext, - onPrevious: handlePrevious, - onComplete: handleComplete, - onSkip: handleSkip, - canNavigateNext, - isNextBtnDisabled, - onNavigationControlChange: handleNavigationControlChange, - isNavPrevented: preventNavigation ? isNavPrevented : false, - handlesKeyboardEvents, - }; - - return ( - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/components/OnboardingContext.tsx b/packages/web/src/views/Onboarding/components/OnboardingContext.tsx deleted file mode 100644 index 8d90b04c1..000000000 --- a/packages/web/src/views/Onboarding/components/OnboardingContext.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React, { createContext, useContext, useState } from "react"; - -interface OnboardingContextType { - hideSteps: boolean; - setHideSteps: (hideSteps: boolean) => void; - firstName?: string; - setFirstName: (firstName: string) => void; -} - -const OnboardingContext = createContext( - undefined, -); - -interface OnboardingProviderProps { - children: React.ReactNode; - defaultValues?: { - hideSteps?: boolean; - firstName?: string; - }; -} - -export const OnboardingProvider: React.FC = ({ - children, - defaultValues, -}) => { - const [hideSteps, setHideSteps] = useState(defaultValues?.hideSteps ?? false); - const [firstName, setFirstName] = useState( - defaultValues?.firstName, - ); - const value = { - hideSteps, - setHideSteps, - firstName, - setFirstName, - }; - - return ( - - {children} - - ); -}; - -export const useOnboarding = (): OnboardingContextType => { - const context = useContext(OnboardingContext); - if (context === undefined) { - throw new Error("useOnboarding must be used within an OnboardingProvider"); - } - return context; -}; - -export const withOnboardingProvider = ( - Component: React.ComponentType, -) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const WrappedWithOnboardingProvider = (props: any) => { - return ( - - - - ); - }; - WrappedWithOnboardingProvider.displayName = `WithOnboardingProvider(${Component.displayName || Component.name || "Component"})`; - return WrappedWithOnboardingProvider; -}; diff --git a/packages/web/src/views/Onboarding/components/OnboardingFooter.tsx b/packages/web/src/views/Onboarding/components/OnboardingFooter.tsx deleted file mode 100644 index 0f4f4aa53..000000000 --- a/packages/web/src/views/Onboarding/components/OnboardingFooter.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import React from "react"; -import { - OnboardingButton, - OnboardingNextButton, - OnboardingPreviousButton, -} from "."; - -export const OnboardingFooter = ({ - onSkip, - onPrev, - onNext, - hideSkip, - nextBtnDisabled, - prevBtnDisabled, -}: { - onSkip: () => void; - onPrev: () => void; - onNext: () => void; - hideSkip?: boolean; - nextBtnDisabled?: boolean; - prevBtnDisabled?: boolean; -}) => { - return ( - - ); -}; diff --git a/packages/web/src/views/Onboarding/components/OnboardingForm.tsx b/packages/web/src/views/Onboarding/components/OnboardingForm.tsx deleted file mode 100644 index a6895e4aa..000000000 --- a/packages/web/src/views/Onboarding/components/OnboardingForm.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import styled from "styled-components"; - -export const OnboardingForm = styled.form` - width: 100%; -`; diff --git a/packages/web/src/views/Onboarding/components/OnboardingGuide.test.tsx b/packages/web/src/views/Onboarding/components/OnboardingGuide.test.tsx deleted file mode 100644 index e94d57d0c..000000000 --- a/packages/web/src/views/Onboarding/components/OnboardingGuide.test.tsx +++ /dev/null @@ -1,651 +0,0 @@ -import { useLocation } from "react-router-dom"; -import "@testing-library/jest-dom"; -import userEvent from "@testing-library/user-event"; -import { act, render, screen } from "@web/__tests__/__mocks__/mock.render"; -import { useSession } from "@web/auth/hooks/session/useSession"; -import { - getDateKey, - loadTasksFromStorage, -} from "@web/common/utils/storage/storage.util"; -import { ONBOARDING_STEPS } from "../constants/onboarding.constants"; -import { useCmdPaletteGuide } from "../hooks/useCmdPaletteGuide"; -import { useStepDetection } from "../hooks/useStepDetection"; -import { markStepCompleted } from "../utils/onboarding.storage.util"; -import { OnboardingGuide } from "./OnboardingGuide"; - -// Mock posthog to avoid transitive dependency issues in tests -jest.mock("posthog-js/react", () => ({ - usePostHog: () => ({ - identify: jest.fn(), - capture: jest.fn(), - }), - PostHogProvider: ({ children }: { children: React.ReactNode }) => children, -})); - -// Mock the hooks before importing the component -jest.mock("react-router-dom", () => ({ - useLocation: jest.fn(), -})); -jest.mock("@web/auth/hooks/session/useSession"); -jest.mock("../hooks/useCmdPaletteGuide"); -jest.mock("../hooks/useStepDetection"); -jest.mock("@web/common/utils/storage/storage.util", () => ({ - getDateKey: jest.fn(() => "2024-01-01"), - loadTasksFromStorage: jest.fn(() => []), -})); - -const mockUseLocation = useLocation as jest.MockedFunction; -const mockUseSession = useSession as jest.MockedFunction; -const mockUseCmdPaletteGuide = useCmdPaletteGuide as jest.MockedFunction< - typeof useCmdPaletteGuide ->; -const mockUseStepDetection = useStepDetection as jest.MockedFunction< - typeof useStepDetection ->; - -const mockGetDateKey = getDateKey as jest.MockedFunction; -const mockLoadTasksFromStorage = loadTasksFromStorage as jest.MockedFunction< - typeof loadTasksFromStorage ->; - -describe("CmdPaletteGuide", () => { - beforeEach(() => { - jest.clearAllMocks(); - localStorage.clear(); - mockUseStepDetection.mockImplementation(() => {}); - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: jest.fn(), - }); - mockGetDateKey.mockReturnValue("2024-01-01"); - mockLoadTasksFromStorage.mockReturnValue([]); // No tasks by default - }); - - it("should not render when guide is not active", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: null, - isGuideActive: false, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.queryByText("Welcome to Compass")).not.toBeInTheDocument(); - expect( - screen.queryByText("Welcome to the Day View"), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("Welcome to the Now View"), - ).not.toBeInTheDocument(); - }); - - it("should render create task instructions on Day view when day step is completed", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockLoadTasksFromStorage.mockReturnValue([]); // No tasks yet - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.CREATE_TASK, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Welcome to the Day View")).toBeInTheDocument(); - expect( - screen.getByText( - (_, element) => - element?.textContent === "Type c to create a task" ?? false, - ), - ).toBeInTheDocument(); - expect(screen.getByText("c")).toBeInTheDocument(); // The kbd element - expect(screen.getByLabelText("Skip guide")).toBeInTheDocument(); - }); - - it("should render step 1 on Now view when on step 1", () => { - mockUseLocation.mockReturnValue({ pathname: "/now" } as any); - mockLoadTasksFromStorage.mockReturnValue([]); // Step 1 not completed - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_DAY, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Welcome to the Now View")).toBeInTheDocument(); - expect( - screen.getByText( - (_, element) => - element?.textContent === "Press 2 to go to the Day view" ?? false, - ), - ).toBeInTheDocument(); - }); - - it("should render step 3 instructions on Now view when day and task steps are completed", () => { - mockUseLocation.mockReturnValue({ pathname: "/now" } as any); - mockLoadTasksFromStorage.mockReturnValue([ - { - id: "task-1", - title: "Test task", - status: "todo", - order: 0, - createdAt: new Date().toISOString(), - }, - ] as any); // Step 1 completed - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - markStepCompleted(ONBOARDING_STEPS.CREATE_TASK); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_NOW, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Welcome to the Now View")).toBeInTheDocument(); - expect( - screen.getByText( - (_, element) => - element?.textContent === "Press 1 to go to the Now view" ?? false, - ), - ).toBeInTheDocument(); - expect(screen.getByText("1")).toBeInTheDocument(); // The kbd element - expect(screen.getByText("Step 3 of 5")).toBeInTheDocument(); - }); - - it("should show step 1 instructions on Now view when step 1 is not completed", () => { - mockUseLocation.mockReturnValue({ pathname: "/now" } as any); - mockLoadTasksFromStorage.mockReturnValue([]); // Step 1 not completed - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_NOW, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Welcome to the Now View")).toBeInTheDocument(); - expect( - screen.getByText( - (_, element) => - element?.textContent === "Press 2 to go to the Day view" ?? false, - ), - ).toBeInTheDocument(); - expect(screen.getByText("Step 1 of 5")).toBeInTheDocument(); - }); - - it("should render step 1 instructions on Week view", () => { - mockUseLocation.mockReturnValue({ pathname: "/" } as any); - mockLoadTasksFromStorage.mockReturnValue([]); // Step 1 not completed - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_DAY, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Welcome to the Week View")).toBeInTheDocument(); - expect( - screen.getByText( - (_, element) => - element?.textContent === "Press 2 to go to the Day view" ?? false, - ), - ).toBeInTheDocument(); - }); - - it("should render step 3 instructions on Week view", () => { - mockUseLocation.mockReturnValue({ pathname: "/" } as any); - mockLoadTasksFromStorage.mockReturnValue([ - { - id: "task-1", - title: "Test task", - status: "todo", - order: 0, - createdAt: new Date().toISOString(), - }, - ] as any); - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - markStepCompleted(ONBOARDING_STEPS.CREATE_TASK); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_NOW, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Welcome to the Week View")).toBeInTheDocument(); - expect( - screen.getByText( - (_, element) => - element?.textContent === "Press 1 to go to the Now view" ?? false, - ), - ).toBeInTheDocument(); - }); - - it("should render step 3 instructions on Day view when day and task steps are completed", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockLoadTasksFromStorage.mockReturnValue([ - { - id: "task-1", - title: "Test task", - status: "todo", - order: 0, - createdAt: new Date().toISOString(), - }, - ] as any); // Step 1 completed - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - markStepCompleted(ONBOARDING_STEPS.CREATE_TASK); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_NOW, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Welcome to the Day View")).toBeInTheDocument(); - expect( - screen.getByText( - (_, element) => - element?.textContent === "Press 1 to go to the Now view" ?? false, - ), - ).toBeInTheDocument(); - expect(screen.getByText("1")).toBeInTheDocument(); // The kbd element - }); - - it("should show step 1 instructions on Day view when step 1 is not completed", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockLoadTasksFromStorage.mockReturnValue([]); // Step 1 not completed - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_NOW, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - // Should show step 1 instructions instead since step 1 wasn't completed - expect(screen.getByText("Welcome to the Day View")).toBeInTheDocument(); - expect( - screen.getAllByText( - (_, element) => - element?.textContent === "You're already on the Day view." ?? false, - )[0], - ).toBeInTheDocument(); - expect(screen.getByText("Step 1 of 5")).toBeInTheDocument(); - }); - - it("should render on Day view when authenticated", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - const mockSession = { - authenticated: true, - setAuthenticated: jest.fn(), - }; - mockUseSession.mockReturnValue(mockSession); - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.CREATE_TASK, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Welcome to the Day View")).toBeInTheDocument(); - expect( - screen.getByText( - (_, element) => - element?.textContent === "Type c to create a task" ?? false, - ), - ).toBeInTheDocument(); - }); - - it("should call skipGuide when skip button is clicked on Day view", async () => { - const user = userEvent.setup(); - const skipGuide = jest.fn(); - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.CREATE_TASK, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide, - completeGuide: jest.fn(), - }); - - render(); - - const skipButton = screen.getByLabelText("Skip guide"); - await user.click(skipButton); - - expect(skipGuide).toHaveBeenCalledTimes(1); - }); - - it("should call skipGuide when skip button is clicked on Now view", async () => { - const user = userEvent.setup(); - const skipGuide = jest.fn(); - mockUseLocation.mockReturnValue({ pathname: "/now" } as any); - mockLoadTasksFromStorage.mockReturnValue([ - { - id: "task-1", - title: "Test task", - status: "todo", - order: 0, - createdAt: new Date().toISOString(), - }, - ] as any); // Step 1 completed - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - markStepCompleted(ONBOARDING_STEPS.CREATE_TASK); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_NOW, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide, - completeGuide: jest.fn(), - }); - - render(); - - const skipButton = screen.getByLabelText("Skip guide"); - await user.click(skipButton); - - expect(skipGuide).toHaveBeenCalledTimes(1); - }); - - it("should show progress indicators correctly on Now view", () => { - mockUseLocation.mockReturnValue({ pathname: "/now" } as any); - mockLoadTasksFromStorage.mockReturnValue([ - { - id: "task-1", - title: "Test task", - status: "todo", - order: 0, - createdAt: new Date().toISOString(), - }, - ] as any); // Step 1 completed - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - markStepCompleted(ONBOARDING_STEPS.CREATE_TASK); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_NOW, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - // Check that progress dots are rendered - const progressDots = screen - .getByText("Step 3 of 5") - .parentElement?.querySelectorAll("div[class*='rounded-full']"); - expect(progressDots).toHaveLength(5); - }); - - it("should show progress indicators on Day view", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - markStepCompleted(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.CREATE_TASK, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(screen.getByText("Step 2 of 5")).toBeInTheDocument(); - // Check that progress dots are rendered - const progressDots = screen - .getByText("Step 2 of 5") - .parentElement?.querySelectorAll("div[class*='rounded-full']"); - expect(progressDots).toHaveLength(5); - }); - - it("should call unified step detection hook", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.CREATE_TASK, - isGuideActive: true, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(mockUseStepDetection).toHaveBeenCalled(); - }); - - it("should pass correct props to unified step detection hook", () => { - const completeStep = jest.fn(); - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.CREATE_TASK, - isGuideActive: true, - completeStep, - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(); - - expect(mockUseStepDetection).toHaveBeenCalledWith({ - currentStep: ONBOARDING_STEPS.CREATE_TASK, - onStepComplete: expect.any(Function), - }); - }); - - describe("Import Results", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("should show success message with import results when import completes", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: null, - isGuideActive: false, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(, { - state: { - sync: { - importGCal: { - importing: false, - importResults: { eventsCount: 10, calendarsCount: 2 }, - pendingLocalEventsSynced: null, - awaitingImportResults: false, - importError: null, - }, - }, - }, - }); - - expect(screen.getByText("Welcome to Compass")).toBeInTheDocument(); - expect( - screen.getByText("Imported 10 events from 2 calendars"), - ).toBeInTheDocument(); - expect(screen.queryByText(/Step \d+ of \d+/)).not.toBeInTheDocument(); - }); - - it("should show import results with local events synced", () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: null, - isGuideActive: false, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(, { - state: { - sync: { - importGCal: { - importing: false, - importResults: { - eventsCount: 5, - calendarsCount: 1, - localEventsSynced: 3, - }, - pendingLocalEventsSynced: null, - awaitingImportResults: false, - importError: null, - }, - }, - }, - }); - - expect( - screen.getByText("Imported 5 events from 1 calendar"), - ).toBeInTheDocument(); - expect( - screen.getByText("3 local events synced to the cloud"), - ).toBeInTheDocument(); - }); - - it("should auto-dismiss after 8 seconds", async () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: null, - isGuideActive: false, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - render(, { - state: { - sync: { - importGCal: { - importing: false, - importResults: { eventsCount: 10, calendarsCount: 2 }, - pendingLocalEventsSynced: null, - awaitingImportResults: false, - importError: null, - }, - }, - }, - }); - - expect( - screen.getByText("Imported 10 events from 2 calendars"), - ).toBeInTheDocument(); - - await act(async () => { - jest.advanceTimersByTime(8000); - }); - - // After auto-dismiss, the success message should no longer be visible - expect( - screen.queryByText("Imported 10 events from 2 calendars"), - ).not.toBeInTheDocument(); - }); - - it("should clear auto-dismiss timer on unmount", async () => { - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: null, - isGuideActive: false, - completeStep: jest.fn(), - skipGuide: jest.fn(), - completeGuide: jest.fn(), - }); - - const { unmount } = render(, { - state: { - sync: { - importGCal: { - importing: false, - importResults: { eventsCount: 10, calendarsCount: 2 }, - pendingLocalEventsSynced: null, - awaitingImportResults: false, - importError: null, - }, - }, - }, - }); - - await act(async () => { - jest.advanceTimersByTime(4000); - }); - unmount(); - - // Advancing timers after unmount should not cause any errors - await act(async () => { - jest.advanceTimersByTime(4000); - }); - }); - - it("should dismiss import results when skip button is clicked", async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - const skipGuide = jest.fn(); - mockUseLocation.mockReturnValue({ pathname: "/day" } as any); - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: null, - isGuideActive: false, - completeStep: jest.fn(), - skipGuide, - completeGuide: jest.fn(), - }); - - render(, { - state: { - sync: { - importGCal: { - importing: false, - importResults: { eventsCount: 10, calendarsCount: 2 }, - pendingLocalEventsSynced: null, - awaitingImportResults: false, - importError: null, - }, - }, - }, - }); - - expect( - screen.getByText("Imported 10 events from 2 calendars"), - ).toBeInTheDocument(); - - const dismissButton = screen.getByLabelText("Dismiss"); - await user.click(dismissButton); - - expect(skipGuide).not.toHaveBeenCalled(); - expect( - screen.queryByText("Imported 10 events from 2 calendars"), - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/components/OnboardingOverlay/OnboardingNoticeCard.tsx b/packages/web/src/views/Onboarding/components/OnboardingOverlay/OnboardingNoticeCard.tsx deleted file mode 100644 index 955d47e20..000000000 --- a/packages/web/src/views/Onboarding/components/OnboardingOverlay/OnboardingNoticeCard.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { OnboardingNotice } from "@web/views/Onboarding/types/onboarding-notice.types"; - -interface OnboardingNoticeCardProps { - notice: OnboardingNotice; -} - -export const OnboardingNoticeCard = ({ notice }: OnboardingNoticeCardProps) => { - const { header, body, primaryAction, secondaryAction } = notice; - - return ( -
-
-

{header}

-

{body}

-
- {(primaryAction || secondaryAction) && ( -
- {primaryAction && ( - - )} - {secondaryAction && ( - - )} -
- )} -
- ); -}; diff --git a/packages/web/src/views/Onboarding/components/OnboardingStep.tsx b/packages/web/src/views/Onboarding/components/OnboardingStep.tsx deleted file mode 100644 index f8ad88226..000000000 --- a/packages/web/src/views/Onboarding/components/OnboardingStep.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; -import { ProgressDot, ProgressIndicator, useOnboarding } from "."; - -interface Props { - currentStep: number; - totalSteps: number; - style?: React.CSSProperties; -} - -export const OnboardingStep: React.FC = ({ - currentStep, - totalSteps, - style, -}) => { - const { hideSteps } = useOnboarding(); - - return ( -
- {!hideSteps && ( - - {Array.from({ length: totalSteps }, (_, index) => ( - - ))} - - )} -
- ); -}; diff --git a/packages/web/src/views/Onboarding/components/index.ts b/packages/web/src/views/Onboarding/components/index.ts deleted file mode 100644 index 98ffbb871..000000000 --- a/packages/web/src/views/Onboarding/components/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./IconButtons"; -export * from "./styled"; -export * from "./OnboardingFooter"; -export * from "./FixedOnboardingFooter"; -export * from "./layouts/OnboardingCardLayout"; -export * from "./layouts/OnboardingTwoRowLayout"; -export * from "./Logo"; -export * from "./OnboardingContext"; -export * from "./DynamicLogo"; diff --git a/packages/web/src/views/Onboarding/components/layouts/OnboardingCardLayout.tsx b/packages/web/src/views/Onboarding/components/layouts/OnboardingCardLayout.tsx deleted file mode 100644 index b8311a262..000000000 --- a/packages/web/src/views/Onboarding/components/layouts/OnboardingCardLayout.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { FixedOnboardingFooter } from "../FixedOnboardingFooter"; -import { OnboardingContainer, OnboardingStepProps } from "../Onboarding"; -import { OnboardingStep } from "../OnboardingStep"; -import { OnboardingCard, OnboardingContent } from "../styled"; - -const StyledOnboardingCard = styled(OnboardingCard)` - padding-bottom: 100px; - margin-top: 60px; - height: 600px; - min-height: 600px; -`; - -interface OnboardingStepBoilerplateProps - extends Omit { - children: React.ReactNode; - hideSkip?: boolean; - nextBtnDisabled?: boolean; - prevBtnDisabled?: boolean; - showFooter?: boolean; -} - -export const OnboardingCardLayout = (props: OnboardingStepBoilerplateProps) => { - const { - currentStep, - totalSteps, - children, - hideSkip, - nextBtnDisabled, - prevBtnDisabled, - showFooter = true, - onNext, - onPrevious, - onSkip, - } = props; - - return ( - <> - - - - {children} - {showFooter && ( - - )} - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/components/layouts/OnboardingTwoRowLayout.tsx b/packages/web/src/views/Onboarding/components/layouts/OnboardingTwoRowLayout.tsx deleted file mode 100644 index d8b86561a..000000000 --- a/packages/web/src/views/Onboarding/components/layouts/OnboardingTwoRowLayout.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, { useEffect } from "react"; -import styled from "styled-components"; -import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; -import { OnboardingNextButton, OnboardingPreviousButton } from "../IconButtons"; -import { OnboardingContainer, OnboardingStepProps } from "../Onboarding"; -import { OnboardingStep } from "../OnboardingStep"; -import { OnboardingButton } from "../styled"; - -const TwoRowLayoutContainer = styled.div` - display: flex; - flex-direction: column; - width: 100%; - gap: 20px; -`; - -const Content = styled.div` - flex: 1; - display: flex; - width: 100%; - min-height: 400px; -`; - -const SkipButton = styled(OnboardingButton)` - position: absolute; - bottom: 20px; - left: 20px; -`; - -const NavigationButtons = styled.div` - position: absolute; - bottom: 20px; - right: 20px; - display: flex; - gap: 8px; -`; - -interface OnboardingTwoRowLayoutProps - extends Omit< - OnboardingStepProps, - "onNext" | "onPrevious" | "onComplete" | "onSkip" - > { - content: React.ReactNode; - onNext: () => void; - onPrevious: () => void; - onSkip: () => void; - isNextBtnDisabled?: boolean; - isPrevBtnDisabled?: boolean; - defaultPreventNavigation?: boolean; - onNavigationControlChange?: (shouldPrevent: boolean) => void; - isNavPrevented?: boolean; -} - -export const OnboardingTwoRowLayout: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, - content, - isNextBtnDisabled = false, - isPrevBtnDisabled = false, - onNavigationControlChange, - isNavPrevented = false, -}) => { - // Pass the navigation control function to parent components - useEffect(() => { - if (onNavigationControlChange) { - onNavigationControlChange(isNavPrevented); - } - }, [onNavigationControlChange, isNavPrevented]); - - return ( - - - - {content} - SKIP INTRO - - - - - - - - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/components/styled.ts b/packages/web/src/views/Onboarding/components/styled.ts deleted file mode 100644 index 357e76409..000000000 --- a/packages/web/src/views/Onboarding/components/styled.ts +++ /dev/null @@ -1,160 +0,0 @@ -import styled, { css } from "styled-components"; - -export const OnboardingText = styled.p` - font-family: "VT323", monospace; - font-size: 24px; - color: ${({ theme }) => theme.color.common.white}; -`; - -export const OnboardingCard = styled.div<{ hideBorder?: boolean }>` - background-color: #12151b; - border: ${({ hideBorder }) => (hideBorder ? "none" : "2px solid #ffffff")}; - border-radius: 4px; - padding: ${({ theme }) => theme.spacing.xl}; - width: 500px; - max-width: 90vw; - max-height: 90vh; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - position: relative; -`; - -export const OnboardingContent = styled.div` - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: ${({ theme }) => theme.spacing.l}; - flex: 1; - min-height: 0; -`; - -export const OnboardingInputContainer = styled.div` - margin-bottom: 40px; -`; - -export const OnboardingInputSection = styled.div` - width: 100%; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: ${({ theme }) => theme.spacing.s}; - padding-bottom: 20px; -`; - -export const OnboardingInputLabel = styled.label` - font-family: "VT323", monospace; - color: ${({ theme }) => theme.color.common.white}; - font-size: ${({ theme }) => theme.text.size.xxl}; -`; -const OnboardingInputBase = css` - font-family: "VT323", monospace; - font-size: ${({ theme }) => theme.text.size.xxl}; - width: 100%; - background: transparent; - color: ${({ theme }) => theme.color.common.white}; - border: 1px solid ${({ theme }) => theme.color.common.white}; - border-radius: ${({ theme }) => theme.shape.borderRadius}; - padding: ${({ theme }) => theme.spacing.s} ${({ theme }) => theme.spacing.m}; - transition: border-color ${({ theme }) => theme.transition.default}; - - &::placeholder { - color: ${({ theme }) => theme.color.text.darkPlaceholder}; - } - &:focus { - border-color: ${({ theme }) => theme.color.text.accent}; - } -`; - -export const OnboardingInput = styled.input` - ${OnboardingInputBase} -`; - -export const OnboardingTextarea = styled.textarea` - ${OnboardingInputBase} -`; - -export const OnboardingTextareaWhite = styled(OnboardingTextarea)` - background-color: ${({ theme }) => theme.color.common.white}; - border-radius: 0; - color: ${({ theme }) => theme.color.common.black}; - box-shadow: - rgb(95 95 95) 1px 1px 0px 1px inset, - rgb(255 255 255) 1px 1px 0px 1px; -`; - -export const ProgressIndicator = styled.div` - display: flex; - align-items: flex-end; - justify-content: center; - gap: 3px; - margin-top: ${({ theme }) => theme.spacing.l}; -`; - -export const ProgressDot = styled.div<{ isActive: boolean }>` - width: 14px; - height: 20px; - border-bottom: 2px solid ${({ theme }) => theme.color.common.white}; - border-left: 2px solid rgb(163 163 163); - border-top: 2px solid rgb(163 163 163); - border-right: 2px solid ${({ theme }) => theme.color.common.white}; - border-radius: 2px; - background-color: ${({ isActive, theme }) => - isActive ? theme.color.text.accent : "#395264"}; - transition: background-color ${({ theme }) => theme.transition.default}; -`; - -export const OnboardingButton = styled.button` - font-family: "VT323", monospace; - border: none; - border-radius: 0; - padding: ${({ theme }) => theme.spacing.s} ${({ theme }) => theme.spacing.xl}; - font-size: ${({ theme }) => theme.text.size.m}; - cursor: pointer; - min-width: 80px; - background-color: ${({ theme }) => theme.color.common.white}; - color: ${({ theme }) => theme.color.common.black}; - - &:hover { - background: #d4d4d4; - } - - &:disabled { - background: #888888; - cursor: not-allowed; - } - - &:focus { - outline: 1px solid ${({ theme }) => theme.color.text.accent}; - outline-offset: 1px; - } -`; - -export const OnboardingLink = styled.a` - font-family: "VT323", monospace; - color: #222222; - text-decoration: none; - font-size: ${({ theme }) => theme.text.size.m}; - background: #c0c0c0; - padding: 8px 8px; - display: inline-block; - position: relative; - border: none; - box-shadow: - inset -2px -2px #808080, - inset 2px 2px #dfdfdf, - inset -1px -1px #0a0a0a, - inset 1px 1px #ffffff; - cursor: pointer; - - &:active { - box-shadow: - inset 2px 2px #808080, - inset -2px -2px #dfdfdf, - inset 1px 1px #0a0a0a, - inset -1px -1px #ffffff; - } -`; diff --git a/packages/web/src/views/Onboarding/hooks/useAuthPrompt.test.tsx b/packages/web/src/views/Onboarding/hooks/useAuthPrompt.test.tsx deleted file mode 100644 index 74d42156d..000000000 --- a/packages/web/src/views/Onboarding/hooks/useAuthPrompt.test.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { Provider } from "react-redux"; -import { configureStore } from "@reduxjs/toolkit"; -import { renderHook, waitFor } from "@testing-library/react"; -import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; -import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; -import { useAuthPrompt } from "@web/views/Onboarding/hooks/useAuthPrompt"; -import { updateOnboardingProgress } from "@web/views/Onboarding/utils/onboarding.storage.util"; - -// Mock useSession -jest.mock("@web/auth/hooks/session/useSession", () => ({ - useSession: jest.fn(() => ({ - authenticated: false, - setAuthenticated: jest.fn(), - })), -})); - -const createTestStore = (isCmdPaletteOpen = false) => { - return configureStore({ - reducer: { - settings: settingsSlice.reducer, - }, - preloadedState: { - settings: { - isCmdPaletteOpen: isCmdPaletteOpen, - }, - }, - }); -}; - -describe("useAuthPrompt", () => { - beforeEach(() => { - localStorage.clear(); - }); - - it("should show auth prompt after user creates 2+ tasks", async () => { - const store = createTestStore(); - - const { result } = renderHook( - () => - useAuthPrompt({ - tasks: [{ id: "1" }, { id: "2" }], - hasNavigatedDates: false, - showOnboardingOverlay: false, - }), - { - wrapper: ({ children }) => ( - {children} - ), - }, - ); - - await waitFor( - () => { - expect(result.current.showAuthPrompt).toBe(true); - }, - { timeout: 3000 }, - ); - }); - - it("should show auth prompt after user navigates dates", async () => { - const store = createTestStore(); - - const { result } = renderHook( - () => - useAuthPrompt({ - tasks: [], - hasNavigatedDates: true, - showOnboardingOverlay: false, - }), - { - wrapper: ({ children }) => ( - {children} - ), - }, - ); - - await waitFor( - () => { - expect(result.current.showAuthPrompt).toBe(true); - }, - { timeout: 3000 }, - ); - }); - - it("should not show auth prompt if dismissed", () => { - updateOnboardingProgress({ isAuthPromptDismissed: true }); - const store = createTestStore(); - - const { result } = renderHook( - () => - useAuthPrompt({ - tasks: [{ id: "1" }, { id: "2" }], - hasNavigatedDates: false, - showOnboardingOverlay: false, - }), - { - wrapper: ({ children }) => ( - {children} - ), - }, - ); - - expect(result.current.showAuthPrompt).toBe(false); - }); - - it("should not show auth prompt for authenticated users", () => { - const { useSession } = require("@web/auth/hooks/session/useSession"); - const mockSession = { - authenticated: true, - setAuthenticated: jest.fn(), - }; - useSession.mockReturnValue(mockSession); - const store = createTestStore(); - - const { result } = renderHook( - () => - useAuthPrompt({ - tasks: [{ id: "1" }, { id: "2" }], - hasNavigatedDates: false, - showOnboardingOverlay: false, - }), - { - wrapper: ({ children }) => ( - {children} - ), - }, - ); - - expect(result.current.showAuthPrompt).toBe(false); - }); - - it("should not show auth prompt if onboarding overlay is showing", () => { - const store = createTestStore(); - - const { result } = renderHook( - () => - useAuthPrompt({ - tasks: [{ id: "1" }, { id: "2" }], - hasNavigatedDates: false, - showOnboardingOverlay: true, - }), - { - wrapper: ({ children }) => ( - {children} - ), - }, - ); - - expect(result.current.showAuthPrompt).toBe(false); - }); - - it("should not show auth prompt if cmd palette tutorial is showing", () => { - const store = createTestStore(); - - const { result } = renderHook( - () => - useAuthPrompt({ - tasks: [{ id: "1" }, { id: "2" }], - hasNavigatedDates: false, - showOnboardingOverlay: false, - }), - { - wrapper: ({ children }) => ( - {children} - ), - }, - ); - - expect(result.current.showAuthPrompt).toBe(false); - }); - - it("should not show auth prompt if user has less than 2 tasks", () => { - const store = createTestStore(); - - const { result } = renderHook( - () => - useAuthPrompt({ - tasks: [{ id: "1" }], - hasNavigatedDates: false, - showOnboardingOverlay: false, - }), - { - wrapper: ({ children }) => ( - {children} - ), - }, - ); - - expect(result.current.showAuthPrompt).toBe(false); - }); - - it("should dismiss auth prompt", () => { - const store = createTestStore(); - - const { result } = renderHook( - () => - useAuthPrompt({ - tasks: [{ id: "1" }, { id: "2" }], - hasNavigatedDates: false, - showOnboardingOverlay: false, - }), - { - wrapper: ({ children }) => ( - {children} - ), - }, - ); - - result.current.dismissAuthPrompt(); - expect(result.current.showAuthPrompt).toBe(false); - const stored = JSON.parse( - localStorage.getItem(STORAGE_KEYS.ONBOARDING_PROGRESS) ?? "{}", - ); - expect(stored.isAuthPromptDismissed).toBe(true); - }); -}); diff --git a/packages/web/src/views/Onboarding/hooks/useAuthPrompt.ts b/packages/web/src/views/Onboarding/hooks/useAuthPrompt.ts deleted file mode 100644 index eb80847e9..000000000 --- a/packages/web/src/views/Onboarding/hooks/useAuthPrompt.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useState } from "react"; -import { useSession } from "@web/auth/hooks/session/useSession"; -import { - getOnboardingProgress, - updateOnboardingProgress, -} from "@web/views/Onboarding/utils/onboarding.storage.util"; - -interface UseAuthPromptProps { - tasks: Array<{ id: string }>; - hasNavigatedDates: boolean; - showOnboardingOverlay: boolean; -} - -interface UseAuthPromptReturn { - showAuthPrompt: boolean; - dismissAuthPrompt: () => void; -} - -/** - * Hook to manage the auth prompt visibility - * Shows prompt after user interactions (creating tasks, using cmd+k, navigating dates) - * Only shows for unauthenticated users who haven't dismissed it - */ -export function useAuthPrompt({ - tasks, - hasNavigatedDates, - showOnboardingOverlay, -}: UseAuthPromptProps): UseAuthPromptReturn { - const { authenticated } = useSession(); - const [showAuthPrompt, setShowAuthPrompt] = useState(false); - const AUTH_PROMPT_DELAY = 2000; - - useEffect(() => { - if (typeof window === "undefined") return; - if (authenticated) return; - - const { isAuthPromptDismissed } = getOnboardingProgress(); - if (isAuthPromptDismissed) return; - - // Show auth prompt if user has: - // - Created 2+ tasks, OR - // - Navigated between dates - const shouldShow = tasks.length >= 2 || hasNavigatedDates; - - if (shouldShow && !showOnboardingOverlay) { - // Delay showing auth prompt to avoid overwhelming user - const timer = setTimeout(() => { - setShowAuthPrompt(true); - }, AUTH_PROMPT_DELAY); - - return () => clearTimeout(timer); - } - }, [authenticated, tasks.length, hasNavigatedDates, showOnboardingOverlay]); - - const dismissAuthPrompt = () => { - updateOnboardingProgress({ isAuthPromptDismissed: true }); - setShowAuthPrompt(false); - }; - - return { - showAuthPrompt, - dismissAuthPrompt, - }; -} diff --git a/packages/web/src/views/Onboarding/hooks/useOnboardingOverlay.test.tsx b/packages/web/src/views/Onboarding/hooks/useOnboardingOverlay.test.tsx deleted file mode 100644 index c9a5b8daf..000000000 --- a/packages/web/src/views/Onboarding/hooks/useOnboardingOverlay.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { ONBOARDING_STEPS } from "@web/views/Onboarding/constants/onboarding.constants"; -import { useOnboardingOverlay } from "@web/views/Onboarding/hooks/useOnboardingOverlay"; -import { - getOnboardingProgress, - updateOnboardingProgress, -} from "@web/views/Onboarding/utils/onboarding.storage.util"; - -// Mock useSession -jest.mock("@web/auth/hooks/session/useSession", () => ({ - useSession: jest.fn(() => ({ - authenticated: false, - setAuthenticated: jest.fn(), - })), -})); - -// Mock useCmdPaletteGuide -const mockUseCmdPaletteGuide = jest.fn(); -jest.mock("@web/views/Onboarding/hooks/useCmdPaletteGuide", () => ({ - useCmdPaletteGuide: () => mockUseCmdPaletteGuide(), -})); - -describe("useOnboardingOverlay", () => { - beforeEach(() => { - localStorage.clear(); - jest.clearAllMocks(); - // Default mock - guide active on step 1 - // The mock checks onboarding progress to determine if guide is completed - mockUseCmdPaletteGuide.mockImplementation(() => { - const progress = getOnboardingProgress(); - const isCompleted = progress.isCompleted; - return { - currentStep: isCompleted ? null : ONBOARDING_STEPS.NAVIGATE_TO_DAY, - isGuideActive: !isCompleted, - skipGuide: jest.fn(() => { - updateOnboardingProgress({ isCompleted: true }); - }), - completeStep: jest.fn(), - completeGuide: jest.fn(), - }; - }); - }); - - it("should show onboarding overlay when guide is active on step 1 for unauthenticated users", () => { - const { result } = renderHook(() => useOnboardingOverlay()); - - expect(result.current.showOnboardingOverlay).toBe(true); - expect(result.current.currentStep).toBe(ONBOARDING_STEPS.NAVIGATE_TO_DAY); - }); - - it("should show onboarding overlay when guide is active on step 2 for unauthenticated users", () => { - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.CREATE_TASK, - isGuideActive: true, - skipGuide: jest.fn(), - completeStep: jest.fn(), - completeGuide: jest.fn(), - }); - - const { result } = renderHook(() => useOnboardingOverlay()); - - expect(result.current.currentStep).toBe(ONBOARDING_STEPS.CREATE_TASK); - expect(result.current.showOnboardingOverlay).toBe(true); - }); - - it("should not show onboarding overlay when guide is on step 3 (Now view step)", () => { - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_NOW, - isGuideActive: true, - skipGuide: jest.fn(), - completeStep: jest.fn(), - completeGuide: jest.fn(), - }); - - const { result } = renderHook(() => useOnboardingOverlay()); - - // Overlay should not show on step 3 (it's for Now view, not Day view) - expect(result.current.currentStep).toBe(ONBOARDING_STEPS.NAVIGATE_TO_NOW); - expect(result.current.showOnboardingOverlay).toBe(false); - }); - - it("should not show onboarding overlay if guide is completed", () => { - updateOnboardingProgress({ isCompleted: true }); - // Mock should reflect completed state - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: null, - isGuideActive: false, - skipGuide: jest.fn(), - completeStep: jest.fn(), - completeGuide: jest.fn(), - }); - - const { result } = renderHook(() => useOnboardingOverlay()); - - expect(result.current.showOnboardingOverlay).toBe(false); - }); - - it("should not show onboarding overlay for authenticated users", () => { - const { useSession } = require("@web/auth/hooks/session/useSession"); - const mockSession = { - authenticated: true, - setAuthenticated: jest.fn(), - }; - useSession.mockReturnValue(mockSession); - - const { result } = renderHook(() => useOnboardingOverlay()); - - expect(result.current.showOnboardingOverlay).toBe(false); - }); - - it("should not show onboarding overlay when guide is completed", () => { - const { useSession } = require("@web/auth/hooks/session/useSession"); - const mockSession = { - authenticated: false, - setAuthenticated: jest.fn(), - }; - useSession.mockReturnValue(mockSession); - - // Set guide as completed - updateOnboardingProgress({ isCompleted: true }); - // Mock should reflect completed state - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: null, - isGuideActive: false, - skipGuide: jest.fn(), - completeStep: jest.fn(), - completeGuide: jest.fn(), - }); - - const { result } = renderHook(() => useOnboardingOverlay()); - - // Overlay should not show when guide is completed - expect(result.current.showOnboardingOverlay).toBe(false); - }); - - it("should skip guide when dismissed", () => { - // Reset mock to ensure authenticated is false - const { useSession } = require("@web/auth/hooks/session/useSession"); - const mockSession = { - authenticated: false, - setAuthenticated: jest.fn(), - }; - useSession.mockReturnValue(mockSession); - - const skipGuideFn = jest.fn(() => { - updateOnboardingProgress({ isCompleted: true }); - }); - - mockUseCmdPaletteGuide.mockReturnValue({ - currentStep: ONBOARDING_STEPS.NAVIGATE_TO_DAY, - isGuideActive: true, - skipGuide: skipGuideFn, - completeStep: jest.fn(), - completeGuide: jest.fn(), - }); - - const { result } = renderHook(() => useOnboardingOverlay()); - - expect(result.current.showOnboardingOverlay).toBe(true); - - act(() => { - result.current.dismissOnboardingOverlay(); - }); - - // Verify skipGuide was called and onboarding progress was updated - expect(skipGuideFn).toHaveBeenCalled(); - const progress = getOnboardingProgress(); - expect(progress.isCompleted).toBe(true); - }); -}); diff --git a/packages/web/src/views/Onboarding/hooks/useOnboardingOverlay.ts b/packages/web/src/views/Onboarding/hooks/useOnboardingOverlay.ts deleted file mode 100644 index 62c5510eb..000000000 --- a/packages/web/src/views/Onboarding/hooks/useOnboardingOverlay.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useSession } from "@web/auth/hooks/session/useSession"; -import { - ONBOARDING_STEPS, - type OnboardingStepName, -} from "@web/views/Onboarding/constants/onboarding.constants"; -import { useCmdPaletteGuide } from "@web/views/Onboarding/hooks/useCmdPaletteGuide"; - -interface UseOnboardingOverlayReturn { - showOnboardingOverlay: boolean; - currentStep: OnboardingStepName | null; - dismissOnboardingOverlay: () => void; -} - -/** - * Hook to manage the onboarding overlay visibility - * Shows overlay when the cmd palette guide is active (for steps 1 and 2 on Day view) - * The overlay stays visible and updates its content based on the current step - */ -export function useOnboardingOverlay(): UseOnboardingOverlayReturn { - const { authenticated } = useSession(); - const { currentStep, isGuideActive, skipGuide } = useCmdPaletteGuide(); - - // Show overlay when guide is active, on steps 1 or 2 (Day view steps), and user is not authenticated - // Step 3 is for Now view, so overlay won't show for that step - const showOnboardingOverlay = - isGuideActive && - currentStep !== null && - (currentStep === ONBOARDING_STEPS.NAVIGATE_TO_DAY || - currentStep === ONBOARDING_STEPS.CREATE_TASK) && - !authenticated; - - const dismissOnboardingOverlay = () => { - // Dismissing the overlay should skip the guide - skipGuide(); - }; - - return { - showOnboardingOverlay, - currentStep, - dismissOnboardingOverlay, - }; -} diff --git a/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.disablePrevious.test.ts b/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.disablePrevious.test.ts deleted file mode 100644 index 8d3333178..000000000 --- a/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.disablePrevious.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { useOnboardingShortcuts } from "./useOnboardingShortcuts"; - -// Mock document methods -const mockAddEventListener = jest.fn(); -const mockRemoveEventListener = jest.fn(); -const mockPreventDefault = jest.fn(); -const mockStopPropagation = jest.fn(); - -// Mock document.activeElement -const mockActiveElement = { - tagName: "DIV", -}; - -Object.defineProperty(document, "addEventListener", { - value: mockAddEventListener, - writable: true, -}); - -Object.defineProperty(document, "removeEventListener", { - value: mockRemoveEventListener, - writable: true, -}); - -Object.defineProperty(document, "activeElement", { - value: mockActiveElement, - writable: true, -}); - -describe("useOnboardingShortcuts - disablePrevious functionality", () => { - const mockOnNext = jest.fn(); - const mockOnPrevious = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - mockAddEventListener.mockClear(); - mockRemoveEventListener.mockClear(); - mockPreventDefault.mockClear(); - mockStopPropagation.mockClear(); - }); - - afterEach(() => { - // Clean up any event listeners - const cleanup = mockAddEventListener.mock.calls.find( - (call) => call[0] === "keydown", - )?.[2]; - if (cleanup) { - cleanup(); - } - }); - - const defaultProps = { - onNext: mockOnNext, - onPrevious: mockOnPrevious, - canNavigateNext: true, - handlesKeyboardEvents: false, - }; - - describe("disablePrevious = true", () => { - it("should prevent 'j' key navigation when disablePrevious is true", () => { - const props = { ...defaultProps, disablePrevious: true }; - renderHook(() => useOnboardingShortcuts(props)); - - // Verify that event listener was added - expect(mockAddEventListener).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - false, - ); - - // Get the event handler function - const keydownHandler = mockAddEventListener.mock.calls.find( - (call) => call[0] === "keydown", - )?.[1]; - - expect(keydownHandler).toBeDefined(); - - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(jKeyEvent); - - expect(mockOnPrevious).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should prevent 'J' key navigation when disablePrevious is true", () => { - const props = { ...defaultProps, disablePrevious: true }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const JKeyEvent = { - key: "J", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(JKeyEvent); - - expect(mockOnPrevious).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should still allow 'k' key navigation when disablePrevious is true", () => { - const props = { ...defaultProps, disablePrevious: true }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(kKeyEvent); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should still allow Enter key navigation when disablePrevious is true", () => { - const props = { ...defaultProps, disablePrevious: true }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const enterEvent = { - key: "Enter", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(enterEvent); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - }); - - describe("disablePrevious = false", () => { - it("should allow 'j' key navigation when disablePrevious is false", () => { - // Mock no active element (not in input field) - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - const props = { ...defaultProps, disablePrevious: false }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(jKeyEvent); - - expect(mockOnPrevious).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should allow 'k' key navigation when disablePrevious is false", () => { - // Mock no active element (not in input field) - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - const props = { ...defaultProps, disablePrevious: false }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(kKeyEvent); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - }); - - describe("disablePrevious = undefined (default)", () => { - it("should allow 'j' key navigation when disablePrevious is undefined", () => { - // Mock no active element (not in input field) - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - const props = { ...defaultProps }; - delete (props as any).disablePrevious; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(jKeyEvent); - - expect(mockOnPrevious).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - }); - - describe("disablePrevious with other navigation controls", () => { - it("should prevent 'j' key even when canNavigateNext is true", () => { - const props = { - ...defaultProps, - disablePrevious: true, - canNavigateNext: true, - }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(jKeyEvent); - - expect(mockOnPrevious).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should prevent 'j' key even when shouldPreventNavigation is false", () => { - const props = { - ...defaultProps, - disablePrevious: true, - shouldPreventNavigation: false, - }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(jKeyEvent); - - expect(mockOnPrevious).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should not interfere with 'k' key prevention when canNavigateNext is false", () => { - const props = { - ...defaultProps, - disablePrevious: true, - canNavigateNext: false, - }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - - // Test 'j' key (should be prevented by disablePrevious) - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - keydownHandler(jKeyEvent); - - expect(mockOnPrevious).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - - // Reset mocks - mockPreventDefault.mockClear(); - mockStopPropagation.mockClear(); - - // Test 'k' key (should be prevented by canNavigateNext: false) - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - keydownHandler(kKeyEvent); - - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - }); - - describe("dependency updates", () => { - it("should update behavior when disablePrevious changes from false to true", () => { - const { rerender } = renderHook( - ({ props }) => useOnboardingShortcuts(props), - { - initialProps: { props: { ...defaultProps, disablePrevious: false } }, - }, - ); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - // Initially should allow 'j' key - keydownHandler(jKeyEvent); - expect(mockOnPrevious).toHaveBeenCalledTimes(1); - - // Reset mocks - mockOnPrevious.mockClear(); - mockPreventDefault.mockClear(); - mockStopPropagation.mockClear(); - - // Update to disable previous - rerender({ props: { ...defaultProps, disablePrevious: true } }); - - // Get the new handler - const newKeydownHandler = - mockAddEventListener.mock.calls[ - mockAddEventListener.mock.calls.length - 1 - ][1]; - - // Now should prevent 'j' key - newKeydownHandler(jKeyEvent); - expect(mockOnPrevious).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should update behavior when disablePrevious changes from true to false", () => { - const { rerender } = renderHook( - ({ props }) => useOnboardingShortcuts(props), - { - initialProps: { props: { ...defaultProps, disablePrevious: true } }, - }, - ); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - // Initially should prevent 'j' key - keydownHandler(jKeyEvent); - expect(mockOnPrevious).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - - // Reset mocks - mockOnPrevious.mockClear(); - mockPreventDefault.mockClear(); - mockStopPropagation.mockClear(); - - // Update to allow previous - rerender({ props: { ...defaultProps, disablePrevious: false } }); - - // Get the new handler - const newKeydownHandler = - mockAddEventListener.mock.calls[ - mockAddEventListener.mock.calls.length - 1 - ][1]; - - // Now should allow 'j' key - newKeydownHandler(jKeyEvent); - expect(mockOnPrevious).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.test.ts b/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.test.ts deleted file mode 100644 index 1e3ed2054..000000000 --- a/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { useOnboardingShortcuts } from "./useOnboardingShortcuts"; - -// Mock document methods -const mockAddEventListener = jest.fn(); -const mockRemoveEventListener = jest.fn(); -const mockPreventDefault = jest.fn(); -const mockStopPropagation = jest.fn(); - -// Mock document.activeElement -const mockActiveElement = { - tagName: "DIV", -}; - -Object.defineProperty(document, "addEventListener", { - value: mockAddEventListener, - writable: true, -}); - -Object.defineProperty(document, "removeEventListener", { - value: mockRemoveEventListener, - writable: true, -}); - -Object.defineProperty(document, "activeElement", { - value: mockActiveElement, - writable: true, -}); - -describe("useOnboardingShortcuts", () => { - const mockOnNext = jest.fn(); - const mockOnPrevious = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - mockAddEventListener.mockClear(); - mockRemoveEventListener.mockClear(); - mockPreventDefault.mockClear(); - mockStopPropagation.mockClear(); - }); - - afterEach(() => { - // Clean up any event listeners - const cleanup = mockAddEventListener.mock.calls.find( - (call) => call[0] === "keydown", - )?.[2]; - if (cleanup) { - cleanup(); - } - }); - - const defaultProps = { - onNext: mockOnNext, - onPrevious: mockOnPrevious, - canNavigateNext: true, - handlesKeyboardEvents: false, - }; - - it("should add and remove event listeners on mount and unmount", () => { - const { unmount } = renderHook(() => useOnboardingShortcuts(defaultProps)); - - expect(mockAddEventListener).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - false, - ); - - unmount(); - - expect(mockRemoveEventListener).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - false, - ); - }); - - it("should call onNext when 'k' key is pressed and navigation is allowed", () => { - // Mock no active element (not in input field) - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(kKeyEvent); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should call onPrevious when 'j' key is pressed", () => { - // Mock no active element (not in input field) - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(jKeyEvent); - - expect(mockOnPrevious).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should prevent 'k' key navigation when canNavigateNext is false", () => { - // Mock no active element (not in input field) - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - const props = { ...defaultProps, canNavigateNext: false }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(kKeyEvent); - - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should call onNext when Enter is pressed and navigation is allowed", () => { - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const enterEvent = { - key: "Enter", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(enterEvent); - - // Enter should work like 'k' key when navigation is allowed - expect(mockOnNext).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should not call onNext when Enter is pressed and input is focused", () => { - // Mock input element as active - Object.defineProperty(document, "activeElement", { - value: { tagName: "INPUT", contentEditable: "false" }, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const enterEvent = { - key: "Enter", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(enterEvent); - - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should not call onNext when Enter is pressed and textarea is focused", () => { - // Mock textarea element as active - Object.defineProperty(document, "activeElement", { - value: { tagName: "TEXTAREA", contentEditable: "false" }, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const enterEvent = { - key: "Enter", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(enterEvent); - - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should prevent Enter navigation when canNavigateNext is false", () => { - const props = { ...defaultProps, canNavigateNext: false }; - renderHook(() => useOnboardingShortcuts(props)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const enterEvent = { - key: "Enter", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(enterEvent); - - expect(mockOnNext).not.toHaveBeenCalled(); - // Note: The test is simplified - the main functionality works as evidenced by other tests - // The preventDefault/stopPropagation behavior is tested in the working tests above - }); - - it("should ignore other keys", () => { - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const otherKeyEvent = { - key: "Space", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(otherKeyEvent); - - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockOnPrevious).not.toHaveBeenCalled(); - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should prevent event bubbling for successful 'k' navigation when no input is focused", () => { - // Mock no active element - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(kKeyEvent); - - // Verify navigation happened - expect(mockOnNext).toHaveBeenCalledTimes(1); - - // Verify event was prevented from bubbling (this prevents input interference) - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should prevent event bubbling for successful 'j' navigation when no input is focused", () => { - // Mock no active element - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(jKeyEvent); - - // Verify navigation happened - expect(mockOnPrevious).toHaveBeenCalledTimes(1); - - // Verify event was prevented from bubbling (this prevents input interference) - expect(mockPreventDefault).toHaveBeenCalled(); - expect(mockStopPropagation).toHaveBeenCalled(); - }); - - it("should allow typing 'k' in input fields without triggering navigation", () => { - // Mock input element as active - Object.defineProperty(document, "activeElement", { - value: { tagName: "INPUT" }, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(kKeyEvent); - - // Should NOT trigger navigation - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockOnPrevious).not.toHaveBeenCalled(); - - // Should NOT prevent the keypress (allow typing) - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should allow typing 'j' in input fields without triggering navigation", () => { - // Mock input element as active - Object.defineProperty(document, "activeElement", { - value: { tagName: "INPUT" }, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const jKeyEvent = { - key: "j", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(jKeyEvent); - - // Should NOT trigger navigation - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockOnPrevious).not.toHaveBeenCalled(); - - // Should NOT prevent the keypress (allow typing) - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should allow typing 'k' in textarea fields without triggering navigation", () => { - // Mock textarea element as active - Object.defineProperty(document, "activeElement", { - value: { tagName: "TEXTAREA" }, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(kKeyEvent); - - // Should NOT trigger navigation - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockOnPrevious).not.toHaveBeenCalled(); - - // Should NOT prevent the keypress (allow typing) - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should allow typing 'k' in contentEditable elements without triggering navigation", () => { - // Mock contentEditable element as active - Object.defineProperty(document, "activeElement", { - value: { tagName: "DIV", contentEditable: "true" }, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(kKeyEvent); - - // Should NOT trigger navigation - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockOnPrevious).not.toHaveBeenCalled(); - - // Should NOT prevent the keypress (allow typing) - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should return shouldPreventNavigation as false by default", () => { - const { result } = renderHook(() => useOnboardingShortcuts(defaultProps)); - - expect(result.current.shouldPreventNavigation).toBe(false); - }); - - it("should call onNext after Enter when no input is focused", () => { - // Mock null activeElement - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - renderHook(() => useOnboardingShortcuts(defaultProps)); - - const keydownHandler = mockAddEventListener.mock.calls[0][1]; - const enterEvent = { - key: "Enter", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - keydownHandler(enterEvent); - - // Enter should work like 'k' key when no input is focused - expect(mockOnNext).toHaveBeenCalledTimes(1); - expect(mockPreventDefault).not.toHaveBeenCalled(); - expect(mockStopPropagation).not.toHaveBeenCalled(); - }); - - it("should update event listener when dependencies change", () => { - const { rerender } = renderHook( - ({ props }) => useOnboardingShortcuts(props), - { - initialProps: { props: defaultProps }, - }, - ); - - const initialAddCalls = mockAddEventListener.mock.calls.length; - const initialRemoveCalls = mockRemoveEventListener.mock.calls.length; - - // Change a dependency - const newProps = { ...defaultProps, canNavigateNext: false }; - rerender({ props: newProps }); - - // Should have removed old listener and added new one - expect(mockRemoveEventListener).toHaveBeenCalledTimes( - initialRemoveCalls + 1, - ); - expect(mockAddEventListener).toHaveBeenCalledTimes(initialAddCalls + 1); - }); - - describe("handlesKeyboardEvents behavior", () => { - it("should not add event listeners when handlesKeyboardEvents is true", () => { - const props = { ...defaultProps, handlesKeyboardEvents: true }; - renderHook(() => useOnboardingShortcuts(props)); - - // Should not add any event listeners - expect(mockAddEventListener).not.toHaveBeenCalled(); - }); - - it("should add event listeners when handlesKeyboardEvents is false", () => { - const props = { ...defaultProps, handlesKeyboardEvents: false }; - renderHook(() => useOnboardingShortcuts(props)); - - // Should add event listener - expect(mockAddEventListener).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - false, - ); - }); - - it("should add event listeners when handlesKeyboardEvents is undefined", () => { - const props = { ...defaultProps }; - delete props.handlesKeyboardEvents; - renderHook(() => useOnboardingShortcuts(props)); - - // Should add event listener (default behavior) - expect(mockAddEventListener).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - false, - ); - }); - - it("should not respond to keyboard events when handlesKeyboardEvents is true", () => { - const props = { ...defaultProps, handlesKeyboardEvents: true }; - renderHook(() => useOnboardingShortcuts(props)); - - // Manually trigger a keydown event since no listener was added - const kKeyEvent = { - key: "k", - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }; - - // Since no event listener was added, nothing should happen - expect(mockOnNext).not.toHaveBeenCalled(); - expect(mockOnPrevious).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.ts b/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.ts deleted file mode 100644 index b942f9064..000000000 --- a/packages/web/src/views/Onboarding/hooks/useOnboardingShortcuts.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useEffect, useRef } from "react"; - -interface UseOnboardingShortcutsProps { - onNext: () => void; - onPrevious: () => void; - canNavigateNext: boolean; - shouldPreventNavigation?: boolean; - handlesKeyboardEvents?: boolean; - disablePrevious?: boolean; -} - -// Helper function to check if an input field is currently focused -const isInputFieldFocused = (): boolean => { - const activeElement = document.activeElement as HTMLElement; - return ( - activeElement && - (activeElement.tagName === "INPUT" || - activeElement.tagName === "TEXTAREA" || - activeElement.contentEditable === "true") - ); -}; - -export const useOnboardingShortcuts = ({ - onNext, - onPrevious, - canNavigateNext, - shouldPreventNavigation = false, - handlesKeyboardEvents = false, - disablePrevious = false, -}: UseOnboardingShortcutsProps) => { - const shouldPreventNavigationRef = useRef(shouldPreventNavigation); - - // Update the ref when the prop changes - useEffect(() => { - shouldPreventNavigationRef.current = shouldPreventNavigation; - }, [shouldPreventNavigation]); - - useEffect(() => { - // If the step handles its own keyboard events, don't add our listeners - if (handlesKeyboardEvents) { - return; - } - - const handleKeyDown = (event: KeyboardEvent) => { - const isNextKey = event.key === "k" || event.key === "K"; - const isEnter = event.key === "Enter"; - const isPreviousKey = event.key === "j" || event.key === "J"; - - // Check if we should prevent navigation - const shouldPrevent = shouldPreventNavigationRef.current; - - // For 'k' key navigation (next) - if (isNextKey) { - // Allow typing in input fields - if (isInputFieldFocused()) { - return; - } - - if (shouldPrevent || !canNavigateNext) { - event.preventDefault(); - event.stopPropagation(); - return; - } - event.preventDefault(); - event.stopPropagation(); - onNext(); - } - - // For 'j' key navigation (previous) - if (isPreviousKey) { - // Allow typing in input fields - if (isInputFieldFocused()) { - return; - } - - if (disablePrevious) { - event.preventDefault(); - event.stopPropagation(); - return; - } - event.preventDefault(); - event.stopPropagation(); - onPrevious(); - } - - // For Enter key navigation - if (isEnter) { - // If focused on an input, let the input handle it - if (isInputFieldFocused()) { - return; - } - - // If not focused on input, check navigation rules - if (shouldPrevent || !canNavigateNext) { - event.preventDefault(); - event.stopPropagation(); - return; - } - - // If all conditions are met, navigate to next step - onNext(); - } - }; - - document.addEventListener("keydown", handleKeyDown, false); - return () => document.removeEventListener("keydown", handleKeyDown, false); - }, [ - onNext, - onPrevious, - canNavigateNext, - shouldPreventNavigation, - handlesKeyboardEvents, - disablePrevious, - ]); - - return { - shouldPreventNavigation: shouldPreventNavigationRef.current, - }; -}; diff --git a/packages/web/src/views/Onboarding/hooks/useStoredTasks.test.ts b/packages/web/src/views/Onboarding/hooks/useStoredTasks.test.ts deleted file mode 100644 index d2b7c091d..000000000 --- a/packages/web/src/views/Onboarding/hooks/useStoredTasks.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { Task } from "@web/common/types/task.types"; -import { - getStoredTasksInitialValue, - useStoredTasks, -} from "@web/views/Onboarding/hooks/useStoredTasks"; - -// Mock loadTodayTasks and the custom event name from storage.util -const mockTasks: Task[] = [ - { - id: "1", - title: "Task A", - status: "todo", - createdAt: new Date().toISOString(), - order: 0, - }, - { - id: "2", - title: "Task B", - status: "todo", - createdAt: new Date().toISOString(), - order: 0, - }, -]; - -jest.mock("@web/common/utils/storage/storage.util", () => ({ - loadTodayTasks: jest.fn(), - COMPASS_TASKS_SAVED_EVENT_NAME: "compass:tasks:saved", -})); - -const { - loadTodayTasks, - COMPASS_TASKS_SAVED_EVENT_NAME, -} = require("@web/common/utils/storage/storage.util"); - -// Helper to dispatch the custom event -function fireTasksSavedEvent() { - const evt = new Event(COMPASS_TASKS_SAVED_EVENT_NAME); - window.dispatchEvent(evt); -} - -describe("useStoredTasks", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("returns tasks from loadTodayTasks on mount", () => { - (loadTodayTasks as jest.Mock).mockReturnValue(mockTasks); - - const { result } = renderHook(() => useStoredTasks()); - - expect(loadTodayTasks).toHaveBeenCalledTimes(1); - expect(result.current).toEqual(mockTasks); - }); - - it("returns an empty array on server environment", () => { - const originalWindow = global.window; - // @ts-expect-error - delete global.window; - (loadTodayTasks as jest.Mock).mockReturnValue(mockTasks); - expect(getStoredTasksInitialValue()).toEqual([]); - expect(loadTodayTasks).not.toHaveBeenCalled(); - // Restore window - global.window = originalWindow; - }); - - it("updates tasks when COMPASS_TASKS_SAVED_EVENT_NAME is fired", () => { - // Initial value - (loadTodayTasks as jest.Mock).mockReturnValueOnce([{ id: "old" }]); - const { result } = renderHook(() => useStoredTasks()); - expect(result.current).toEqual([{ id: "old" }]); - - // New value after event - (loadTodayTasks as jest.Mock).mockReturnValueOnce([ - { id: "old" }, - { id: "new" }, - ]); - act(() => { - fireTasksSavedEvent(); - }); - - expect(result.current).toEqual([{ id: "old" }, { id: "new" }]); - }); - - it("removes event listener on unmount", () => { - (loadTodayTasks as jest.Mock).mockReturnValue([{ id: "1" }]); - const addSpy = jest.spyOn(window, "addEventListener"); - const removeSpy = jest.spyOn(window, "removeEventListener"); - const { unmount } = renderHook(() => useStoredTasks()); - expect(addSpy).toHaveBeenCalledWith( - COMPASS_TASKS_SAVED_EVENT_NAME, - expect.any(Function), - ); - unmount(); - expect(removeSpy).toHaveBeenCalledWith( - COMPASS_TASKS_SAVED_EVENT_NAME, - expect.any(Function), - ); - - addSpy.mockRestore(); - removeSpy.mockRestore(); - }); -}); diff --git a/packages/web/src/views/Onboarding/hooks/useStoredTasks.ts b/packages/web/src/views/Onboarding/hooks/useStoredTasks.ts deleted file mode 100644 index cf9961314..000000000 --- a/packages/web/src/views/Onboarding/hooks/useStoredTasks.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useState } from "react"; -import { - COMPASS_TASKS_SAVED_EVENT_NAME, - loadTodayTasks, -} from "@web/common/utils/storage/storage.util"; - -export function getStoredTasksInitialValue() { - if (typeof window === "undefined") { - return []; - } - return loadTodayTasks(); -} - -export function useStoredTasks() { - const [tasks, setTasks] = useState(() => getStoredTasksInitialValue()); - - useEffect(() => { - if (typeof window === "undefined") { - return; - } - - const handleTasksSaved = () => { - setTasks(loadTodayTasks()); - }; - - window.addEventListener( - COMPASS_TASKS_SAVED_EVENT_NAME, - handleTasksSaved as EventListener, - ); - - return () => { - window.removeEventListener( - COMPASS_TASKS_SAVED_EVENT_NAME, - handleTasksSaved as EventListener, - ); - }; - }, []); - - return tasks; -} diff --git a/packages/web/src/views/Onboarding/index.ts b/packages/web/src/views/Onboarding/index.ts deleted file mode 100644 index 30ece5412..000000000 --- a/packages/web/src/views/Onboarding/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { Onboarding } from "./components/Onboarding"; -export { OnboardingStep } from "./components/OnboardingStep"; -export type { - OnboardingStepProps, - OnboardingStep as OnboardingStepType, -} from "./components/Onboarding"; diff --git a/packages/web/src/views/Onboarding/steps/events/MigrationIntro/MigrationIntro.tsx b/packages/web/src/views/Onboarding/steps/events/MigrationIntro/MigrationIntro.tsx deleted file mode 100644 index 2227a7a98..000000000 --- a/packages/web/src/views/Onboarding/steps/events/MigrationIntro/MigrationIntro.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { colorByPriority } from "@web/common/styles/theme.util"; -import { OnboardingText } from "../../../components"; -import { OnboardingStepProps } from "../../../components/Onboarding"; -import { OnboardingTwoRowLayout } from "../../../components/layouts/OnboardingTwoRowLayout"; -import { WeekHighlighter } from "../MigrationSandbox/WeekHighlighter"; - -const MainContent = styled.div` - flex: 1; - background-color: #12151b; - border: 1px solid #333; - display: flex; - margin: 20px; - gap: 20px; - z-index: 5; - position: relative; - overflow: visible; - height: 580px; -`; - -const TwoColumnContainer = styled.div` - display: flex; - width: 100%; - height: 100%; - gap: 40px; - padding: 20px; -`; - -const LeftColumn = styled.div` - flex: 1; - display: flex; - align-items: center; - justify-content: center; -`; - -const RightColumn = styled.div` - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - gap: 16px; -`; - -const EventContainer = styled.div` - display: flex; - align-items: center; - justify-content: center; -`; - -const EventItem = styled.div` - font-family: "Rubik", sans-serif; - font-size: 14px; - background: ${colorByPriority.self}; - color: ${({ theme }) => theme.color.common.black}; - border-radius: 4px; - padding: 12px; - width: 280px; - cursor: pointer; - transition: all 0.2s ease; - user-select: none; - display: flex; - align-items: center; - justify-content: space-between; - - &:hover { - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - } - - &:active { - transform: translateY(0); - } -`; - -const EventText = styled.span` - flex: 1; -`; - -const EventArrows = styled.div` - display: flex; - gap: 4px; -`; - -const MigrateArrow = styled.span` - padding: 4px 8px; - font-size: 12px; - font-weight: bold; - cursor: pointer; - border-radius: 3px; - transition: all 0.2s ease; - user-select: none; - box-shadow: 0 0 8px rgba(96, 165, 250, 0.4); - animation: pulse 2s ease-in-out infinite; - - &:hover { - background: rgba(0, 0, 0, 0.1); - transform: scale(1.1); - box-shadow: 0 0 12px rgba(96, 165, 250, 0.6); - } - - &:active { - background: rgba(0, 0, 0, 0.2); - transform: scale(0.95); - } -`; - -export const MigrationIntro: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, - onNavigationControlChange, - isNavPrevented = false, -}) => { - const eventItemRef = useRef(null); - const [arrowsPosition, setArrowsPosition] = useState({ - x: 200, - y: 60, - width: 60, - height: 30, - }); - - useEffect(() => { - const computeArrowsPosition = () => { - if (!eventItemRef.current) return; - - const eventRect = eventItemRef.current.getBoundingClientRect(); - - // Get the container element position to calculate relative coordinates - const container = eventItemRef.current.closest( - '[class*="MainContent"]', - ) as HTMLElement; - const containerRect = container?.getBoundingClientRect(); - if (!containerRect) return; - - // Find the arrows container within the event item - const arrowsContainer = eventItemRef.current.querySelector( - '[class*="EventArrows"]', - ) as HTMLElement; - if (!arrowsContainer) return; - - const arrowsRect = arrowsContainer.getBoundingClientRect(); - - // Calculate position relative to MainContent container - const arrowsX = arrowsRect.left - containerRect.left - 8; - const arrowsY = arrowsRect.top - containerRect.top - 8; - const arrowsWidth = arrowsRect.width + 16; - const arrowsHeight = arrowsRect.height + 16; - - setArrowsPosition({ - x: arrowsX, - y: arrowsY, - width: arrowsWidth, - height: arrowsHeight, - }); - }; - - // Slight delay to ensure layout is ready - const timer = setTimeout(computeArrowsPosition, 100); - - // Recompute on resize for responsive behavior - window.addEventListener("resize", computeArrowsPosition); - - return () => { - clearTimeout(timer); - window.removeEventListener("resize", computeArrowsPosition); - }; - }, []); - const content = ( - - - - - - 📚 Return library books - - {"<"} - {">"} - - - - - - Life at sea is unpredictable. - - Compass makes it easy to adjust your plans as things change. - - - You can click one of the arrows to migrate the task forward or back - a week/month. - - - Let's practice on the next screen. - - - - {}} - /> - - ); - - return ( - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/MigrationSandbox.test.tsx b/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/MigrationSandbox.test.tsx deleted file mode 100644 index 410134403..000000000 --- a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/MigrationSandbox.test.tsx +++ /dev/null @@ -1,844 +0,0 @@ -import "@testing-library/jest-dom"; -import { fireEvent, screen } from "@testing-library/react"; -import { within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { render } from "@web/__tests__/__mocks__/mock.render"; -import { withOnboardingProvider } from "../../../components/OnboardingContext"; -import { MigrationSandbox } from "./MigrationSandbox"; - -// Mock the useOnboardingShortcuts hook -jest.mock("../../../hooks/useOnboardingShortcuts", () => ({ - useOnboardingShortcuts: jest.fn(), -})); - -// Get the mocked function -const mockUseOnboardingShortcuts = - require("../../../hooks/useOnboardingShortcuts").useOnboardingShortcuts; - -// Wrap the component with OnboardingProvider -const SomedayMigrationWithProvider = withOnboardingProvider(MigrationSandbox); - -// Mock required props for SomedayMigration -const defaultProps = { - currentStep: 1, - totalSteps: 3, - onNext: jest.fn(), - onPrevious: jest.fn(), - canNavigateNext: true, - nextButtonDisabled: false, - onComplete: jest.fn(), - onSkip: jest.fn(), -}; - -describe("SomedayMigration", () => { - let mockOnNext: jest.Mock; - let mockOnPrevious: jest.Mock; - - beforeEach(() => { - // Clear all mocks before each test - jest.clearAllMocks(); - // Clear console.log mock - jest.spyOn(console, "log").mockImplementation(() => {}); - - // Set up mock functions - mockOnNext = jest.fn(); - mockOnPrevious = jest.fn(); - - // Mock the useOnboardingShortcuts hook to simulate keyboard event handling - mockUseOnboardingShortcuts.mockImplementation( - ({ - onNext, - onPrevious, - canNavigateNext, - shouldPreventNavigation, - handlesKeyboardEvents, - }) => { - // If handlesKeyboardEvents is true, don't set up listeners - if (handlesKeyboardEvents) { - return; - } - - // Set up a mock keyboard event listener - const handleKeyDown = (event: KeyboardEvent) => { - const isNextKey = event.key === "k" || event.key === "K"; - const isPreviousKey = event.key === "j" || event.key === "J"; - - if (isNextKey) { - // Check if we should prevent navigation - if (shouldPreventNavigation || !canNavigateNext) { - event.preventDefault(); - event.stopPropagation(); - return; - } - event.preventDefault(); - event.stopPropagation(); - onNext(); - } else if (isPreviousKey) { - event.preventDefault(); - event.stopPropagation(); - onPrevious(); - } - }; - - // Add the event listener - document.addEventListener("keydown", handleKeyDown, false); - - // Return a cleanup function - return () => { - document.removeEventListener("keydown", handleKeyDown, false); - }; - }, - ); - }); - - afterEach(() => { - // Restore console.log - jest.restoreAllMocks(); - }); - - function setup() { - render(); - return { - getBackArrow: () => screen.getByTitle("Previous week"), - getForwardArrow: () => screen.getByTitle("Next week"), - getWeekLabel: () => screen.getByText(/This Week|Next Week/), - getEventMigrateArrows: () => - screen.getAllByTitle(/Migrate to (previous|next) week/), - getEvents: () => screen.getAllByText(/🥙|🥗|☕️|📑|🧹/), - }; - } - - it("should render the component with 3 someday events", () => { - setup(); - - expect(screen.getByText("🥙 Meal prep")).toBeInTheDocument(); - expect(screen.getByText("🥗 Get groceries")).toBeInTheDocument(); - expect(screen.getAllByText("☕️ Coffee with Mom")).toHaveLength(1); - }); - - it("should render the sidebar with correct title", () => { - setup(); - - expect(screen.getByText("This Week")).toBeInTheDocument(); - }); - - it("should render checkboxes for user tasks", () => { - setup(); - - expect( - screen.getByText("Migrate an event to next week"), - ).toBeInTheDocument(); - expect( - screen.getByText("Migrate an event to next month"), - ).toBeInTheDocument(); - expect(screen.getByText("Go to next week/month")).toBeInTheDocument(); - }); - - it("should have proper accessibility attributes for event containers", () => { - setup(); - - const eventContainers = screen - .getAllByText(/🥙|🥗|☕️/) - .map((text) => text.closest('[role="button"]')) - .filter((item) => item !== null); - - expect(eventContainers.length).toBeGreaterThan(0); - eventContainers.forEach((item) => { - expect(item).toBeInTheDocument(); - expect(item).toHaveAttribute("role", "button"); - expect(item).toHaveAttribute("tabIndex", "0"); - }); - }); - - it("should render with OnboardingTwoRowLayout", () => { - setup(); - - // Check that the navigation buttons are present - expect(screen.getByLabelText("Previous")).toBeInTheDocument(); - expect(screen.getByLabelText("Next")).toBeInTheDocument(); - expect(screen.getByText("SKIP INTRO")).toBeInTheDocument(); - }); - - it("should render two month widgets with titles", () => { - setup(); - - // Check that the month widgets have titles (appears multiple times) - expect(screen.getAllByText("This Month")).toHaveLength(2); // Sidebar and calendar widget - expect(screen.getByText("Next Month")).toBeInTheDocument(); // Calendar widget only initially - }); - - it("should not render week day abbreviation labels", () => { - setup(); - - // WeekDays container should not be present now - const weekDaysContainer = document.querySelector('[class*="WeekDays"]'); - expect(weekDaysContainer).not.toBeInTheDocument(); - }); - - it("should highlight the current week days in the calendar", () => { - setup(); - - // Get the current date to determine which days should be highlighted - const currentDate = new Date(); - const currentDay = currentDate.getDate(); - const currentMonth = currentDate.getMonth(); - const currentYear = currentDate.getFullYear(); - - // Find the calendar day elements - const calendarDays = screen - .getAllByText(/^\d+$/) - .filter( - (day) => - parseInt(day.textContent || "0") >= 1 && - parseInt(day.textContent || "0") <= 31, - ); - - // Should have some calendar days - expect(calendarDays.length).toBeGreaterThan(0); - - // The current day should be visible (using hard-coded September 10, 2025) - // May appear in both current and next month calendars - expect(screen.getAllByText("10").length).toBeGreaterThanOrEqual(1); - }); - - it("should highlight day numbers of the current week", () => { - setup(); - - // Get the current week boundaries - const currentDate = new Date(); - const currentWeekStart = new Date(currentDate); - currentWeekStart.setDate(currentDate.getDate() - currentDate.getDay()); - const currentWeekEnd = new Date(currentWeekStart); - currentWeekEnd.setDate(currentWeekStart.getDate() + 6); - - // Find all calendar day elements - const calendarDays = screen - .getAllByText(/^\d+$/) - .filter( - (day) => - parseInt(day.textContent || "0") >= 1 && - parseInt(day.textContent || "0") <= 31, - ); - - // Should have calendar days - expect(calendarDays.length).toBeGreaterThan(0); - - // Verify that the hard-coded current day (September 10) is present - // May appear in both current and next month calendars - expect(screen.getAllByText("10").length).toBeGreaterThanOrEqual(1); - }); - - it("should render the calendar grids with current week highlighting", () => { - setup(); - - // Verify the calendar grid container exists - const calendarGridContainer = document.querySelector( - '[class*="CalendarGrid"]', - ); - - expect(calendarGridContainer).toBeInTheDocument(); - }); - - it("should render and be clickable the week highlighter", async () => { - const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - const user = userEvent.setup(); - - setup(); - - // Check that the highlighter canvas is in the DOM - const highlighterCanvas = document.querySelector("canvas"); - expect(highlighterCanvas).toBeInTheDocument(); - expect(highlighterCanvas?.tagName).toBe("CANVAS"); - - // Check that the highlighter has the correct positioning styles - const highlighterElement = highlighterCanvas as HTMLCanvasElement; - expect(highlighterElement.style.position).toBe("absolute"); - expect(highlighterElement.style.pointerEvents).toBe("auto"); - expect(highlighterElement.style.cursor).toBe("pointer"); - - // Verify the highlighter has reasonable dimensions (will be dynamic based on calendar) - expect(highlighterElement.width).toBeGreaterThan(200); // Should be wide enough for calendar - expect(highlighterElement.height).toBeGreaterThan(40); // Should have height for week row plus text - - // Click the highlighter - await user.click(highlighterCanvas!); - - consoleSpy.mockRestore(); - }); - - it("should render highlighter when current week is visible", () => { - setup(); - - // Check that the highlighter canvas is in the DOM when current week is visible - const highlighterCanvas = document.querySelector("canvas"); - expect(highlighterCanvas).toBeInTheDocument(); - }); - - it("should position ellipse highlighter dynamically based on current week row", async () => { - setup(); - - // Wait for the component to calculate positions (useEffect with refs) - await new Promise((resolve) => setTimeout(resolve, 100)); - - const highlighterCanvas = document.querySelector("canvas"); - expect(highlighterCanvas).toBeInTheDocument(); - - // Verify the highlighter is positioned (exact values will depend on layout) - const highlighterElement = highlighterCanvas as HTMLCanvasElement; - expect(highlighterElement.style.position).toBe("absolute"); - - // Check that left and top styles are set (they will be calculated dynamically) - expect(highlighterElement.style.left).toMatch(/\d+px/); - expect(highlighterElement.style.top).toMatch(/\d+px/); - }); - - it("should position highlighter overlapping the calendar", async () => { - setup(); - - // Find the calendar grid - const calendarGrid = document.querySelector('[class*="CalendarGrid"]'); - expect(calendarGrid).toBeInTheDocument(); - - // Wait for positioning calculations - await new Promise((resolve) => setTimeout(resolve, 150)); - - const highlighterCanvas = document.querySelector("canvas"); - expect(highlighterCanvas).toBeInTheDocument(); - - // Check that highlighter exists and has been positioned - const highlighterElement = highlighterCanvas as HTMLCanvasElement; - const left = parseInt(highlighterElement.style.left.replace("px", "")); - const top = parseInt(highlighterElement.style.top.replace("px", "")); - - // Highlighter should be positioned overlapping the calendar - expect(left).toBeGreaterThan(-50); // Allow for negative positioning due to padding/margin - expect(top).toBeGreaterThan(-50); - - // Highlighter should have high z-index to render on top - expect(highlighterElement.style.zIndex).toBe("100"); - }); - - it("should include Saturday (last day) of the current week", async () => { - setup(); - - // Wait for positioning calculations - await new Promise((resolve) => setTimeout(resolve, 150)); - - // Get current date to find the Saturday of current week - const currentDate = new Date(); - const currentDay = currentDate.getDay(); // 0 = Sunday, 6 = Saturday - const daysUntilSaturday = (6 - currentDay + 7) % 7; - const saturdayDate = new Date(currentDate); - saturdayDate.setDate(currentDate.getDate() + daysUntilSaturday); - - // In the mock, we're using January 11, 2024 (Thursday) - // So Saturday would be January 13, 2024 - const expectedSaturday = 13; - - // Find the Saturday elements in the calendar (may appear in multiple months) - const saturdayElements = screen.getAllByText(expectedSaturday.toString()); - expect(saturdayElements.length).toBeGreaterThanOrEqual(1); - - // Verify at least one Saturday is part of the calendar grid - const saturdayInGrid = saturdayElements.find((element) => { - // Check if the element itself or its parent has calendar-related class - return ( - element.className.includes("CalendarDay") || - (element.parentElement && - element.parentElement.className.includes("CalendarDay")) - ); - }); - expect(saturdayInGrid).toBeDefined(); - expect(saturdayInGrid).toBeInTheDocument(); - }); - - // Note: Highlighter dimensions are dynamic and responsive; exact - // width/height assertions are validated in the earlier highlighter test. - - describe("Week Navigation", () => { - it("should render This Week label and navigation arrows initially", () => { - const { getWeekLabel, getBackArrow, getForwardArrow } = setup(); - - expect(getWeekLabel()).toHaveTextContent("This Week"); - expect(getBackArrow()).toBeInTheDocument(); - expect(getForwardArrow()).toBeInTheDocument(); - }); - - it("should have back arrow disabled and forward arrow enabled initially", () => { - const { getBackArrow, getForwardArrow } = setup(); - - expect(getBackArrow()).toBeDisabled(); - expect(getForwardArrow()).not.toBeDisabled(); - }); - - it("should show initial week events (Meal prep, Get groceries, Coffee with Mom)", () => { - const { getEvents } = setup(); - const events = getEvents(); - - expect(screen.getByText("🥙 Meal prep")).toBeInTheDocument(); - expect(screen.getByText("🥗 Get groceries")).toBeInTheDocument(); - expect(screen.getAllByText("☕️ Coffee with Mom")).toHaveLength(1); - }); - - it("should navigate to next week when forward arrow is clicked", async () => { - const { getForwardArrow, getWeekLabel } = setup(); - const consoleSpy = jest.spyOn(console, "log"); - - await userEvent.click(getForwardArrow()); - - expect(getWeekLabel()).toHaveTextContent("Next Week"); - }); - - it("should show different events when navigated to next week", async () => { - const { getForwardArrow } = setup(); - - await userEvent.click(getForwardArrow()); - - expect(screen.getByText("📑 Submit report")).toBeInTheDocument(); - expect(screen.getByText("🧹 Clean house")).toBeInTheDocument(); - expect(screen.queryByText("🥙 Meal prep")).not.toBeInTheDocument(); - expect(screen.queryByText("🥗 Get groceries")).not.toBeInTheDocument(); - expect(screen.queryByText("☕️ Coffee with Mom")).not.toBeInTheDocument(); - }); - - it("should show only 2 events in next week", async () => { - const { getForwardArrow, getEvents } = setup(); - - await userEvent.click(getForwardArrow()); - - const events = getEvents(); - expect(events).toHaveLength(2); - }); - - it("should have forward arrow disabled when on next week", async () => { - const { getForwardArrow, getBackArrow } = setup(); - - await userEvent.click(getForwardArrow()); - - expect(getForwardArrow()).toBeDisabled(); - expect(getBackArrow()).not.toBeDisabled(); - }); - - it("should navigate back to this week when back arrow is clicked", async () => { - const { getForwardArrow, getBackArrow, getWeekLabel } = setup(); - const consoleSpy = jest.spyOn(console, "log"); - - // Navigate to next week first - await userEvent.click(getForwardArrow()); - expect(getWeekLabel()).toHaveTextContent("Next Week"); - - // Navigate back - await userEvent.click(getBackArrow()); - - expect(getWeekLabel()).toHaveTextContent("This Week"); - }); - - it("should show original events when navigated back to this week", async () => { - const { getForwardArrow, getBackArrow } = setup(); - - // Navigate to next week and back - await userEvent.click(getForwardArrow()); - await userEvent.click(getBackArrow()); - - expect(screen.getByText("🥙 Meal prep")).toBeInTheDocument(); - expect(screen.getByText("🥗 Get groceries")).toBeInTheDocument(); - expect(screen.getAllByText("☕️ Coffee with Mom")).toHaveLength(1); - expect(screen.queryByText("📑 Submit report")).not.toBeInTheDocument(); - expect(screen.queryByText("🧹 Clean house")).not.toBeInTheDocument(); - }); - - it("should have proper accessibility attributes for navigation arrows", () => { - const { getBackArrow, getForwardArrow } = setup(); - - expect(getBackArrow()).toHaveAttribute( - "aria-label", - "Navigate to previous week", - ); - expect(getForwardArrow()).toHaveAttribute( - "aria-label", - "Navigate to next week", - ); - expect(getBackArrow()).toHaveAttribute("title", "Previous week"); - expect(getForwardArrow()).toHaveAttribute("title", "Next week"); - }); - }); - - describe("Month Migration", () => { - it("should check the month migration checkbox and remove the event when migrating a month event", async () => { - render(); - - // Ensure a month event is present initially - expect(screen.getByText("🤖 Start AI course")).toBeInTheDocument(); - - // Click the first month event's forward arrow - const monthForwardArrows = screen.getAllByTitle("Migrate to next month"); - expect(monthForwardArrows.length).toBeGreaterThan(0); - await userEvent.click(monthForwardArrows[0]); - - // Checkbox should be checked - const monthCheckbox = screen.getByLabelText( - "Migrate an event to next month", - ) as HTMLInputElement; - expect(monthCheckbox).toBeChecked(); - - // Event should be removed from the This Month list - expect(screen.queryByText("🤖 Start AI course")).not.toBeInTheDocument(); - - // A label should appear next to the Next Month rectangle - expect( - screen.getByText(/Start AI course is here now/), - ).toBeInTheDocument(); - - // There should be at least one canvas drawn for the month highlight - const canvases = document.querySelectorAll("canvas"); - expect(canvases.length).toBeGreaterThanOrEqual(2); - }); - }); - - describe("Month Sidebar Paging", () => { - it("changes label from This Month to Next Month when navigating forward", async () => { - render(); - - const sections = document.querySelectorAll('[class*="SidebarSection"]'); - const monthSection = sections[1] as HTMLElement; // Second section is month - - expect(within(monthSection).getByText("This Month")).toBeInTheDocument(); - - // Navigate to next page (week navigation forward) - await userEvent.click(screen.getByTitle("Next week")); - - expect(within(monthSection).getByText("Next Month")).toBeInTheDocument(); - }); - - it("shows migrated month event on Next Month after navigating forward", async () => { - render(); - - // Migrate first month event forward - const monthForwardArrows = screen.getAllByTitle("Migrate to next month"); - await userEvent.click(monthForwardArrows[0]); - - // Navigate to next page - await userEvent.click(screen.getByTitle("Next week")); - - const sections = document.querySelectorAll('[class*="SidebarSection"]'); - const monthSection = sections[1] as HTMLElement; - - // Label should be Next Month now - expect(within(monthSection).getByText("Next Month")).toBeInTheDocument(); - - // Migrated event should appear in Next Month list - expect( - within(monthSection).getByText("🤖 Start AI course"), - ).toBeInTheDocument(); - }); - }); - - describe("Event Migration Arrows", () => { - it("should render migrate arrows for each event", () => { - const { getEventMigrateArrows } = setup(); - const arrows = getEventMigrateArrows(); - - // 3 events × 2 arrows each = 6 arrows - expect(arrows).toHaveLength(6); - }); - - it("should log migration when event arrow is clicked", async () => { - const consoleSpy = jest.spyOn(console, "log"); - const { getEventMigrateArrows } = setup(); - const arrows = getEventMigrateArrows(); - - // Click first event's forward arrow - const firstEventForwardArrow = arrows.find( - (arrow) => - arrow.getAttribute("title") === "Migrate to next week" && - arrow.textContent === ">", - ); - - await userEvent.click(firstEventForwardArrow!); - }); - - it("should log backward migration when back arrow is clicked", async () => { - const consoleSpy = jest.spyOn(console, "log"); - const { getEventMigrateArrows } = setup(); - const arrows = getEventMigrateArrows(); - - // Click first event's back arrow - const firstEventBackArrow = arrows.find( - (arrow) => - arrow.getAttribute("title") === "Migrate to previous week" && - arrow.textContent === "<", - ); - - await userEvent.click(firstEventBackArrow!); - }); - - it("should log correct event name and week context when on next week", async () => { - const consoleSpy = jest.spyOn(console, "log"); - const { getForwardArrow, getEventMigrateArrows } = setup(); - - // Navigate to next week - await userEvent.click(getForwardArrow()); - - const arrows = getEventMigrateArrows(); - const firstEventForwardArrow = arrows.find( - (arrow) => - arrow.getAttribute("title") === "Migrate to next week" && - arrow.textContent === ">", - ); - - await userEvent.click(firstEventForwardArrow!); - }); - - it("should have proper accessibility for event migrate arrows", () => { - const { getEventMigrateArrows } = setup(); - const arrows = getEventMigrateArrows(); - - arrows.forEach((arrow) => { - expect(arrow).toHaveAttribute("role", "button"); - expect(arrow).toHaveAttribute("tabIndex", "0"); - expect(arrow).toHaveAttribute("title"); - }); - }); - - it("should support keyboard navigation for event arrows", async () => { - const consoleSpy = jest.spyOn(console, "log"); - const { getEventMigrateArrows } = setup(); - const arrows = getEventMigrateArrows(); - - const firstEventForwardArrow = arrows.find( - (arrow) => - arrow.getAttribute("title") === "Migrate to next week" && - arrow.textContent === ">", - ); - - // Focus and press Enter - firstEventForwardArrow!.focus(); - await userEvent.keyboard("{Enter}"); - - // Clear console and test Space key - consoleSpy.mockClear(); - await userEvent.keyboard(" "); - }); - - it("should disable event migration arrows when on Next Week", async () => { - const { getForwardArrow, getEventMigrateArrows } = setup(); - - // Navigate to next week - await userEvent.click(getForwardArrow()); - - const arrows = getEventMigrateArrows(); - - // All arrows should be disabled when on Next Week - arrows.forEach((arrow) => { - expect(arrow).toHaveAttribute("tabIndex", "-1"); - }); - }); - - it("should not trigger migration when disabled arrows are clicked", async () => { - const consoleSpy = jest.spyOn(console, "log"); - const { getForwardArrow, getEventMigrateArrows } = setup(); - - // Navigate to next week - await userEvent.click(getForwardArrow()); - - const arrows = getEventMigrateArrows(); - const firstArrow = arrows[0]; - - // Clear any previous console logs - consoleSpy.mockClear(); - - // Try to click disabled arrow - await userEvent.click(firstArrow); - }); - - it("should re-enable event migration arrows when navigating back to This Week", async () => { - const { getForwardArrow, getBackArrow, getEventMigrateArrows } = setup(); - - // Navigate to next week and back - await userEvent.click(getForwardArrow()); - await userEvent.click(getBackArrow()); - - const arrows = getEventMigrateArrows(); - - // All arrows should be re-enabled - arrows.forEach((arrow) => { - expect(arrow).toHaveAttribute("tabIndex", "0"); - expect(arrow.getAttribute("title")).toMatch( - /Migrate to (previous|next) week/, - ); - }); - }); - }); - - describe("Keyboard Navigation Blocking", () => { - it("should prevent ENTER key navigation when not all checkboxes are checked", async () => { - const mockOnNext = jest.fn(); - render( - , - ); - - // Initially, no checkboxes should be checked - const eventCheckbox = screen.getByLabelText( - "Migrate an event to next week", - ) as HTMLInputElement; - const monthCheckbox = screen.getByLabelText( - "Migrate an event to next month", - ) as HTMLInputElement; - const viewCheckbox = screen.getByLabelText( - "Go to next week/month", - ) as HTMLInputElement; - - expect(eventCheckbox).not.toBeChecked(); - expect(monthCheckbox).not.toBeChecked(); - expect(viewCheckbox).not.toBeChecked(); - - // Try to press ENTER - should not navigate - await userEvent.keyboard("{Enter}"); - expect(mockOnNext).not.toHaveBeenCalled(); - }); - - it("should prevent RIGHT ARROW key navigation when not all checkboxes are checked", async () => { - const mockOnNext = jest.fn(); - render( - , - ); - - // Try to press RIGHT ARROW - should not navigate - await userEvent.keyboard("{ArrowRight}"); - expect(mockOnNext).not.toHaveBeenCalled(); - }); - - it("should prevent 'k' key navigation when not all checkboxes are checked", async () => { - render( - , - ); - - // Initially, no checkboxes should be checked - const eventCheckbox = screen.getByLabelText( - "Migrate an event to next week", - ) as HTMLInputElement; - const monthCheckbox = screen.getByLabelText( - "Migrate an event to next month", - ) as HTMLInputElement; - const viewCheckbox = screen.getByLabelText( - "Go to next week/month", - ) as HTMLInputElement; - - expect(eventCheckbox).not.toBeChecked(); - expect(monthCheckbox).not.toBeChecked(); - expect(viewCheckbox).not.toBeChecked(); - - // Try to press 'k' - should not navigate - fireEvent.keyDown(document, { key: "k", code: "KeyK" }); - expect(mockOnNext).not.toHaveBeenCalled(); - }); - - it("should be ready for navigation after all checkboxes are checked", async () => { - render( - , - ); - - // Complete all tasks by interacting with the component - // 1. Migrate a week event - const eventMigrateArrows = screen.getAllByTitle("Migrate to next week"); - await userEvent.click(eventMigrateArrows[0]); - - // 2. Migrate a month event - const monthMigrateArrows = screen.getAllByTitle("Migrate to next month"); - await userEvent.click(monthMigrateArrows[0]); - - // 3. Navigate to next week - await userEvent.click(screen.getByTitle("Next week")); - - // Now all checkboxes should be checked - const eventCheckbox = screen.getByLabelText( - "Migrate an event to next week", - ) as HTMLInputElement; - const monthCheckbox = screen.getByLabelText( - "Migrate an event to next month", - ) as HTMLInputElement; - const viewCheckbox = screen.getByLabelText( - "Go to next week/month", - ) as HTMLInputElement; - - expect(eventCheckbox).toBeChecked(); - expect(monthCheckbox).toBeChecked(); - expect(viewCheckbox).toBeChecked(); - - // Verify that the Next button is enabled (which means ENTER should work) - const nextButton = screen.getByLabelText("Next"); - expect(nextButton).not.toBeDisabled(); - - // The component should be ready for keyboard navigation - // This test verifies that the component state is correct for ENTER key navigation - // The actual ENTER key handling is tested in the integration tests - }); - - it("should be ready for 'k' key navigation after all checkboxes are checked", async () => { - render( - , - ); - - // Complete all tasks by interacting with the component - // 1. Migrate a week event - const eventMigrateArrows = screen.getAllByTitle("Migrate to next week"); - await userEvent.click(eventMigrateArrows[0]); - - // 2. Migrate a month event - const monthMigrateArrows = screen.getAllByTitle("Migrate to next month"); - await userEvent.click(monthMigrateArrows[0]); - - // 3. Navigate to next week - await userEvent.click(screen.getByTitle("Next week")); - - // Now all checkboxes should be checked - const eventCheckbox = screen.getByLabelText( - "Migrate an event to next week", - ) as HTMLInputElement; - const monthCheckbox = screen.getByLabelText( - "Migrate an event to next month", - ) as HTMLInputElement; - const viewCheckbox = screen.getByLabelText( - "Go to next week/month", - ) as HTMLInputElement; - - expect(eventCheckbox).toBeChecked(); - expect(monthCheckbox).toBeChecked(); - expect(viewCheckbox).toBeChecked(); - - // Verify that the Next button is enabled (which means 'k' key should work) - const nextButton = screen.getByLabelText("Next"); - expect(nextButton).not.toBeDisabled(); - - // The component should be ready for keyboard navigation - // This test verifies that the component state is correct for 'k' key navigation - // The actual 'k' key handling is tested in the integration tests - }); - - it("should have Next button disabled when not all checkboxes are checked", () => { - render(); - - const nextButton = screen.getByLabelText("Next"); - expect(nextButton).toBeDisabled(); - }); - - it("should have Next button enabled after all checkboxes are checked", async () => { - render(); - - // Complete all tasks - const eventMigrateArrows = screen.getAllByTitle("Migrate to next week"); - await userEvent.click(eventMigrateArrows[0]); - - const monthMigrateArrows = screen.getAllByTitle("Migrate to next month"); - await userEvent.click(monthMigrateArrows[0]); - - await userEvent.click(screen.getByTitle("Next week")); - - // Wait for state updates - await new Promise((resolve) => setTimeout(resolve, 100)); - - const nextButton = screen.getByLabelText("Next"); - expect(nextButton).not.toBeDisabled(); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/MigrationSandbox.tsx b/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/MigrationSandbox.tsx deleted file mode 100644 index ec4a56d7b..000000000 --- a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/MigrationSandbox.tsx +++ /dev/null @@ -1,544 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import { colorByPriority } from "@web/common/styles/theme.util"; -import { Divider } from "@web/components/Divider"; -import { OnboardingText } from "../../../components"; -import { OnboardingStepProps } from "../../../components/Onboarding"; -import { OnboardingTwoRowLayout } from "../../../components/layouts/OnboardingTwoRowLayout"; -import { WeekHighlighter } from "./WeekHighlighter"; -import { - CalendarDay, - CalendarGrid, - Checkbox, - CheckboxContainer, - CheckboxLabel, - EventArrows, - EventItem, - EventList, - EventText, - MainContent, - MiddleColumn, - MigrateArrow, - MonthHeader, - MonthPicker, - MonthTitle, - RightColumn, - SectionNavigationArrow, - SectionNavigationArrows, - SectionTitle, - SectionTitleText, - Sidebar, - SidebarSection, -} from "./styled"; -import { useMigrationLogic } from "./useMigrationLogic"; -import { useMigrationSandbox } from "./useMigrationSandbox"; - -export const MigrationSandbox: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, - onNavigationControlChange, - isNavPrevented = false, -}) => { - const { - somedayEvents, - weekLabel, - currentWeekIndex, - canNavigateBack, - canNavigateForward, - navigateToNextWeek, - navigateToPreviousWeek, - handleEventClick, - } = useMigrationSandbox( - () => setHasMigratedEvent(true), - (eventName: string) => { - setMigratedEventName(eventName); - setShowMigratedEventEllipse(true); - setShouldHighlightNavigation(true); - }, - () => { - setHasViewedNextWeek(true); - setShouldHighlightNavigation(false); // Remove highlight once they navigate - }, - ); - const { - weeks, - nextMonthWeeks, - isCurrentWeekVisible, - currentWeekIndex: calendarWeekIndex, - } = useMigrationLogic(); - - const thisWeekLabelRef = useRef(null); - const calendarGridRef = useRef(null); - const nextCalendarGridRef = useRef(null); - const [ellipsePosition, setEllipsePosition] = useState({ - x: 280, - y: 60, - width: 280, - height: 40, - }); - const [nextMonthFirstWeekPosition, setNextMonthFirstWeekPosition] = useState({ - x: 280, - y: 60, - width: 280, - height: 40, - }); - const [hasMigratedEvent, setHasMigratedEvent] = useState(false); - const [hasViewedNextWeek, setHasViewedNextWeek] = useState(false); - const [hasMigratedMonthEvent, setHasMigratedMonthEvent] = useState(false); - const [migratedEventName, setMigratedEventName] = useState( - null, - ); - const [migratedMonthEventName, setMigratedMonthEventName] = useState< - string | null - >(null); - const [showMigratedEventEllipse, setShowMigratedEventEllipse] = - useState(false); - const [showMonthMigratedEllipse, setShowMonthMigratedEllipse] = - useState(false); - const [shouldHighlightNavigation, setShouldHighlightNavigation] = - useState(false); - - // Disable the footer's Next button until all steps are completed - const isAllChecked = - hasMigratedEvent && hasMigratedMonthEvent && hasViewedNextWeek; - - const [thisMonthEvents, setThisMonthEvents] = useState([ - { text: "🤖 Start AI course", color: colorByPriority.work }, - { text: "🏠 Book Airbnb", color: colorByPriority.relationships }, - { text: "📚 Return library books", color: colorByPriority.self }, - ] as { text: string; color: string }[]); - const [nextMonthEventsList, setNextMonthEventsList] = useState< - { text: string; color: string }[] - >([]); - - useEffect(() => { - const computeEllipse = () => { - if (!calendarGridRef.current || calendarWeekIndex === -1) return; - - const calendarGridRect = calendarGridRef.current.getBoundingClientRect(); - - // Get the container element position to calculate relative coordinates - const container = calendarGridRef.current.closest( - '[class*="MainContent"]', - ) as HTMLElement; - const containerRect = container?.getBoundingClientRect(); - if (!containerRect) return; - - // Calculate geometry - const totalWeeks = 5; // calendar uses 5 rows - const currentWeekRowHeight = calendarGridRect.height / totalWeeks; - const colWidth = calendarGridRect.width / 7; - - // Include all days in the current week for the ellipse - const thisWeek = weeks[calendarWeekIndex]; - const currentWeekDayIndices = thisWeek.days - .map((d, idx) => ({ idx, isCurrentWeek: d.isCurrentWeek })) - .filter((x) => x.isCurrentWeek) - .map((x) => x.idx); - - const firstIdx = currentWeekDayIndices.length - ? Math.min(...currentWeekDayIndices) - : 0; - const lastIdx = currentWeekDayIndices.length - ? Math.max(...currentWeekDayIndices) - : 6; - - const ellipseX = - calendarGridRect.left - containerRect.left + firstIdx * colWidth - 8; - const ellipseY = - calendarGridRect.top - - containerRect.top + - calendarWeekIndex * currentWeekRowHeight - - 8; - const ellipseWidth = (lastIdx - firstIdx + 1) * colWidth + 16; - const ellipseHeight = currentWeekRowHeight + 16; - - setEllipsePosition({ - x: ellipseX, - y: ellipseY, - width: ellipseWidth, - height: ellipseHeight, - }); - - // Compute rectangle for first week of the NEXT month widget - if (nextCalendarGridRef.current) { - const nextRect = nextCalendarGridRef.current.getBoundingClientRect(); - const totalWeeksNext = 5; - const rowHeightNext = nextRect.height / totalWeeksNext; - const colWidthNext = nextRect.width / 7; - - const nmX = nextRect.left - containerRect.left - 8; - const nmY = nextRect.top - containerRect.top + 0 * rowHeightNext - 8; - const nmWidth = 7 * colWidthNext + 16; - const nmHeight = rowHeightNext + 16; - - setNextMonthFirstWeekPosition({ - x: nmX, - y: nmY, - width: nmWidth, - height: nmHeight, - }); - } - }; - - // Slight delay to ensure layout is ready - const timer = setTimeout(computeEllipse, 100); - - // Recompute on resize for responsive behavior - window.addEventListener("resize", computeEllipse); - - return () => { - clearTimeout(timer); - window.removeEventListener("resize", computeEllipse); - }; - }, [calendarWeekIndex, isCurrentWeekVisible, weeks]); - - const content = ( - - - - - {weekLabel} - - - {"<"} - - - {">"} - - - - - {somedayEvents.map((event, index) => ( - - {event.text} - - { - e.stopPropagation(); - if (currentWeekIndex !== 1) { - handleEventClick(event.text, index, "back"); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - if (currentWeekIndex !== 1) { - handleEventClick(event.text, index, "back"); - } - } - }} - role="button" - tabIndex={currentWeekIndex === 1 ? -1 : 0} - title="Migrate to previous week" - > - {"<"} - - { - e.stopPropagation(); - if (currentWeekIndex !== 1) { - handleEventClick(event.text, index, "forward"); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - if (currentWeekIndex !== 1) { - handleEventClick(event.text, index, "forward"); - } - } - }} - role="button" - tabIndex={currentWeekIndex === 1 ? -1 : 0} - title="Migrate to next week" - > - {">"} - - - - ))} - - - - - - {currentWeekIndex === 0 ? "This Month" : "Next Month"} - - - {(currentWeekIndex === 0 - ? thisMonthEvents - : nextMonthEventsList - ).map((event, index) => ( - - {event.text} - - { - e.stopPropagation(); - // No-op for back month migration - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - // No-op for back month migration - } - }} - role="button" - tabIndex={-1} - title="Migrate to previous month" - > - {"<"} - - { - e.stopPropagation(); - if (currentWeekIndex === 0) { - setHasMigratedMonthEvent(true); - setShowMonthMigratedEllipse(true); - setMigratedMonthEventName(event.text); - // Remove from This Month and add to Next Month list - setThisMonthEvents((prev) => - prev.filter((_, i) => i !== index), - ); - setNextMonthEventsList((prev) => [...prev, event]); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - if (currentWeekIndex === 0) { - setHasMigratedMonthEvent(true); - setShowMonthMigratedEllipse(true); - setMigratedMonthEventName(event.text); - setThisMonthEvents((prev) => - prev.filter((_, i) => i !== index), - ); - setNextMonthEventsList((prev) => [...prev, event]); - } - } - }} - role="button" - tabIndex={currentWeekIndex === 1 ? -1 : 0} - title="Migrate to next month" - > - {">"} - - - - ))} - - - - - - - This Month - - - {weeks.map((week, weekIndex) => - week.days.map((day, dayIndex) => ( - - {day.isCurrentMonth ? day.day : ""} - - )), - )} - - {/* Spacer between months */} -
- - Next Month - - - {nextMonthWeeks.map((week, weekIndex) => - week.days.map((day, dayIndex) => ( - - {day.isCurrentMonth ? day.day : ""} - - )), - )} - - - - - - - - Migrate an event to next week - - - - - - Migrate an event to next month - - - - - - Go to next week/month - - - - {isCurrentWeekVisible && ( - <> - {}} - /> -
- This week (pretend) -
- - )} - {showMigratedEventEllipse && calendarGridRef.current && ( - {}} - /> - )} - {showMonthMigratedEllipse && nextCalendarGridRef.current && ( - {}} - /> - )} - {showMonthMigratedEllipse && migratedMonthEventName && ( -
- {migratedMonthEventName} is here now -
- )} - {showMigratedEventEllipse && - migratedEventName && - calendarGridRef.current && ( -
- {migratedEventName} is here now -
- )} - - ); - - return ( - {}} - onPrevious={onPrevious} - onSkip={onSkip} - content={content} - isNextBtnDisabled={!isAllChecked} - onNavigationControlChange={onNavigationControlChange} - isNavPrevented={!isAllChecked || isNavPrevented} - /> - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/WeekHighlighter.tsx b/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/WeekHighlighter.tsx deleted file mode 100644 index 9166c6e52..000000000 --- a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/WeekHighlighter.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; - -interface WeekHighlighterProps { - x: number; - y: number; - width?: number; - height?: number; - color?: string; - textColor?: string; - strokeWidth?: number; - className?: string; - onClick?: () => void; - text?: string; -} - -export const WeekHighlighter: React.FC = ({ - x, - y, - width = 280, - height = 40, - color, - textColor, - strokeWidth = 3, - className, - onClick, - text, -}) => { - const canvasRef = useRef(null); - const [isNarrowScreen, setIsNarrowScreen] = useState(false); - - useEffect(() => { - const checkScreenWidth = () => { - // Check if there's enough space to the right of the ellipse - // Consider the container width and position with more generous margin - const availableSpaceToRight = window.innerWidth - (x + width + 40); - const textWidth = 180; // Increased approximate width of "pretend we're here" text - setIsNarrowScreen(availableSpaceToRight < textWidth); - }; - - checkScreenWidth(); - window.addEventListener("resize", checkScreenWidth); - - return () => { - window.removeEventListener("resize", checkScreenWidth); - }; - }, [x, width]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - // Clear canvas - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Draw a rounded rectangle around the week - const startX = 5; - const startY = 5; - const rectWidth = width - 10; - const rectHeight = height - 10; - const cornerRadius = 12; // Rounded corner radius - - // Draw rounded rectangle - ctx.beginPath(); - ctx.moveTo(startX + cornerRadius, startY); - ctx.lineTo(startX + rectWidth - cornerRadius, startY); - ctx.quadraticCurveTo( - startX + rectWidth, - startY, - startX + rectWidth, - startY + cornerRadius, - ); - ctx.lineTo(startX + rectWidth, startY + rectHeight - cornerRadius); - ctx.quadraticCurveTo( - startX + rectWidth, - startY + rectHeight, - startX + rectWidth - cornerRadius, - startY + rectHeight, - ); - ctx.lineTo(startX + cornerRadius, startY + rectHeight); - ctx.quadraticCurveTo( - startX, - startY + rectHeight, - startX, - startY + rectHeight - cornerRadius, - ); - ctx.lineTo(startX, startY + cornerRadius); - ctx.quadraticCurveTo(startX, startY, startX + cornerRadius, startY); - ctx.closePath(); - - ctx.strokeStyle = color; - ctx.lineWidth = strokeWidth; - ctx.stroke(); - - // Draw text with responsive positioning (unless text is explicitly empty string) - if (text !== "") { - const displayText = text || "pretend we're here"; - ctx.font = "18px Caveat, cursive"; - ctx.fillStyle = textColor || color; - - let textX: number; - let textY: number; - - if (isNarrowScreen) { - // Position text underneath the entire month widget when screen is narrow - ctx.textAlign = "center"; - ctx.textBaseline = "top"; - textX = startX + width / 2; // Centered horizontally with ellipse - // Position well below the ellipse to clear the entire month widget - // The month widget extends below the ellipse, so we need more space - textY = startX + height + 60; // Well below the month widget - } else { - // Position text to the right of the ellipse when there's space - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - textX = startX + width + 15; // To the right of the ellipse - textY = startY + height / 2; // Vertically centered with ellipse - } - - ctx.fillText(displayText, textX, textY); - } - }, [x, y, width, height, color, strokeWidth, text, isNarrowScreen]); - - // Calculate canvas dimensions based on text positioning - const canvasWidth = isNarrowScreen ? width + 10 : width + 180; - const canvasHeight = isNarrowScreen ? height + 90 : height + 30; - - return ( - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/styled.ts b/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/styled.ts deleted file mode 100644 index 456fd4ab5..000000000 --- a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/styled.ts +++ /dev/null @@ -1,302 +0,0 @@ -import styled, { css, keyframes } from "styled-components"; - -// Keyframes for pulsing animation similar to onboarding buttons -const pulse = keyframes` - 0% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.02); - opacity: 0.9; - } - 100% { - transform: scale(1); - opacity: 1; - } -`; - -export const MainContent = styled.div` - flex: 1; - background-color: #12151b; - border: 1px solid #333; - display: flex; - margin: 20px; - gap: 20px; - z-index: 5; - position: relative; - overflow: visible; - /* Fix container height so ellipse positions remain stable and sidebar fits */ - height: 580px; -`; - -export const Sidebar = styled.div` - width: 300px; - background-color: #23262f; - display: flex; - flex-direction: column; - padding: 20px; - gap: 20px; -`; - -export const SidebarSection = styled.div` - display: flex; - flex-direction: column; - gap: 12px; -`; - -export const SectionTitle = styled.h4.withConfig({ - shouldForwardProp: (prop) => prop !== "ref", -})` - font-family: "Rubik", sans-serif; - font-size: 18px; - color: ${({ theme }) => theme.color.common.white}; - margin: 0 0 8px 0; - display: flex; - align-items: center; - justify-content: space-between; -`; - -export const SectionTitleText = styled.span` - flex: 1; -`; - -export const SectionNavigationArrows = styled.div` - display: flex; - gap: 8px; -`; - -export const SectionNavigationArrow = styled.button<{ - disabled?: boolean; - $shouldHighlight?: boolean; -}>` - background: none; - border: 1px solid ${({ theme }) => theme.color.border.primary}; - color: ${({ theme }) => theme.color.common.white}; - font-size: 14px; - font-weight: bold; - padding: 4px 8px; - border-radius: 4px; - cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")}; - transition: all 0.2s ease; - opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; - - ${({ $shouldHighlight, theme }) => - $shouldHighlight && - css` - background: ${theme.color.border.primary}30; - border-color: ${theme.color.text.accent}; - color: ${theme.color.common.white}; - animation: ${pulse} 3s ease-in-out infinite; - box-shadow: 0 0 4px ${theme.color.text.accent}20; - `} - - &:hover:not(:disabled) { - background: ${({ theme, $shouldHighlight }) => - $shouldHighlight ? theme.color.text.accent : theme.color.bg.secondary}; - transform: scale(1.05); - } - - &:active:not(:disabled) { - background: ${({ theme }) => theme.color.bg.primary}; - transform: scale(0.95); - } - - &:disabled { - cursor: not-allowed; - } -`; - -export const EventList = styled.div` - display: flex; - flex-direction: column; - gap: 8px; -`; - -export const EventItem = styled.div<{ color: string }>` - font-family: "Rubik", sans-serif; - font-size: 14px; - background: ${({ color }) => color}; - color: ${({ theme }) => theme.color.common.black}; - border-radius: 4px; - padding: 12px; - width: 100%; - cursor: pointer; - transition: all 0.2s ease; - user-select: none; - display: flex; - align-items: center; - justify-content: space-between; - - &:hover { - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - } - - &:active { - transform: translateY(0); - } -`; - -export const EventText = styled.span` - flex: 1; -`; - -export const EventArrows = styled.div` - display: flex; - gap: 4px; -`; - -export const MigrateArrow = styled.span<{ disabled?: boolean }>` - padding: 4px 8px; - font-size: 12px; - font-weight: bold; - cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")}; - border-radius: 3px; - transition: all 0.2s ease; - user-select: none; - opacity: ${({ disabled }) => (disabled ? 0.3 : 1)}; - - &:hover { - background: ${({ disabled }) => (disabled ? "none" : "rgba(0, 0, 0, 0.1)")}; - transform: ${({ disabled }) => (disabled ? "none" : "scale(1.1)")}; - } - - &:active { - background: ${({ disabled }) => (disabled ? "none" : "rgba(0, 0, 0, 0.2)")}; - transform: ${({ disabled }) => (disabled ? "none" : "scale(0.95)")}; - } -`; - -export const MiddleColumn = styled.div` - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; -`; - -export const MonthPicker = styled.div` - width: 280px; - height: 435px; - background-color: #2a2d3a; - border: 2px solid #444; - border-radius: 8px; - padding: 16px; - display: flex; - flex-direction: column; - position: relative; -`; - -export const MonthHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -`; - -export const MonthTitle = styled.h3` - font-family: "Rubik", sans-serif; - font-size: 16px; - color: ${({ theme }) => theme.color.common.white}; - margin: 0; -`; - -export const WeekDays = styled.div.withConfig({ - shouldForwardProp: (prop) => prop !== "isCurrentWeek", -})<{ isCurrentWeek: boolean }>` - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 4px; - margin-bottom: 8px; - border-radius: 8px; - padding: 2px; -`; - -export const WeekDayLabel = styled.div.withConfig({ - shouldForwardProp: (prop) => prop !== "isCurrentWeek", -})<{ isCurrentWeek: boolean }>` - font-family: "Rubik", sans-serif; - font-size: 12px; - color: #888; - font-weight: 400; - text-align: center; - padding: 4px; -`; - -export const CalendarGrid = styled.div.withConfig({ - shouldForwardProp: (prop) => prop !== "ref", -})<{ isCurrentWeek: boolean }>` - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 4px; - flex: 1; - background-color: transparent; - border-radius: 8px; - padding: 8px; -`; - -export const CalendarDay = styled.div.withConfig({ - shouldForwardProp: (prop) => - !["isCurrentWeek", "isToday"].includes(prop as string), -})<{ - isCurrentWeek: boolean; - isToday: boolean; -}>` - font-family: "Rubik", sans-serif; - font-size: 12px; - color: ${({ isCurrentWeek, theme }) => { - if (isCurrentWeek) return theme.color.common.white; - return "#888"; - }}; - - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - padding: 4px; - cursor: pointer; - transition: all 0.2s ease; - font-weight: ${({ isCurrentWeek }) => (isCurrentWeek ? "600" : "400")}; - - &:hover { - background-color: ${({ isCurrentWeek }) => { - if (isCurrentWeek) return "#4a4d54"; - return "#2a2d35"; - }}; - } -`; - -export const RightColumn = styled.div` - flex: 1; - display: flex; - flex-direction: column; - gap: 16px; - padding: 20px; -`; - -export const CheckboxContainer = styled.div` - display: flex; - align-items: center; - gap: 12px; - margin-top: 20px; -`; - -export const Checkbox = styled.input` - width: 16px; - height: 16px; - cursor: default; - accent-color: ${({ theme }) => theme.color.text.accent}; - pointer-events: none; - - &:not(:checked) { - accent-color: ${({ theme }) => theme.color.fg.primary}; - } -`; - -export const CheckboxLabel = styled.label` - font-family: "VT323", monospace; - font-size: 24px; - color: ${({ theme }) => theme.color.common.white}; - cursor: pointer; -`; diff --git a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationLogic.test.ts b/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationLogic.test.ts deleted file mode 100644 index d4c51b525..000000000 --- a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationLogic.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import dayjs from "@core/util/date/dayjs"; -import { useMigrationLogic } from "@web/views/Onboarding/steps/events/MigrationSandbox/useMigrationLogic"; - -// Mock dayjs to use a fixed date for consistent testing -const mockDate = dayjs("2025-09-10"); // Wednesday, September 10, 2025 -jest.mock("@core/util/date/dayjs", () => { - const { default: originalDayjs } = jest.requireActual( - "@core/util/date/dayjs", - ); - - const mockDayjs = (date?: unknown) => { - if (date === undefined) { - return originalDayjs(mockDate); - } - return originalDayjs(date); - }; - Object.assign(mockDayjs, originalDayjs); - return mockDayjs; -}); - -describe("useCalendarLogic", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("should return correct month title", () => { - const { result } = renderHook(() => useMigrationLogic()); - - expect(result.current.monthTitle).toBe("September 2025"); - }); - - it("should return correct week day labels", () => { - const { result } = renderHook(() => useMigrationLogic()); - - expect(result.current.weekDays).toEqual([ - "S", - "M", - "T", - "W", - "T", - "F", - "S", - ]); - }); - - it("should identify current week correctly", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Find the week that contains the current day (September 10, 2025) - const currentWeek = result.current.weeks.find((week) => - week.days.some((day) => day.isToday), - ); - - expect(currentWeek).toBeDefined(); - expect(currentWeek?.isCurrentWeek).toBe(true); - }); - - it("should highlight all days in current week (Sunday to Saturday)", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Find the current week - const currentWeek = result.current.weeks.find((week) => - week.days.some((day) => day.isToday), - ); - - expect(currentWeek).toBeDefined(); - - // All days in the current week should be highlighted - currentWeek?.days.forEach((day) => { - expect(day.isCurrentWeek).toBe(true); - }); - }); - - it("should identify today correctly", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Find today's date - const today = result.current.weeks - .flatMap((week) => week.days) - .find((day) => day.isToday); - - expect(today).toBeDefined(); - expect(today?.day).toBe(10); // September 10, 2025 - expect(today?.isCurrentMonth).toBe(true); - expect(today?.isCurrentWeek).toBe(true); - }); - - it("should have correct current week days (September 7-13, 2025)", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Find the current week - const currentWeek = result.current.weeks.find((week) => - week.days.some((day) => day.isToday), - ); - - expect(currentWeek).toBeDefined(); - - // The current week should contain days 7-13 - const currentWeekDays = currentWeek?.days - .filter((day) => day.isCurrentMonth) - .map((day) => day.day) - .sort((a, b) => a - b); - - expect(currentWeekDays).toEqual([7, 8, 9, 10, 11, 12, 13]); - }); - - it("should mark non-current month days correctly", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Find days that are not in the current month - const nonCurrentMonthDays = result.current.weeks - .flatMap((week) => week.days) - .filter((day) => !day.isCurrentMonth); - - // All non-current month days should not be in current week - nonCurrentMonthDays.forEach((day) => { - expect(day.isCurrentWeek).toBe(false); - }); - }); - - it("should have correct isCurrentWeekVisible value", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Since we're testing with September 10, 2025, the current week should be visible - expect(result.current.isCurrentWeekVisible).toBe(true); - }); - - it("should have exactly 5 weeks", () => { - const { result } = renderHook(() => useMigrationLogic()); - - expect(result.current.weeks).toHaveLength(5); - }); - - it("should have exactly 7 days per week", () => { - const { result } = renderHook(() => useMigrationLogic()); - - result.current.weeks.forEach((week) => { - expect(week.days).toHaveLength(7); - }); - }); - - it("should correctly calculate currentWeekIndex", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Since we're using September 10, 2025 (Wednesday), currentWeekIndex should be valid - expect(result.current.currentWeekIndex).toBeGreaterThanOrEqual(0); - expect(result.current.currentWeekIndex).toBeLessThan(5); - - // The week at currentWeekIndex should contain today - const currentWeek = result.current.weeks[result.current.currentWeekIndex]; - expect(currentWeek.isCurrentWeek).toBe(true); - expect(currentWeek.days.some((day) => day.isToday)).toBe(true); - }); - - it("should have Saturday (day 13) as the last day of the current week", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Find the current week - const currentWeek = result.current.weeks[result.current.currentWeekIndex]; - expect(currentWeek.isCurrentWeek).toBe(true); - - // Saturday should be the last day (index 6) of the current week - const saturdayDay = currentWeek.days[6]; // Saturday is index 6 - expect(saturdayDay.isCurrentWeek).toBe(true); - expect(saturdayDay.day).toBe(13); // September 13, 2025 is the Saturday of the week containing September 10 - expect(saturdayDay.isCurrentMonth).toBe(true); - }); - - it("should provide correct week structure for arrow targeting", () => { - const { result } = renderHook(() => useMigrationLogic()); - - // Verify that each week has 7 days (Sunday to Saturday) - result.current.weeks.forEach((week) => { - expect(week.days).toHaveLength(7); - // First day should be Sunday (index 0), last should be Saturday (index 6) - }); - - // The current week should be identifiable - const currentWeek = result.current.weeks[result.current.currentWeekIndex]; - expect(currentWeek).toBeDefined(); - expect(currentWeek.isCurrentWeek).toBe(true); - - // Saturday of current week should be at index 6 - const saturday = currentWeek.days[6]; - expect(saturday.isCurrentWeek).toBe(true); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationLogic.ts b/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationLogic.ts deleted file mode 100644 index 09f17de7c..000000000 --- a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationLogic.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useMemo } from "react"; -import dayjs from "@core/util/date/dayjs"; - -export interface CalendarDay { - day: number; - isCurrentMonth: boolean; - isCurrentWeek: boolean; - isToday: boolean; -} - -export interface CalendarWeek { - days: CalendarDay[]; - isCurrentWeek: boolean; -} - -export interface CalendarData { - monthTitle: string; - weekDays: string[]; - weeks: CalendarWeek[]; - isCurrentWeekVisible: boolean; - currentWeekIndex: number; - // Additional: a second month (next month) rendered below with same styles - nextMonthTitle: string; - nextMonthWeeks: CalendarWeek[]; -} - -export const useMigrationLogic = (): CalendarData => { - return useMemo(() => { - const buildMonth = (baseDate: dayjs.Dayjs) => { - const month = baseDate.month(); - const firstDayOfMonth = baseDate.startOf("month"); - const firstSunday = firstDayOfMonth.subtract( - firstDayOfMonth.day(), - "day", - ); - const weekStartCurrent = baseDate.startOf("week"); - const weekEndCurrent = weekStartCurrent.add(6, "day"); - - const calendarEnd = firstSunday.add(35, "day"); - const isCurrentWeekVisible = - ((weekStartCurrent.isAfter(firstSunday) || - weekStartCurrent.isSame(firstSunday)) && - weekStartCurrent.isBefore(calendarEnd)) || - ((weekEndCurrent.isAfter(firstSunday) || - weekEndCurrent.isSame(firstSunday)) && - weekEndCurrent.isBefore(calendarEnd)) || - (weekStartCurrent.isBefore(firstSunday) && - (weekEndCurrent.isAfter(firstSunday) || - weekEndCurrent.isSame(firstSunday))); - - const title = baseDate.format("MMMM YYYY"); - const weekDays = ["S", "M", "T", "W", "T", "F", "S"]; - - const weeks: CalendarWeek[] = []; - let currentWeekIndex = -1; - for (let weekIndex = 0; weekIndex < 5; weekIndex++) { - const weekStart = firstSunday.add(weekIndex * 7, "day"); - const days: CalendarDay[] = []; - for (let dayIndex = 0; dayIndex < 7; dayIndex++) { - const dayDate = weekStart.add(dayIndex, "day"); - const day = dayDate.date(); - const isCurrentMonth = dayDate.month() === month; - const isCurrentWeek = dayDate.isBetween( - weekStartCurrent, - weekEndCurrent, - "day", - "[]", - ); - const isToday = dayDate.isSame(baseDate, "day"); - days.push({ day, isCurrentMonth, isCurrentWeek, isToday }); - } - const isCurrentWeekForThisWeek = days.some((d) => d.isCurrentWeek); - if (isCurrentWeekForThisWeek) currentWeekIndex = weekIndex; - weeks.push({ days, isCurrentWeek: isCurrentWeekForThisWeek }); - } - - return { title, weekDays, weeks, isCurrentWeekVisible, currentWeekIndex }; - }; - - const currentDate = dayjs("2025-09-10"); - const thisMonth = buildMonth(currentDate); - const nextMonth = buildMonth(currentDate.add(1, "month")); - - return { - monthTitle: thisMonth.title, - weekDays: thisMonth.weekDays, - weeks: thisMonth.weeks, - isCurrentWeekVisible: thisMonth.isCurrentWeekVisible, - currentWeekIndex: thisMonth.currentWeekIndex, - nextMonthTitle: nextMonth.title, - nextMonthWeeks: nextMonth.weeks, - }; - }, []); -}; diff --git a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationSandbox.ts b/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationSandbox.ts deleted file mode 100644 index 8a377bc5a..000000000 --- a/packages/web/src/views/Onboarding/steps/events/MigrationSandbox/useMigrationSandbox.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useCallback, useState } from "react"; -import { colorByPriority } from "@web/common/styles/theme.util"; - -// Types -export interface SomedayEvent { - text: string; - color: string; -} - -// Constants - Different events for each week -const thisWeekEvents: SomedayEvent[] = [ - { text: "🥙 Meal prep", color: colorByPriority.work }, - { text: "🥗 Get groceries", color: colorByPriority.self }, - { text: "☕️ Coffee with Mom", color: colorByPriority.relationships }, -]; - -const nextWeekEvents: SomedayEvent[] = [ - { text: "📑 Submit report", color: colorByPriority.work }, - { text: "🧹 Clean house", color: colorByPriority.self }, -]; - -// Custom hook for managing someday event migration -export const useMigrationSandbox = ( - onEventMigrated?: () => void, - onEventMigratedForward?: (eventName: string) => void, - onNavigatedToNextWeek?: () => void, -) => { - const [currentWeekIndex, setCurrentWeekIndex] = useState(0); // 0 = this week, 1 = next week - const [thisWeekEventsList, setThisWeekEventsList] = - useState(thisWeekEvents); - const [nextWeekEventsList, setNextWeekEventsList] = - useState(nextWeekEvents); - - // Get events based on current week - const somedayEvents = - currentWeekIndex === 0 ? thisWeekEventsList : nextWeekEventsList; - - // Get current week label - const weekLabel = currentWeekIndex === 0 ? "This Week" : "Next Week"; - - // Navigation functions - const canNavigateBack = currentWeekIndex > 0; - const canNavigateForward = currentWeekIndex < 1; - - const navigateToNextWeek = useCallback(() => { - if (canNavigateForward) { - setCurrentWeekIndex(1); - onNavigatedToNextWeek?.(); - } - }, [canNavigateForward, onNavigatedToNextWeek]); - - const navigateToPreviousWeek = useCallback(() => { - if (canNavigateBack) { - setCurrentWeekIndex(0); - } - }, [canNavigateBack]); - - // Handle event click - logs to console as requested - const handleEventClick = useCallback( - (eventText: string, eventIndex: number, direction: "back" | "forward") => { - // Actually move the event between lists - if (direction === "forward" && currentWeekIndex === 0) { - // Moving from This Week to Next Week - const eventToMove = thisWeekEventsList[eventIndex]; - if (eventToMove) { - setThisWeekEventsList((prev) => - prev.filter((_, index) => index !== eventIndex), - ); - setNextWeekEventsList((prev) => [...prev, eventToMove]); - } - } else if (direction === "back" && currentWeekIndex === 1) { - // Moving from Next Week back to This Week - const eventToMove = nextWeekEventsList[eventIndex]; - if (eventToMove) { - setNextWeekEventsList((prev) => - prev.filter((_, index) => index !== eventIndex), - ); - setThisWeekEventsList((prev) => [...prev, eventToMove]); - } - } - - // Call the callback to mark that an event has been migrated - onEventMigrated?.(); - - // If migrating forward, call the specific forward migration callback - if (direction === "forward") { - onEventMigratedForward?.(eventText); - } - }, - [ - currentWeekIndex, - onEventMigrated, - onEventMigratedForward, - thisWeekEventsList, - nextWeekEventsList, - ], - ); - - return { - somedayEvents, - weekLabel, - currentWeekIndex, - canNavigateBack, - canNavigateForward, - navigateToNextWeek, - navigateToPreviousWeek, - handleEventClick, - }; -}; diff --git a/packages/web/src/views/Onboarding/steps/events/SomedayEventsIntro/SomedayEventsIntro.tsx b/packages/web/src/views/Onboarding/steps/events/SomedayEventsIntro/SomedayEventsIntro.tsx deleted file mode 100644 index 9476a5908..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedayEventsIntro/SomedayEventsIntro.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import { OnboardingText } from "../../../components"; -import { OnboardingStepProps } from "../../../components/Onboarding"; -import { OnboardingCardLayout } from "../../../components/layouts/OnboardingCardLayout"; - -export const SomedayEventsIntro: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - - There'll be times when you need to do something, but you're - not sure when. - - - Compass has a special place for those in the sidebar. - - - Let's track a few of those events now. - - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/SomedaySandbox.test.tsx b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/SomedaySandbox.test.tsx deleted file mode 100644 index 1c82cf089..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/SomedaySandbox.test.tsx +++ /dev/null @@ -1,528 +0,0 @@ -import { act } from "react"; -import "@testing-library/jest-dom"; -import { screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { render } from "@web/__tests__/__mocks__/mock.render"; -import { withOnboardingProvider } from "../../../components/OnboardingContext"; -import { SomedaySandbox } from "./SomedaySandbox"; - -// Mock the createAndSubmitEvents function -jest.mock("./sandbox.util", () => ({ - createAndSubmitEvents: jest - .fn() - .mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve(undefined), 100)), - ), -})); - -// Wrap the component with OnboardingProvider -const SomedaySandboxWithProvider = withOnboardingProvider(SomedaySandbox); - -// Mock required props for SomedaySandbox -const defaultProps = { - currentStep: 1, - totalSteps: 3, - onNext: jest.fn(), - onPrevious: jest.fn(), - canNavigateNext: true, - nextButtonDisabled: false, - onComplete: jest.fn(), - onSkip: jest.fn(), -}; - -describe("SomedaySandbox", () => { - beforeEach(() => { - // Clear all mocks before each test - jest.clearAllMocks(); - }); - - function setup() { - render(); - // The first input is for "This Week", the second for "This Month" - const inputs = screen.getAllByPlaceholderText("Create new task..."); - const weekTaskInput = inputs[0]; - const monthTaskInput = inputs[1]; - return { weekTaskInput, monthTaskInput }; - } - - it("should add a week task when Enter is pressed", async () => { - const { weekTaskInput } = setup(); - await act(async () => { - await userEvent.type(weekTaskInput, "Test week task{enter}"); - }); - expect(screen.getByText("Test week task")).toBeInTheDocument(); - }); - - it("should add a month task when Enter is pressed", async () => { - const { monthTaskInput } = setup(); - await act(async () => { - await userEvent.type(monthTaskInput, "Test month task{enter}"); - }); - expect(screen.getByText("Test month task")).toBeInTheDocument(); - }); - - it("should focus the month input after adding a week task with Enter", async () => { - const { weekTaskInput, monthTaskInput } = setup(); - weekTaskInput.focus(); - await act(async () => { - await userEvent.type(weekTaskInput, "Focus test{enter}"); - }); - expect(document.activeElement).toBe(monthTaskInput); - }); - - it("should not add empty week or month tasks", async () => { - const { weekTaskInput, monthTaskInput } = setup(); - await userEvent.type(weekTaskInput, "{enter}"); - await userEvent.type(monthTaskInput, "{enter}"); - // There should be no new task items rendered (only the existing ones) - const taskItems = screen.getAllByText( - /File taxes|Get groceries|Start AI course|Book Airbnb|Return library books/, - ); - expect(taskItems).toHaveLength(5); // Only the pre-existing tasks - }); - - it("should add week task on blur if input is not empty", async () => { - const { weekTaskInput } = setup(); - await act(async () => { - await userEvent.type(weekTaskInput, "Blur week task"); - }); - await act(async () => { - await userEvent.tab(); // move focus away to trigger blur - }); - expect(screen.getByText("Blur week task")).toBeInTheDocument(); - }); - - it("should add month task on blur if input is not empty", async () => { - const { monthTaskInput } = setup(); - await act(async () => { - await userEvent.type(monthTaskInput, "Blur month task"); - }); - await act(async () => { - await userEvent.tab(); // move focus away to trigger blur - }); - expect(screen.getByText("Blur month task")).toBeInTheDocument(); - }); - - it("should call createAndSubmitEvents and onNext when handleNext is called and tasks are ready", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Add enough tasks to make both checkboxes ready - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - const monthInput = screen.getAllByPlaceholderText("Create new task...")[1]; - - // Add week task - await act(async () => { - await userEvent.type(weekInput, "Week task{enter}"); - }); - - // Add month task - await act(async () => { - await userEvent.type(monthInput, "Month task{enter}"); - }); - - // Wait for checkboxes to be checked - await waitFor(() => { - expect(screen.getByText("Week task")).toBeInTheDocument(); - expect(screen.getByText("Month task")).toBeInTheDocument(); - }); - - // Click the next button to trigger handleNext (the right arrow button) - const nextButton = screen.getByLabelText("Next"); - - await act(async () => { - await userEvent.click(nextButton); - }); - - // createAndSubmitEvents should be called first - await waitFor(() => { - expect(createAndSubmitEvents).toHaveBeenCalled(); - }); - - // onNext should be called after createAndSubmitEvents completes - await waitFor(() => { - expect(mockOnNext).toHaveBeenCalled(); - }); - }); - - it("should call createAndSubmitEvents and onNext when handleNext is called and tasks are ready (Enter test)", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Add enough tasks to make both checkboxes ready - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - const monthInput = screen.getAllByPlaceholderText("Create new task...")[1]; - - // Add week task - await act(async () => { - await userEvent.type(weekInput, "Week task{enter}"); - }); - - // Add month task - await act(async () => { - await userEvent.type(monthInput, "Month task{enter}"); - }); - - // Wait for checkboxes to be checked - await waitFor(() => { - expect(screen.getByText("Week task")).toBeInTheDocument(); - expect(screen.getByText("Month task")).toBeInTheDocument(); - }); - - // Click the next button to trigger handleNext (the right arrow button) - const nextButton = screen.getByLabelText("Next"); - await act(async () => { - await userEvent.click(nextButton); - }); - - // createAndSubmitEvents should be called first - await waitFor(() => { - expect(createAndSubmitEvents).toHaveBeenCalled(); - }); - - // onNext should be called after createAndSubmitEvents completes - await waitFor(() => { - expect(mockOnNext).toHaveBeenCalled(); - }); - }); - - it("should not trigger onNext when Enter is pressed while focused on input field", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Clear the mock to reset any previous calls - createAndSubmitEvents.mockClear(); - mockOnNext.mockClear(); - - // Focus on the week input and press Enter - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - weekInput.focus(); - await act(async () => { - await userEvent.type(weekInput, "Test task{enter}"); - }); - - // Should not call createAndSubmitEvents or onNext because Enter was pressed while focused on input - expect(createAndSubmitEvents).not.toHaveBeenCalled(); - expect(mockOnNext).not.toHaveBeenCalled(); - - // But the task should still be added - expect(screen.getByText("Test task")).toBeInTheDocument(); - }); - - it("should not trigger onNext when Enter is pressed while focused on month input field", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Clear the mock to reset any previous calls - createAndSubmitEvents.mockClear(); - mockOnNext.mockClear(); - - // Focus on the month input and press Enter - const monthInput = screen.getAllByPlaceholderText("Create new task...")[1]; - monthInput.focus(); - await act(async () => { - await userEvent.type(monthInput, "Test month task{enter}"); - }); - - // Should not call createAndSubmitEvents or onNext because Enter was pressed while focused on input - expect(createAndSubmitEvents).not.toHaveBeenCalled(); - expect(mockOnNext).not.toHaveBeenCalled(); - - // But the task should still be added - expect(screen.getByText("Test month task")).toBeInTheDocument(); - }); - - it("should trigger onNext when handleNext is called after tasks are ready", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Add enough tasks to make both checkboxes ready - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - const monthInput = screen.getAllByPlaceholderText("Create new task...")[1]; - - // Add week task - await act(async () => { - await userEvent.type(weekInput, "Week task{enter}"); - }); - - // Add month task - await act(async () => { - await userEvent.type(monthInput, "Month task{enter}"); - }); - - // Wait for checkboxes to be checked - await waitFor(() => { - expect(screen.getByText("Week task")).toBeInTheDocument(); - expect(screen.getByText("Month task")).toBeInTheDocument(); - }); - - // Clear mocks to test fresh - createAndSubmitEvents.mockClear(); - mockOnNext.mockClear(); - - // Click the next button to trigger handleNext (the right arrow button) - const nextButton = screen.getByLabelText("Next"); - await act(async () => { - await userEvent.click(nextButton); - }); - - // createAndSubmitEvents should be called first - await waitFor(() => { - expect(createAndSubmitEvents).toHaveBeenCalled(); - }); - - // onNext should be called after createAndSubmitEvents completes - await waitFor(() => { - expect(mockOnNext).toHaveBeenCalled(); - }); - }); - - it("should not navigate when next button is clicked with default tasks but checkboxes not ready", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Clear the mock to reset any previous calls - createAndSubmitEvents.mockClear(); - mockOnNext.mockClear(); - - // The component starts with default tasks but checkboxes are not ready - // Since the button is disabled, we can't click it, but we can test that the button is disabled - const nextButton = screen.getByLabelText("Next"); - expect(nextButton).toBeDisabled(); - - // Should not call createAndSubmitEvents or onNext because checkboxes are not ready - expect(createAndSubmitEvents).not.toHaveBeenCalled(); - expect(mockOnNext).not.toHaveBeenCalled(); - }); - - it("should not navigate when next button is clicked with unsaved changes", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Clear the mock to reset any previous calls - createAndSubmitEvents.mockClear(); - mockOnNext.mockClear(); - - // Type in an input but don't submit (unsaved changes) - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - await act(async () => { - await userEvent.type(weekInput, "Unsaved task"); - }); - - // The button should be disabled due to unsaved changes - const nextButton = screen.getByLabelText("Next"); - expect(nextButton).toBeDisabled(); - - // Should not call createAndSubmitEvents or onNext due to unsaved changes - expect(createAndSubmitEvents).not.toHaveBeenCalled(); - expect(mockOnNext).not.toHaveBeenCalled(); - }); - - it("should prevent multiple submissions when next button is clicked multiple times", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Add enough tasks to make both checkboxes ready - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - const monthInput = screen.getAllByPlaceholderText("Create new task...")[1]; - - await act(async () => { - await userEvent.type(weekInput, "Week task{enter}"); - }); - await act(async () => { - await userEvent.type(monthInput, "Month task{enter}"); - }); - - // Wait for checkboxes to be checked - await waitFor(() => { - expect(screen.getByText("Week task")).toBeInTheDocument(); - expect(screen.getByText("Month task")).toBeInTheDocument(); - }); - - // Click next button - it should become disabled after first click - const nextButton = screen.getByLabelText("Next"); - await act(async () => { - await userEvent.click(nextButton); - }); - - // Button should be disabled during submission - wait for state update - await waitFor(() => { - expect(nextButton).toBeDisabled(); - }); - - // onNext should be called after createAndSubmitEvents completes - await waitFor(() => { - expect(mockOnNext).toHaveBeenCalled(); - }); - - // createAndSubmitEvents should be called - await waitFor(() => { - expect(createAndSubmitEvents).toHaveBeenCalled(); - }); - }); - - it("should prevent multiple submissions when next button is clicked multiple times (Enter test)", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Add enough tasks to make both checkboxes ready - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - const monthInput = screen.getAllByPlaceholderText("Create new task...")[1]; - - await act(async () => { - await userEvent.type(weekInput, "Week task{enter}"); - }); - await act(async () => { - await userEvent.type(monthInput, "Month task{enter}"); - }); - - // Wait for checkboxes to be checked - await waitFor(() => { - expect(screen.getByText("Week task")).toBeInTheDocument(); - expect(screen.getByText("Month task")).toBeInTheDocument(); - }); - - // Click next button - it should become disabled after first click - const nextButton = screen.getByLabelText("Next"); - await act(async () => { - await userEvent.click(nextButton); - }); - - // Button should be disabled during submission - wait for state update - await waitFor(() => { - expect(nextButton).toBeDisabled(); - }); - - // onNext should be called after createAndSubmitEvents completes - await waitFor(() => { - expect(mockOnNext).toHaveBeenCalled(); - }); - - // createAndSubmitEvents should be called - await waitFor(() => { - expect(createAndSubmitEvents).toHaveBeenCalled(); - }); - }); - - it("should handle createAndSubmitEvents errors gracefully", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const consoleSpy = jest - .spyOn(console, "error") - .mockImplementation(() => {}); - - // Mock createAndSubmitEvents to reject - createAndSubmitEvents.mockRejectedValueOnce(new Error("Network error")); - - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Add enough tasks to make both checkboxes ready - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - const monthInput = screen.getAllByPlaceholderText("Create new task...")[1]; - - await userEvent.type(weekInput, "Week task{enter}"); - await userEvent.type(monthInput, "Month task{enter}"); - - // Wait for checkboxes to be checked - await waitFor(() => { - expect(screen.getByText("Week task")).toBeInTheDocument(); - expect(screen.getByText("Month task")).toBeInTheDocument(); - }); - - // Click the next button to trigger handleNext (the right arrow button) - const nextButton = screen.getByLabelText("Next"); - await userEvent.click(nextButton); - - // Error should be logged to console - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - "Failed to create someday events:", - expect.any(Error), - ); - }); - - // onNext should NOT be called when there's an error - expect(mockOnNext).not.toHaveBeenCalled(); - - consoleSpy.mockRestore(); - }); - - it("should not navigate when isSubmitting is true", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const mockOnNext = jest.fn(); - const props = { ...defaultProps, onNext: mockOnNext }; - - render(); - - // Add enough tasks to make both checkboxes ready - const weekInput = screen.getAllByPlaceholderText("Create new task...")[0]; - const monthInput = screen.getAllByPlaceholderText("Create new task...")[1]; - - await act(async () => { - await userEvent.type(weekInput, "Week task{enter}"); - }); - await act(async () => { - await userEvent.type(monthInput, "Month task{enter}"); - }); - - // Wait for checkboxes to be checked - await waitFor(() => { - expect(screen.getByText("Week task")).toBeInTheDocument(); - expect(screen.getByText("Month task")).toBeInTheDocument(); - }); - - // Mock createAndSubmitEvents to take a long time - createAndSubmitEvents.mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 1000)), - ); - - // Click the next button to trigger handleNext (the right arrow button) - const nextButton = screen.getByLabelText("Next"); - await act(async () => { - await userEvent.click(nextButton); - }); - - // The button should be disabled while submitting - expect(nextButton).toBeDisabled(); - - // onNext should be called after createAndSubmitEvents completes - await waitFor( - () => { - expect(mockOnNext).toHaveBeenCalled(); - }, - { timeout: 2000 }, - ); - - // onNext should only be called once - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/SomedaySandbox.tsx b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/SomedaySandbox.tsx deleted file mode 100644 index 8402f0b2a..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/SomedaySandbox.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import React from "react"; -import { Divider } from "@web/components/Divider"; -import { OnboardingText } from "../../../components"; -import { OnboardingStepProps } from "../../../components/Onboarding"; -import { OnboardingTwoRowLayout } from "../../../components/layouts/OnboardingTwoRowLayout"; -import { - AnimatedText, - BottomContent, - Checkbox, - CheckboxContainer, - CheckboxLabel, - LeftColumn, - MainContent, - SectionTitle, - Sidebar, - SidebarSection, - TaskInput, - TaskItem, -} from "./styled"; -import { useHeaderAnimation } from "./useHeaderAnimation"; -import { useSomedaySandbox } from "./useSomedaySandbox"; -import { useSomedaySandboxKeyboard } from "./useSomedaySandboxShortcuts"; - -const WEEK_LIMIT = 3; -const MONTH_LIMIT = 4; - -export const SomedaySandbox: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, - onNavigationControlChange, - isNavPrevented = false, -}) => { - // Use the custom hooks - const { - isWeekTaskReady, - isMonthTaskReady, - isSubmitting, - weekTasks, - monthTasks, - newWeekTask, - newMonthTask, - monthInputRef, - handleNext, - handleAddWeekTask, - handleAddMonthTask, - handleNewWeekTaskKeyPress, - handleNewMonthTaskKeyPress, - setNewWeekTask, - setNewMonthTask, - } = useSomedaySandbox({ - onNext, - onNavigationControlChange, - }); - - // Use the keyboard shortcuts hook - useSomedaySandboxKeyboard({ - isWeekTaskReady, - isMonthTaskReady, - isSubmitting, - handleNext, - onPrevious, - }); - const { isHeaderAnimating } = useHeaderAnimation(); - - const content = ( - - - - - This Week - {weekTasks.length < WEEK_LIMIT && ( - setNewWeekTask(e.target.value)} - onKeyDown={handleNewWeekTaskKeyPress} - onBlur={() => { - if (newWeekTask.trim()) { - handleAddWeekTask(); - } - }} - /> - )} - {weekTasks.map((task, index) => ( - - {task.text} - - ))} - - - - - - This Month - {monthTasks.length < MONTH_LIMIT && ( - setNewMonthTask(e.target.value)} - onKeyDown={handleNewMonthTaskKeyPress} - onBlur={() => { - if (newMonthTask.trim()) { - handleAddMonthTask(); - } - }} - /> - )} - {monthTasks.map((task, index) => ( - - {task.text} - - ))} - - - - {!isWeekTaskReady || !isMonthTaskReady ? ( - <> - - Behold, the all-mighty sidebar - - {"{dramatic music}"} - - Don't be shy, jot down a task and type ENTER to save - -
- - - - Create a week task - - - - - - Create a month task - - -
- - ) : ( - <> - - Nice work. Who said being organized had to be complicated? - - - To continue, click the right arrow or press the 'k' - key (There's a shortcut for everything here) - -
- - - - Create a week task - - - - - - Create a month task - - -
- - )} -
-
-
- ); - - return ( - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/sandbox.util.test.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/sandbox.util.test.ts deleted file mode 100644 index c95989d85..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/sandbox.util.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import dayjs from "@core/util/date/dayjs"; -import { createAndSubmitEvents } from "@web/views/Onboarding/steps/events/SomedaySandbox/sandbox.util"; - -// Mock dependencies -jest.mock("@core/constants/core.constants", () => ({ - Origin: { - COMPASS: "compass", - }, - Priorities: { - WORK: "work", - SELF: "self", - RELATIONS: "relations", - UNASSIGNED: "unassigned", - }, -})); - -jest.mock("@core/types/event.types", () => ({ - Categories_Event: { - SOMEDAY_WEEK: "someday_week", - SOMEDAY_MONTH: "someday_month", - }, -})); - -jest.mock("@web/auth/auth.util", () => ({ - getUserId: jest.fn().mockResolvedValue("test-user-id"), - getUserEmail: jest.fn().mockResolvedValue("test@example.com"), -})); - -jest.mock("@web/common/styles/theme.util", () => ({ - colorByPriority: { - work: "#ff6b6b", - self: "#4ecdc4", - relationships: "#45b7d1", - }, -})); - -jest.mock("@web/common/utils/datetime/web.date.util", () => ({ - getDatesByCategory: jest.fn().mockReturnValue({ - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", - }), -})); - -jest.mock("@web/ducks/events/event.api", () => ({ - EventApi: { - create: jest.fn().mockResolvedValue(undefined), - }, -})); - -describe("sandbox.util", () => { - beforeEach(() => { - jest.clearAllMocks(); - // Mock dayjs to return a consistent date - jest.spyOn(dayjs.prototype, "startOf").mockReturnThis(); - jest.spyOn(dayjs.prototype, "endOf").mockReturnThis(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe("createAndSubmitEvents", () => { - it("should create and submit events for week and month tasks", async () => { - const { EventApi } = await import("@web/ducks/events/event.api"); - const { getDatesByCategory } = await import( - "@web/common/utils/datetime/web.date.util" - ); - - const weekTasks = [ - { text: "Week task 1", color: "#ff6b6b" }, - { text: "Week task 2", color: "#4ecdc4" }, - ]; - - const monthTasks = [ - { text: "Month task 1", color: "#45b7d1" }, - { text: "Month task 2", color: "#ff6b6b" }, - ]; - - await createAndSubmitEvents(weekTasks, monthTasks); - - // Should call getDatesByCategory for each task - expect(getDatesByCategory).toHaveBeenCalledTimes(4); - - // Should call EventApi.create with all events - expect(EventApi.create).toHaveBeenCalledTimes(1); - expect(EventApi.create).toHaveBeenCalledWith(expect.any(Array)); - }); - - it("should create events with correct structure for week tasks", async () => { - const { EventApi } = await import("@web/ducks/events/event.api"); - const { getUserId } = await import("@web/auth/auth.util"); - const { getDatesByCategory } = await import( - "@web/common/utils/datetime/web.date.util" - ); - - const weekTasks = [{ text: "Test week task", color: "#ff6b6b" }]; - const monthTasks = []; - - await createAndSubmitEvents(weekTasks, monthTasks); - - const createdEvents = EventApi.create.mock.calls[0][0]; - const weekEvent = createdEvents[0]; - - expect(weekEvent).toMatchObject({ - title: "Test week task", - description: "", - user: "test-user-id", - isAllDay: false, - isSomeday: true, - origin: "compass", - priority: "work", - }); - - expect(weekEvent.startDate).toBeDefined(); - expect(weekEvent.endDate).toBeDefined(); - }); - - it("should create events with correct structure for month tasks", async () => { - const { EventApi } = await import("@web/ducks/events/event.api"); - - const weekTasks = []; - const monthTasks = [{ text: "Test month task", color: "#4ecdc4" }]; - - await createAndSubmitEvents(weekTasks, monthTasks); - - const createdEvents = EventApi.create.mock.calls[0][0]; - const monthEvent = createdEvents[0]; - - expect(monthEvent).toMatchObject({ - title: "Test month task", - description: "", - user: "test-user-id", - isAllDay: false, - isSomeday: true, - origin: "compass", - priority: "self", - }); - }); - - it("should map colors to correct priorities", async () => { - const { EventApi } = await import("@web/ducks/events/event.api"); - - const weekTasks = [ - { text: "Work task", color: "#ff6b6b" }, // work - { text: "Self task", color: "#4ecdc4" }, // self - { text: "Relations task", color: "#45b7d1" }, // relationships - { text: "Unknown task", color: "#unknown" }, // unassigned - ]; - const monthTasks = []; - - await createAndSubmitEvents(weekTasks, monthTasks); - - const createdEvents = EventApi.create.mock.calls[0][0]; - - expect(createdEvents[0].priority).toBe("work"); - expect(createdEvents[1].priority).toBe("self"); - expect(createdEvents[2].priority).toBe("relations"); - expect(createdEvents[3].priority).toBe("unassigned"); - }); - - it("should handle empty task arrays", async () => { - const { EventApi } = await import("@web/ducks/events/event.api"); - - await createAndSubmitEvents([], []); - - expect(EventApi.create).toHaveBeenCalledWith([]); - }); - - it("should handle mixed week and month tasks", async () => { - const { EventApi } = await import("@web/ducks/events/event.api"); - - const weekTasks = [{ text: "Week task", color: "#ff6b6b" }]; - const monthTasks = [{ text: "Month task", color: "#4ecdc4" }]; - - await createAndSubmitEvents(weekTasks, monthTasks); - - const createdEvents = EventApi.create.mock.calls[0][0]; - - expect(createdEvents).toHaveLength(2); - expect(createdEvents[0].title).toBe("Week task"); - expect(createdEvents[1].title).toBe("Month task"); - }); - - it("should call getDatesByCategory with correct parameters", async () => { - const { getDatesByCategory } = await import( - "@web/common/utils/datetime/web.date.util" - ); - - const weekTasks = [{ text: "Week task", color: "#ff6b6b" }]; - const monthTasks = [{ text: "Month task", color: "#4ecdc4" }]; - - await createAndSubmitEvents(weekTasks, monthTasks); - - expect(getDatesByCategory).toHaveBeenCalledWith( - "someday_week", - expect.any(Object), // weekStart - expect.any(Object), // weekEnd - ); - - expect(getDatesByCategory).toHaveBeenCalledWith( - "someday_month", - expect.any(Object), // weekStart - expect.any(Object), // weekEnd - ); - }); - - it("should propagate errors from EventApi.create", async () => { - const { EventApi } = await import("@web/ducks/events/event.api"); - const error = new Error("API Error"); - EventApi.create.mockRejectedValueOnce(error); - - const weekTasks = [{ text: "Week task", color: "#ff6b6b" }]; - const monthTasks = []; - - await expect( - createAndSubmitEvents(weekTasks, monthTasks), - ).rejects.toThrow("API Error"); - }); - - it("should propagate errors from getUserId", async () => { - const { getUserId } = await import("@web/auth/auth.util"); - const error = new Error("Auth Error"); - getUserId.mockRejectedValueOnce(error); - - const weekTasks = [{ text: "Week task", color: "#ff6b6b" }]; - const monthTasks = []; - - await expect( - createAndSubmitEvents(weekTasks, monthTasks), - ).rejects.toThrow("Auth Error"); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/sandbox.util.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/sandbox.util.ts deleted file mode 100644 index 6b89c9ef9..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/sandbox.util.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ObjectId } from "bson"; -import { Origin, Priorities } from "@core/constants/core.constants"; -import { - Categories_Event, - CompassCoreEvent, - WithCompassId, -} from "@core/types/event.types"; -import dayjs from "@core/util/date/dayjs"; -import { getUserId } from "@web/auth/auth.util"; -import { colorByPriority } from "@web/common/styles/theme.util"; -import { Schema_WebEvent } from "@web/common/types/web.event.types"; -import { getDatesByCategory } from "@web/common/utils/datetime/web.date.util"; -import { EventApi } from "@web/ducks/events/event.api"; - -// Helper function to map task color to priority -const getPriorityFromColor = (color: string): Priorities => { - if (color === colorByPriority.work) return Priorities.WORK; - if (color === colorByPriority.self) return Priorities.SELF; - if (color === colorByPriority.relationships) return Priorities.RELATIONS; - return Priorities.UNASSIGNED; -}; - -// Function to create and submit events to the backend -export const createAndSubmitEvents = async ( - weekTasks: { text: string; color: string }[], - monthTasks: { text: string; color: string }[], -): Promise => { - // Create events from week tasks - const weekEvents: CompassCoreEvent[] = []; - for (let i = 0; i < weekTasks.length; i++) { - const event = await createEventFromTask( - weekTasks[i], - Categories_Event.SOMEDAY_WEEK, - i, - ); - weekEvents.push(event); - } - - // Create events from month tasks - const monthEvents: CompassCoreEvent[] = []; - for (let i = 0; i < monthTasks.length; i++) { - const event = await createEventFromTask( - monthTasks[i], - Categories_Event.SOMEDAY_MONTH, - i, - ); - monthEvents.push(event); - } - - // Submit all events to the backend - const allEvents = [...weekEvents, ...monthEvents]; - - // Create events one by one using the API - await EventApi.create(allEvents); -}; - -const createEventFromTask = async function ( - task: { text: string; color: string }, - category: Categories_Event.SOMEDAY_WEEK | Categories_Event.SOMEDAY_MONTH, - order: number, -): Promise>> { - const userId = await getUserId(); - const now = dayjs(); - - const weekStart = now.startOf("week"); - const weekEnd = now.endOf("week"); - const { startDate, endDate } = getDatesByCategory( - category, - weekStart, - weekEnd, - ); - - return { - _id: new ObjectId().toString(), - title: task.text, - description: "", - startDate, - endDate, - user: userId, - isAllDay: false, - isSomeday: true, - origin: Origin.COMPASS, - order, - priority: getPriorityFromColor(task.color), - }; -}; diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/styled.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/styled.ts deleted file mode 100644 index 05d947378..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/styled.ts +++ /dev/null @@ -1,153 +0,0 @@ -import styled, { css, keyframes } from "styled-components"; -import { OnboardingText } from "@web/views/Onboarding/components"; - -// Keyframes for text wave animation -export const textWave = keyframes` - 0% { - background: linear-gradient(90deg, #ffffff 0%, #ffffff 100%); - background-size: 300% 100%; - background-position: 0% 0%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - } - 30% { - background: linear-gradient(90deg, #ffffff 0%, #60a5fa 30%, #ffffff 100%); - background-size: 300% 100%; - background-position: 30% 0%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - } - 70% { - background: linear-gradient(90deg, #ffffff 0%, #60a5fa 70%, #ffffff 100%); - background-size: 300% 100%; - background-position: 70% 0%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - } - 100% { - background: linear-gradient(90deg, #ffffff 0%, #ffffff 100%); - background-size: 300% 100%; - background-position: 100% 0%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - } -`; - -// Styled Components -export const LeftColumn = styled.div` - flex: 1; - display: flex; - flex-direction: column; - gap: 16px; -`; - -export const CheckboxContainer = styled.div` - display: flex; - align-items: center; - gap: 12px; -`; - -export const Checkbox = styled.input` - width: 16px; - height: 16px; - cursor: default; - accent-color: ${({ theme }) => theme.color.text.accent}; - pointer-events: none; - - &:not(:checked) { - accent-color: ${({ theme }) => theme.color.fg.primary}; - } -`; - -export const CheckboxLabel = styled.label` - font-family: "VT323", monospace; - font-size: 24px; - color: ${({ theme }) => theme.color.common.white}; - cursor: pointer; -`; - -export const BottomContent = styled.div` - display: flex; - width: 100%; - height: 100%; -`; - -export const MainContent = styled.div` - flex: 1; - background-color: #12151b; - border: 1px solid #333; - display: flex; - margin: 20px; - gap: 20px; - z-index: 5; -`; - -export const Sidebar = styled.div` - width: 300px; - background-color: #23262f; - display: flex; - flex-direction: column; - padding: 20px; - gap: 20px; -`; - -export const SidebarSection = styled.div` - display: flex; - flex-direction: column; - gap: 12px; -`; - -export const SectionTitle = styled.h3` - font-family: "Rubik", sans-serif; - font-size: 18px; - color: ${({ theme }) => theme.color.common.white}; - margin: 0 0 8px 0; -`; - -export const TaskInput = styled.input` - font-family: "Rubik", sans-serif; - font-size: 14px; - width: 100%; - background: ${({ theme }) => theme.color.common.white}; - color: ${({ theme }) => theme.color.common.black}; - border: 1px solid #ccc; - border-radius: 4px; - padding: 8px 12px; - - &::placeholder { - color: #666; - } -`; - -export const TaskItem = styled.div<{ color: string }>` - font-family: "Rubik", sans-serif; - font-size: 14px; - background: ${({ color }) => color}; - color: ${({ theme }) => theme.color.common.black}; - border-radius: 4px; - padding: 8px 12px; - width: 100%; -`; - -export const Divider = styled.hr` - border: none; - border-top: 1px solid #333; - margin: 0; -`; - -export const AnimatedText = styled(OnboardingText)<{ isAnimating: boolean }>` - ${({ isAnimating }) => - isAnimating && - css` - background: linear-gradient(90deg, #ffffff 0%, #60a5fa 50%, #ffffff 100%); - background-size: 300% 100%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: ${textWave} 3s ease-in-out; - `} -`; diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useHeaderAnimation.test.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useHeaderAnimation.test.ts deleted file mode 100644 index 00c86f1f7..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useHeaderAnimation.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { act } from "react"; -import { renderHook } from "@testing-library/react"; -import { useHeaderAnimation } from "./useHeaderAnimation"; - -describe("useHeaderAnimation", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - it("should set header animation to false after 2.5 seconds", () => { - const { result } = renderHook(() => useHeaderAnimation()); - - expect(result.current.isHeaderAnimating).toBe(true); - - act(() => { - jest.advanceTimersByTime(2500); - }); - - expect(result.current.isHeaderAnimating).toBe(false); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useHeaderAnimation.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useHeaderAnimation.ts deleted file mode 100644 index 1ffe7775e..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useHeaderAnimation.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect, useState } from "react"; - -export const useHeaderAnimation = () => { - const [isHeaderAnimating, setIsHeaderAnimating] = useState(true); - - useEffect(() => { - setIsHeaderAnimating(true); - const timeout = setTimeout(() => setIsHeaderAnimating(false), 2500); - return () => clearTimeout(timeout); - }, []); - - return { isHeaderAnimating }; -}; diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandbox.test.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandbox.test.ts deleted file mode 100644 index d71edd40f..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandbox.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { act } from "react"; -import { renderHook } from "@testing-library/react"; -import { useSomedaySandbox } from "./useSomedaySandbox"; - -// Mock the createAndSubmitEvents function -jest.mock("./sandbox.util", () => ({ - createAndSubmitEvents: jest.fn().mockResolvedValue(undefined), -})); - -// Mock the colorByPriority -jest.mock("@web/common/styles/theme.util", () => ({ - colorByPriority: { - work: "#ff6b6b", - self: "#4ecdc4", - relationships: "#45b7d1", - }, -})); - -describe("useSomedaySandbox", () => { - const mockOnNext = jest.fn(); - const mockOnNavigationControlChange = jest.fn(); - - const defaultProps = { - onNext: mockOnNext, - onNavigationControlChange: mockOnNavigationControlChange, - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - it("should initialize with default state", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - expect(result.current.isWeekTaskReady).toBe(false); - expect(result.current.isMonthTaskReady).toBe(false); - expect(result.current.isSubmitting).toBe(false); - expect(result.current.weekTasks).toHaveLength(2); - expect(result.current.monthTasks).toHaveLength(3); - expect(result.current.newWeekTask).toBe(""); - expect(result.current.newMonthTask).toBe(""); - expect(result.current.monthInputRef.current).toBeNull(); - }); - - it("should call onNavigationControlChange with correct values", () => { - renderHook(() => useSomedaySandbox(defaultProps)); - - // Initially should prevent navigation (checkboxes not ready) - expect(mockOnNavigationControlChange).toHaveBeenCalledWith(true); - }); - - it("should add week task when handleAddWeekTask is called", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - act(() => { - result.current.setNewWeekTask("Test week task"); - }); - - act(() => { - result.current.handleAddWeekTask(); - }); - - expect(result.current.weekTasks).toHaveLength(3); - expect(result.current.weekTasks[2].text).toBe("Test week task"); - expect(result.current.newWeekTask).toBe(""); - }); - - it("should add month task when handleAddMonthTask is called", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - act(() => { - result.current.setNewMonthTask("Test month task"); - }); - - act(() => { - result.current.handleAddMonthTask(); - }); - - expect(result.current.monthTasks).toHaveLength(4); - expect(result.current.monthTasks[3].text).toBe("Test month task"); - expect(result.current.newMonthTask).toBe(""); - }); - - it("should not add empty week task", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - const initialLength = result.current.weekTasks.length; - - act(() => { - result.current.setNewWeekTask(" "); // Only whitespace - }); - - act(() => { - result.current.handleAddWeekTask(); - }); - - expect(result.current.weekTasks).toHaveLength(initialLength); - expect(result.current.newWeekTask).toBe(" "); - }); - - it("should not add empty month task", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - const initialLength = result.current.monthTasks.length; - - act(() => { - result.current.setNewMonthTask(" "); // Only whitespace - }); - - act(() => { - result.current.handleAddMonthTask(); - }); - - expect(result.current.monthTasks).toHaveLength(initialLength); - expect(result.current.newMonthTask).toBe(" "); - }); - - it("should set isWeekTaskReady to true when week tasks reach limit", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - expect(result.current.isWeekTaskReady).toBe(false); - - // Add one more week task to reach the limit (3) - act(() => { - result.current.setNewWeekTask("Third task"); - }); - - act(() => { - result.current.handleAddWeekTask(); - }); - - expect(result.current.isWeekTaskReady).toBe(true); - }); - - it("should set isMonthTaskReady to true when month tasks reach limit", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - expect(result.current.isMonthTaskReady).toBe(false); - - // Add one more month task to reach the limit (4) - act(() => { - result.current.setNewMonthTask("Fourth task"); - }); - - act(() => { - result.current.handleAddMonthTask(); - }); - - expect(result.current.isMonthTaskReady).toBe(true); - }); - - it("should handle keyboard events for week task input", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - act(() => { - result.current.setNewWeekTask("Keyboard task"); - }); - - const mockEvent = { - key: "Enter", - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - } as any; - - act(() => { - result.current.handleNewWeekTaskKeyPress(mockEvent); - }); - - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(mockEvent.stopPropagation).toHaveBeenCalled(); - expect(result.current.weekTasks).toHaveLength(3); - expect(result.current.weekTasks[2].text).toBe("Keyboard task"); - }); - - it("should handle keyboard events for month task input", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - act(() => { - result.current.setNewMonthTask("Keyboard month task"); - }); - - const mockEvent = { - key: "Enter", - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - } as any; - - act(() => { - result.current.handleNewMonthTaskKeyPress(mockEvent); - }); - - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(mockEvent.stopPropagation).toHaveBeenCalled(); - expect(result.current.monthTasks).toHaveLength(4); - expect(result.current.monthTasks[3].text).toBe("Keyboard month task"); - }); - - it("should not handle non-Enter key events", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - act(() => { - result.current.setNewWeekTask("Test task"); - }); - - const mockEvent = { - key: "Space", - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - } as any; - - act(() => { - result.current.handleNewWeekTaskKeyPress(mockEvent); - }); - - expect(mockEvent.preventDefault).not.toHaveBeenCalled(); - expect(mockEvent.stopPropagation).not.toHaveBeenCalled(); - expect(result.current.weekTasks).toHaveLength(2); // No new task added - }); - - it("should call createAndSubmitEvents and onNext when handleNext is called", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - // Make tasks ready - act(() => { - result.current.setNewWeekTask("Week task"); - result.current.handleAddWeekTask(); - }); - - act(() => { - result.current.setNewMonthTask("Month task"); - result.current.handleAddMonthTask(); - }); - - await act(async () => { - await result.current.handleNext(); - }); - - expect(createAndSubmitEvents).toHaveBeenCalledWith( - result.current.weekTasks, - result.current.monthTasks, - ); - expect(mockOnNext).toHaveBeenCalled(); - }); - - it("should set isSubmitting to true during handleNext", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - // Mock a slow async operation - createAndSubmitEvents.mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 50)), - ); - - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - // Make tasks ready - act(() => { - result.current.setNewWeekTask("Week task"); - result.current.handleAddWeekTask(); - }); - - act(() => { - result.current.setNewMonthTask("Month task"); - result.current.handleAddMonthTask(); - }); - - // Start handleNext - act(() => { - result.current.handleNext(); - }); - - expect(result.current.isSubmitting).toBe(true); - - // Wait for completion - await act(async () => { - jest.advanceTimersByTime(50); - }); - - expect(result.current.isSubmitting).toBe(false); - }); - - it("should prevent multiple submissions", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - // Mock a slow async operation - createAndSubmitEvents.mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 50)), - ); - - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - // Make tasks ready - act(() => { - result.current.setNewWeekTask("Week task"); - result.current.handleAddWeekTask(); - }); - - act(() => { - result.current.setNewMonthTask("Month task"); - result.current.handleAddMonthTask(); - }); - - // Call handleNext multiple times - act(() => { - result.current.handleNext(); - }); - - act(() => { - result.current.handleNext(); - }); - - act(() => { - result.current.handleNext(); - }); - - // Wait for async operations to complete - await act(async () => { - jest.advanceTimersByTime(50); - }); - - // Should only be called once - expect(createAndSubmitEvents).toHaveBeenCalledTimes(1); - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - - it("should handle createAndSubmitEvents errors gracefully", async () => { - const { createAndSubmitEvents } = require("./sandbox.util"); - const consoleSpy = jest - .spyOn(console, "error") - .mockImplementation(() => {}); - - createAndSubmitEvents.mockRejectedValueOnce(new Error("Network error")); - - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - // Make tasks ready - act(() => { - result.current.setNewWeekTask("Week task"); - result.current.handleAddWeekTask(); - }); - - act(() => { - result.current.setNewMonthTask("Month task"); - result.current.handleAddMonthTask(); - }); - - await act(async () => { - await result.current.handleNext(); - }); - - expect(consoleSpy).toHaveBeenCalledWith( - "Failed to create someday events:", - expect.any(Error), - ); - expect(mockOnNext).not.toHaveBeenCalled(); - expect(result.current.isSubmitting).toBe(false); - - consoleSpy.mockRestore(); - }); - - it("should call onNavigationControlChange when state changes", async () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - // Clear initial call - mockOnNavigationControlChange.mockClear(); - - // Test with unsaved changes - act(() => { - result.current.setNewWeekTask("Unsaved task"); - }); - - expect(mockOnNavigationControlChange).toHaveBeenCalledWith(true); - - // Test with no unsaved changes but checkboxes not ready - act(() => { - result.current.setNewWeekTask(""); - }); - - expect(mockOnNavigationControlChange).toHaveBeenCalledWith(true); - - // Test with checkboxes ready - add tasks to reach limits - act(() => { - result.current.setNewWeekTask("Week task"); - }); - - act(() => { - result.current.handleAddWeekTask(); - }); - - act(() => { - result.current.setNewMonthTask("Month task"); - }); - - act(() => { - result.current.handleAddMonthTask(); - }); - - // Check that both checkboxes are ready - expect(result.current.isWeekTaskReady).toBe(true); - expect(result.current.isMonthTaskReady).toBe(true); - - // Clear the input to remove unsaved changes - act(() => { - result.current.setNewWeekTask(""); - }); - - act(() => { - result.current.setNewMonthTask(""); - }); - - // The effect should run and call with false since both checkboxes are ready and no unsaved changes - expect(mockOnNavigationControlChange).toHaveBeenLastCalledWith(false); - }); - - it("should assign random colors to new tasks", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - // Add one more week task (we already have 2, so this will be the 3rd) - act(() => { - result.current.setNewWeekTask("Task 1"); - }); - - act(() => { - result.current.handleAddWeekTask(); - }); - - // Check that we have the expected number of tasks (2 default + 1 new = 3) - expect(result.current.weekTasks).toHaveLength(3); - - const task1Color = result.current.weekTasks[2].color; - - // Colors should be from the predefined set - const validColors = ["#ff6b6b", "#4ecdc4", "#45b7d1"]; - expect(validColors).toContain(task1Color); - }); - - it("should trim whitespace from task text", () => { - const { result } = renderHook(() => useSomedaySandbox(defaultProps)); - - act(() => { - result.current.setNewWeekTask(" Trimmed task "); - }); - - act(() => { - result.current.handleAddWeekTask(); - }); - - expect(result.current.weekTasks[2].text).toBe("Trimmed task"); - }); - - it("should not call onNavigationControlChange when it's not provided", () => { - const { result } = renderHook(() => - useSomedaySandbox({ onNext: mockOnNext }), - ); - - act(() => { - result.current.setNewWeekTask("Test task"); - }); - - // Should not throw error - expect(() => { - act(() => { - result.current.handleAddWeekTask(); - }); - }).not.toThrow(); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandbox.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandbox.ts deleted file mode 100644 index 80b93f225..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandbox.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { colorByPriority } from "@web/common/styles/theme.util"; -import { createAndSubmitEvents } from "./sandbox.util"; - -// Types -export interface Task { - text: string; - color: string; -} - -export interface UseSomedaySandboxProps { - onNext: () => void; - onNavigationControlChange?: (shouldPrevent: boolean) => void; -} - -export interface UseSomedaySandboxReturn { - // State - isWeekTaskReady: boolean; - isMonthTaskReady: boolean; - isSubmitting: boolean; - weekTasks: Task[]; - monthTasks: Task[]; - newWeekTask: string; - newMonthTask: string; - - // Refs - monthInputRef: React.RefObject; - - // Handlers - handleNext: () => Promise; - handleAddWeekTask: () => void; - handleAddMonthTask: () => void; - handleNewWeekTaskKeyPress: (e: React.KeyboardEvent) => void; - handleNewMonthTaskKeyPress: (e: React.KeyboardEvent) => void; - - // Setters - setNewWeekTask: (value: string) => void; - setNewMonthTask: (value: string) => void; -} - -// Constants -const WEEK_LIMIT = 3; -const MONTH_LIMIT = 4; - -const colors = [ - colorByPriority.work, - colorByPriority.self, - colorByPriority.relationships, -]; - -// Custom hook for managing task state and operations -export const useSomedaySandbox = ({ - onNext, - onNavigationControlChange, -}: UseSomedaySandboxProps): UseSomedaySandboxReturn => { - // State - const [isWeekTaskReady, setIsWeekTaskReady] = useState(false); - const [isMonthTaskReady, setIsMonthTaskReady] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const isSubmittingRef = useRef(false); - - const [weekTasks, setWeekTasks] = useState([ - { text: "💸 File taxes", color: colorByPriority.work }, - { text: "🥗 Get groceries", color: colorByPriority.self }, - ]); - const [monthTasks, setMonthTasks] = useState([ - { text: "🤖 Start AI course", color: colorByPriority.work }, - { text: "🏠 Book Airbnb", color: colorByPriority.relationships }, - { text: "📚 Return library books", color: colorByPriority.self }, - ]); - const [newWeekTask, setNewWeekTask] = useState(""); - const [newMonthTask, setNewMonthTask] = useState(""); - - // Refs - const monthInputRef = useRef(null); - - // Utility functions - const getRandomColor = useCallback(() => { - const randomIndex = Math.floor(Math.random() * colors.length); - return colors[randomIndex]; - }, []); - - // Navigation prevention effect - useEffect(() => { - const hasUnsavedChanges = - newWeekTask.trim() !== "" || newMonthTask.trim() !== ""; - - const checkboxesNotChecked = !isWeekTaskReady || !isMonthTaskReady; - - const shouldPrevent = - hasUnsavedChanges || checkboxesNotChecked || isSubmitting; - - if (onNavigationControlChange) { - onNavigationControlChange(shouldPrevent); - } - }, [ - newWeekTask, - newMonthTask, - isWeekTaskReady, - isMonthTaskReady, - isSubmitting, - onNavigationControlChange, - ]); - - // Handle next step with event creation - const handleNext = useCallback(async () => { - // Prevent multiple submissions using ref for immediate check - if (isSubmittingRef.current) { - return; - } - - isSubmittingRef.current = true; - setIsSubmitting(true); - - try { - // Wait for the request to complete before navigating - await createAndSubmitEvents(weekTasks, monthTasks); - - // Navigate only after successful completion - onNext(); - - // Reset submitting state after successful navigation - isSubmittingRef.current = false; - setIsSubmitting(false); - } catch (error) { - console.error("Failed to create someday events:", error); - // Reset submitting state on error but don't navigate - isSubmittingRef.current = false; - setIsSubmitting(false); - } - }, [weekTasks, monthTasks, onNext]); - - // Task management handlers - const handleAddWeekTask = useCallback(() => { - if (newWeekTask.trim()) { - const newTasks = [ - ...weekTasks, - { text: newWeekTask.trim(), color: getRandomColor() }, - ]; - setWeekTasks(newTasks); - setNewWeekTask(""); - - if (newTasks.length >= WEEK_LIMIT && !isWeekTaskReady) { - setIsWeekTaskReady(true); - } - } - }, [newWeekTask, weekTasks, isWeekTaskReady, getRandomColor]); - - const handleAddMonthTask = useCallback(() => { - if (newMonthTask.trim()) { - const newTasks = [ - ...monthTasks, - { text: newMonthTask.trim(), color: getRandomColor() }, - ]; - setMonthTasks(newTasks); - setNewMonthTask(""); - - if (newTasks.length >= MONTH_LIMIT && !isMonthTaskReady) { - setIsMonthTaskReady(true); - } - } - }, [newMonthTask, monthTasks, isMonthTaskReady, getRandomColor]); - - // Keyboard event handlers - const handleNewWeekTaskKeyPress = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - e.stopPropagation(); - handleAddWeekTask(); - // Focus the month input after adding a week task - if (monthInputRef.current) { - monthInputRef.current.focus(); - } - } - }, - [handleAddWeekTask], - ); - - const handleNewMonthTaskKeyPress = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - e.stopPropagation(); - handleAddMonthTask(); - // Focus will naturally move to the next input in tab order - } - }, - [handleAddMonthTask], - ); - - return { - // State - isWeekTaskReady, - isMonthTaskReady, - isSubmitting, - weekTasks, - monthTasks, - newWeekTask, - newMonthTask, - - // Refs - monthInputRef, - - // Handlers - handleNext, - handleAddWeekTask, - handleAddMonthTask, - handleNewWeekTaskKeyPress, - handleNewMonthTaskKeyPress, - - // Setters - setNewWeekTask, - setNewMonthTask, - }; -}; diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandboxShortcuts.test.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandboxShortcuts.test.ts deleted file mode 100644 index 944e3b958..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandboxShortcuts.test.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { useSomedaySandboxKeyboard } from "./useSomedaySandboxShortcuts"; - -// Mock the document methods -const mockAddEventListener = jest.fn(); -const mockRemoveEventListener = jest.fn(); - -// Mock document -Object.defineProperty(document, "addEventListener", { - value: mockAddEventListener, - writable: true, -}); - -Object.defineProperty(document, "removeEventListener", { - value: mockRemoveEventListener, - writable: true, -}); - -// Mock document.activeElement -Object.defineProperty(document, "activeElement", { - value: null, - writable: true, -}); - -describe("useSomedaySandboxKeyboard", () => { - beforeEach(() => { - jest.clearAllMocks(); - // Reset activeElement mock - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - }); - - afterEach(() => { - // Clean up any event listeners - jest.clearAllMocks(); - }); - - const defaultProps = { - isWeekTaskReady: true, - isMonthTaskReady: true, - isSubmitting: false, - handleNext: jest.fn(), - onPrevious: jest.fn(), - }; - - it("should add keydown event listener on mount", () => { - renderHook(() => useSomedaySandboxKeyboard(defaultProps)); - - expect(mockAddEventListener).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - false, - ); - }); - - it("should remove keydown event listener on unmount", () => { - const { unmount } = renderHook(() => - useSomedaySandboxKeyboard(defaultProps), - ); - - unmount(); - - expect(mockRemoveEventListener).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - false, - ); - }); - - it("should call handleNext when k is pressed and conditions are met", () => { - const mockHandleNext = jest.fn(); - const props = { ...defaultProps, handleNext: mockHandleNext }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - // Get the event handler that was registered - const eventHandler = mockAddEventListener.mock.calls[0][1]; - - // Simulate k key press - const event = new KeyboardEvent("keydown", { key: "k" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it("should call handleNext when Enter is pressed and conditions are met", () => { - const mockHandleNext = jest.fn(); - const props = { ...defaultProps, handleNext: mockHandleNext }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - // Get the event handler that was registered - const eventHandler = mockAddEventListener.mock.calls[0][1]; - - // Simulate Enter key press - const event = new KeyboardEvent("keydown", { key: "Enter" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it("should not call handleNext when k is pressed but isWeekTaskReady is false", () => { - const mockHandleNext = jest.fn(); - const props = { - ...defaultProps, - isWeekTaskReady: false, - handleNext: mockHandleNext, - }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "k" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).not.toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it("should not call handleNext when k is pressed but isMonthTaskReady is false", () => { - const mockHandleNext = jest.fn(); - const props = { - ...defaultProps, - isMonthTaskReady: false, - handleNext: mockHandleNext, - }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "k" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).not.toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it("should not call handleNext when k is pressed but isSubmitting is true", () => { - const mockHandleNext = jest.fn(); - const props = { - ...defaultProps, - isSubmitting: true, - handleNext: mockHandleNext, - }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "k" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).not.toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it("should not call handleNext when focused on an input element", () => { - const mockHandleNext = jest.fn(); - const props = { ...defaultProps, handleNext: mockHandleNext }; - - // Mock activeElement as an input - const mockInput = document.createElement("input"); - Object.defineProperty(document, "activeElement", { - value: mockInput, - writable: true, - }); - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "k" }); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).not.toHaveBeenCalled(); - }); - - it("should not call handleNext when focused on a textarea element", () => { - const mockHandleNext = jest.fn(); - const props = { ...defaultProps, handleNext: mockHandleNext }; - - // Mock activeElement as a textarea - const mockTextarea = document.createElement("textarea"); - Object.defineProperty(document, "activeElement", { - value: mockTextarea, - writable: true, - }); - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "Enter" }); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).not.toHaveBeenCalled(); - }); - - it("should not call handleNext for other keys", () => { - const mockHandleNext = jest.fn(); - const props = { ...defaultProps, handleNext: mockHandleNext }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - - // Test various other keys (excluding j and k which now have functionality) - const otherKeys = [ - "Space", - "Tab", - "Escape", - "a", - "1", - "ArrowRight", - "ArrowLeft", - ]; - otherKeys.forEach((key) => { - const event = new KeyboardEvent("keydown", { key }); - act(() => { - eventHandler(event); - }); - }); - - expect(mockHandleNext).not.toHaveBeenCalled(); - }); - - it("should update event listener when dependencies change", () => { - const { rerender } = renderHook( - ({ - isWeekTaskReady, - isMonthTaskReady, - isSubmitting, - handleNext, - onPrevious, - }) => - useSomedaySandboxKeyboard({ - isWeekTaskReady, - isMonthTaskReady, - isSubmitting, - handleNext, - onPrevious, - }), - { - initialProps: defaultProps, - }, - ); - - // Should have added listener on initial render - expect(mockAddEventListener).toHaveBeenCalledTimes(1); - - // Rerender with different props - rerender({ - ...defaultProps, - isWeekTaskReady: false, - }); - - // Should have removed old listener and added new one - expect(mockRemoveEventListener).toHaveBeenCalledTimes(1); - expect(mockAddEventListener).toHaveBeenCalledTimes(2); - }); - - it("should handle case when activeElement is null", () => { - const mockHandleNext = jest.fn(); - const props = { ...defaultProps, handleNext: mockHandleNext }; - - // Ensure activeElement is null - Object.defineProperty(document, "activeElement", { - value: null, - writable: true, - }); - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "k" }); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).toHaveBeenCalled(); - }); - - it("should handle case when activeElement is not an input or textarea", () => { - const mockHandleNext = jest.fn(); - const props = { ...defaultProps, handleNext: mockHandleNext }; - - // Mock activeElement as a div - const mockDiv = document.createElement("div"); - Object.defineProperty(document, "activeElement", { - value: mockDiv, - writable: true, - }); - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "k" }); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).toHaveBeenCalled(); - }); - - // Tests for 'k' key functionality (uppercase) - it("should call handleNext when K (uppercase) is pressed and conditions are met", () => { - const mockHandleNext = jest.fn(); - const props = { ...defaultProps, handleNext: mockHandleNext }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "K" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockHandleNext).toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - // Tests for 'j' key functionality - describe("j key navigation", () => { - it("should call onPrevious when j is pressed", () => { - const mockOnPrevious = jest.fn(); - const props = { ...defaultProps, onPrevious: mockOnPrevious }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "j" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockOnPrevious).toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it("should call onPrevious when J (uppercase) is pressed", () => { - const mockOnPrevious = jest.fn(); - const props = { ...defaultProps, onPrevious: mockOnPrevious }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "J" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockOnPrevious).toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it("should call onPrevious when j is pressed regardless of task readiness", () => { - const mockOnPrevious = jest.fn(); - const props = { - ...defaultProps, - isWeekTaskReady: false, - isMonthTaskReady: false, - isSubmitting: true, - onPrevious: mockOnPrevious, - }; - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "j" }); - event.preventDefault = jest.fn(); - event.stopPropagation = jest.fn(); - - act(() => { - eventHandler(event); - }); - - expect(mockOnPrevious).toHaveBeenCalled(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it("should not call onPrevious when j is pressed while focused on input", () => { - const mockOnPrevious = jest.fn(); - const props = { ...defaultProps, onPrevious: mockOnPrevious }; - - // Mock activeElement as an input - const mockInput = document.createElement("input"); - Object.defineProperty(document, "activeElement", { - value: mockInput, - writable: true, - }); - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "j" }); - - act(() => { - eventHandler(event); - }); - - expect(mockOnPrevious).not.toHaveBeenCalled(); - }); - - it("should not call onPrevious when j is pressed while focused on contentEditable", () => { - const mockOnPrevious = jest.fn(); - const props = { ...defaultProps, onPrevious: mockOnPrevious }; - - // Mock activeElement as a contentEditable div - const mockDiv = document.createElement("div"); - mockDiv.contentEditable = "true"; - Object.defineProperty(document, "activeElement", { - value: mockDiv, - writable: true, - }); - - renderHook(() => useSomedaySandboxKeyboard(props)); - - const eventHandler = mockAddEventListener.mock.calls[0][1]; - const event = new KeyboardEvent("keydown", { key: "j" }); - - act(() => { - eventHandler(event); - }); - - expect(mockOnPrevious).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandboxShortcuts.ts b/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandboxShortcuts.ts deleted file mode 100644 index 20a66d733..000000000 --- a/packages/web/src/views/Onboarding/steps/events/SomedaySandbox/useSomedaySandboxShortcuts.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect } from "react"; - -export const useSomedaySandboxKeyboard = ({ - isWeekTaskReady, - isMonthTaskReady, - isSubmitting, - handleNext, - onPrevious, -}: { - isWeekTaskReady: boolean; - isMonthTaskReady: boolean; - isSubmitting: boolean; - handleNext: () => Promise; - onPrevious: () => void; -}) => { - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isRightArrow = event.key === "ArrowRight"; - const isEnter = event.key === "Enter"; - const isNextKey = event.key === "k" || event.key === "K"; - const isPreviousKey = event.key === "j" || event.key === "J"; - - // Check if input is focused to avoid interfering with typing - const activeElement = document.activeElement as HTMLElement; - const isInputFocused = - activeElement && - (activeElement.tagName === "INPUT" || - activeElement.tagName === "TEXTAREA" || - activeElement.contentEditable === "true"); - - // Handle 'j' key for previous navigation - always allowed - if (isPreviousKey) { - // Allow typing in input fields - if (isInputFocused) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - onPrevious(); - return; - } - - // Handle next navigation (enter, or 'k' key) - const isNextAction = isEnter || isNextKey; - const canNavigateNext = - isWeekTaskReady && isMonthTaskReady && !isSubmitting; - - if (isNextAction) { - // If focused on an input, let the input handle it - if (isInputFocused) { - return; - } - - // Only proceed if all conditions are met - if (canNavigateNext) { - event.preventDefault(); - event.stopPropagation(); - handleNext(); - } else { - // Prevent default behavior even if we can't navigate - event.preventDefault(); - event.stopPropagation(); - } - } - }; - - document.addEventListener("keydown", handleKeyDown, false); - return () => document.removeEventListener("keydown", handleKeyDown, false); - }, [isWeekTaskReady, isMonthTaskReady, isSubmitting, handleNext, onPrevious]); -}; diff --git a/packages/web/src/views/Onboarding/steps/index.ts b/packages/web/src/views/Onboarding/steps/index.ts deleted file mode 100644 index cef916bd0..000000000 --- a/packages/web/src/views/Onboarding/steps/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export * from "./welcome/Welcome"; -export * from "./welcome/WelcomeScreen"; -export * from "./welcome/WelcomeNoteOne"; -export * from "./welcome/WelcomeNoteTwo"; -export * from "./oauth/SignInWithGooglePrelude"; -export * from "./oauth/SignInWithGoogle"; -export * from "./oauth/SignInWithGoogleSuccess"; -export * from "./reminder/SetReminder"; -export * from "./reminder/SetReminderSuccess"; -export * from "./tasks/DayTasksIntro/DayTasksIntro"; -export * from "./tasks/TasksIntro/TasksIntro"; -export * from "./outro/OutroTwo"; -export * from "./outro/OutroQuote"; -export * from "./mobile/MobileWarning"; -export * from "./mobile/MobileSignIn"; diff --git a/packages/web/src/views/Onboarding/steps/mobile/MobileSignIn.test.tsx b/packages/web/src/views/Onboarding/steps/mobile/MobileSignIn.test.tsx deleted file mode 100644 index badca0da0..000000000 --- a/packages/web/src/views/Onboarding/steps/mobile/MobileSignIn.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { createElement } from "react"; -import "@testing-library/jest-dom"; -import { MobileSignIn } from "./MobileSignIn"; - -// Mock dependencies -const mockNavigate = jest.fn(); - -jest.mock("react-router-dom", () => ({ - useNavigate: () => mockNavigate, -})); - -jest.mock("@web/common/apis/auth.api", () => ({ - AuthApi: { - loginOrSignup: jest.fn(), - }, -})); - -jest.mock("@web/common/apis/sync.api", () => ({ - SyncApi: { - importGCal: jest.fn(), - }, -})); - -jest.mock("@web/components/oauth/google/useGoogleLogin", () => ({ - useGoogleLogin: jest.fn(), -})); - -jest.mock("@web/components/oauth/google/GoogleButton", () => ({ - GoogleButton: ({ onClick, disabled, style }: any) => ( - - ), -})); - -jest.mock("@web/components/AbsoluteOverflowLoader", () => ({ - AbsoluteOverflowLoader: () => ( -
Loading...
- ), -})); - -jest.mock("../../components/layouts/OnboardingCardLayout", () => ({ - OnboardingCardLayout: ({ children, currentStep, totalSteps }: any) => ( -
-
- Step {currentStep} of {totalSteps} -
-
{children}
-
- ), -})); - -describe("MobileSignIn - User Experience Validation", () => { - const mockOnNext = jest.fn(); - const mockOnPrevious = jest.fn(); - const mockOnComplete = jest.fn(); - const mockOnSkip = jest.fn(); - - const defaultProps = { - currentStep: 2, - totalSteps: 2, - onNext: mockOnNext, - onPrevious: mockOnPrevious, - onComplete: mockOnComplete, - onSkip: mockOnSkip, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("User Behavior Validation", () => { - it("handles different step configurations correctly", () => { - const configurations = [ - { currentStep: 1, totalSteps: 2 }, - { currentStep: 2, totalSteps: 2 }, - { currentStep: 1, totalSteps: 5 }, - { currentStep: 3, totalSteps: 5 }, - ]; - - configurations.forEach((config) => { - const element = createElement(MobileSignIn, { - ...defaultProps, - ...config, - }); - expect(element.props.currentStep).toBe(config.currentStep); - expect(element.props.totalSteps).toBe(config.totalSteps); - }); - }); - }); - - describe("Integration Validation", () => { - it("maintains proper component structure", () => { - const element = createElement(MobileSignIn, defaultProps); - - // Verify the element has the expected structure - expect(element.type).toBe(MobileSignIn); - expect(element.props).toHaveProperty("currentStep"); - expect(element.props).toHaveProperty("totalSteps"); - expect(element.props).toHaveProperty("onNext"); - expect(element.props).toHaveProperty("onPrevious"); - expect(element.props).toHaveProperty("onComplete"); - expect(element.props).toHaveProperty("onSkip"); - }); - }); - - describe("User Journey Validation", () => { - it("supports the complete mobile onboarding flow", () => { - // Test that the component can be used in a mobile onboarding sequence - const mobileFlowProps = { - currentStep: 2, - totalSteps: 2, - onNext: jest.fn(), - onPrevious: jest.fn(), - onComplete: jest.fn(), - onSkip: jest.fn(), - }; - - const element = createElement(MobileSignIn, mobileFlowProps); - expect(element).toBeDefined(); - expect(element.props.currentStep).toBe(2); - expect(element.props.totalSteps).toBe(2); - }); - - it("handles callback functions for user interactions", () => { - const callbacks = { - onNext: jest.fn(), - onPrevious: jest.fn(), - onComplete: jest.fn(), - onSkip: jest.fn(), - }; - - const element = createElement(MobileSignIn, { - ...defaultProps, - ...callbacks, - }); - - expect(element.props.onNext).toBe(callbacks.onNext); - expect(element.props.onPrevious).toBe(callbacks.onPrevious); - expect(element.props.onComplete).toBe(callbacks.onComplete); - expect(element.props.onSkip).toBe(callbacks.onSkip); - }); - }); - - describe("Mobile-Specific Behavior", () => { - it("supports touch-friendly interactions", () => { - // The component should accept props that enable touch interactions - const touchProps = { - ...defaultProps, - onNext: jest.fn(), // Touch callback - }; - - const element = createElement(MobileSignIn, touchProps); - expect(element.props.onNext).toBe(touchProps.onNext); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/mobile/MobileSignIn.tsx b/packages/web/src/views/Onboarding/steps/mobile/MobileSignIn.tsx deleted file mode 100644 index c3dd4cacd..000000000 --- a/packages/web/src/views/Onboarding/steps/mobile/MobileSignIn.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; -import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; -import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader"; -import { GoogleButton } from "@web/components/oauth/google/GoogleButton"; -import { OnboardingStepProps } from "@web/views/Onboarding/components/Onboarding"; -import { OnboardingCardLayout } from "@web/views/Onboarding/components/layouts/OnboardingCardLayout"; - -export const MobileSignIn: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - const googleAuth = useGoogleAuth({ onNext }); - - return ( - - - {googleAuth.loading ? : null} - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/mobile/MobileWarning.test.tsx b/packages/web/src/views/Onboarding/steps/mobile/MobileWarning.test.tsx deleted file mode 100644 index 7cb743921..000000000 --- a/packages/web/src/views/Onboarding/steps/mobile/MobileWarning.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import "@testing-library/jest-dom"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { render } from "@web/__tests__/__mocks__/mock.render"; -import { MobileWarning } from "./MobileWarning"; - -// Mock the onboarding components -jest.mock("../../components", () => ({ - OnboardingCardLayout: ({ children, currentStep, totalSteps }: any) => ( -
-
- Step {currentStep} of {totalSteps} -
- {children} -
- ), - OnboardingText: ({ children, ...props }: any) => ( -
- {children} -
- ), - OnboardingButton: ({ children, onClick }: any) => ( - - ), -})); - -describe("MobileWarning", () => { - const mockOnNext = jest.fn(); - const mockOnPrevious = jest.fn(); - const mockOnComplete = jest.fn(); - const mockOnSkip = jest.fn(); - - const defaultProps = { - currentStep: 1, - totalSteps: 2, - onNext: mockOnNext, - onPrevious: mockOnPrevious, - onComplete: mockOnComplete, - onSkip: mockOnSkip, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("Component Rendering", () => { - it("renders the component with correct step information", () => { - render(); - - expect(screen.getByTestId("onboarding-card-layout")).toBeInTheDocument(); - expect(screen.getByTestId("step-info")).toHaveTextContent("Step 1 of 2"); - }); - - it("renders the title message", () => { - render(); - - const textElements = screen.getAllByTestId("onboarding-text"); - expect(textElements[0]).toHaveTextContent( - "Compass isn't built for mobile yet", - ); - }); - - it("renders the descriptive message", () => { - render(); - - const textElements = screen.getAllByTestId("onboarding-text"); - expect(textElements[1]).toHaveTextContent( - /We're focusing on perfecting the desktop experience first/, - ); - }); - - it("renders the Continue button", () => { - render(); - - const continueButton = screen.getByTestId("onboarding-button"); - expect(continueButton).toBeInTheDocument(); - expect(continueButton).toHaveTextContent("Continue"); - }); - }); - - describe("Navigation Behavior", () => { - it("calls onNext when Continue button is clicked", async () => { - const user = userEvent.setup(); - render(); - - const continueButton = screen.getByTestId("onboarding-button"); - await user.click(continueButton); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - }); - - describe("Component Props", () => { - it("passes correct props to OnboardingCardLayout", () => { - render(); - - const stepInfo = screen.getByTestId("step-info"); - expect(stepInfo).toHaveTextContent("Step 1 of 2"); - }); - - it("handles different step numbers correctly", () => { - const customProps = { - ...defaultProps, - currentStep: 2, - totalSteps: 3, - }; - - render(); - - const stepInfo = screen.getByTestId("step-info"); - expect(stepInfo).toHaveTextContent("Step 2 of 3"); - }); - }); - - describe("Accessibility", () => { - it("has proper button role and text", () => { - render(); - - const continueButton = screen.getByRole("button"); - expect(continueButton).toBeInTheDocument(); - expect(continueButton).toHaveTextContent("Continue"); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/mobile/MobileWarning.tsx b/packages/web/src/views/Onboarding/steps/mobile/MobileWarning.tsx deleted file mode 100644 index 342e06546..000000000 --- a/packages/web/src/views/Onboarding/steps/mobile/MobileWarning.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { - OnboardingButton, - OnboardingCardLayout, - OnboardingText, -} from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; - -const Title = styled(OnboardingText)` - margin-bottom: ${({ theme }) => theme.spacing.xl}; - text-align: center; -`; - -const Message = styled(OnboardingText)` - margin-bottom: ${({ theme }) => theme.spacing.l}; - text-align: center; - line-height: 1.6; -`; - -const ContinueButton = styled(OnboardingButton)` - margin-top: ${({ theme }) => theme.spacing.l}; - min-height: 48px; - font-size: 18px; -`; - -export const MobileWarning: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - Compass isn't built for mobile yet - - - We're focusing on perfecting the desktop experience first. You can still - import your calendar events so you're ready to go when you access - Compass from your laptop. - - - Continue - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogle.test.tsx b/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogle.test.tsx deleted file mode 100644 index 99dc65eaa..000000000 --- a/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogle.test.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { rest } from "msw"; -import { faker } from "@faker-js/faker"; -import "@testing-library/jest-dom"; -import { screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { render } from "@web/__tests__/__mocks__/mock.render"; -import { server } from "@web/__tests__/__mocks__/server/mock.server"; -import { AuthApi } from "@web/common/apis/auth.api"; -import { ENV_WEB } from "@web/common/constants/env.constants"; -import { useGoogleLogin } from "@web/components/oauth/google/useGoogleLogin"; -import { SignInUpInput } from "@web/components/oauth/ouath.types"; -import { withOnboardingProvider } from "@web/views/Onboarding/components/OnboardingContext"; -import { SignInWithGoogle } from "@web/views/Onboarding/steps/oauth/SignInWithGoogle"; - -// Mock the APIs -jest.mock("@web/common/apis/auth.api", () => ({ - AuthApi: { - loginOrSignup: jest.fn(), - }, -})); - -jest.mock("@web/common/apis/sync.api", () => ({ - SyncApi: { - importGCal: jest.fn(), - }, -})); - -// Mock useNavigate -const mockNavigate = jest.fn(); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), - useNavigate: () => mockNavigate, -})); - -// Mock useGoogleLogin -const mockLogin = jest.fn(); - -// jest.mock("@web/common/classes/Session", () => { -// return { -// session: { -// doesSessionExist: jest.fn().mockResolvedValue(true), -// events: { -// subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), -// pipe: jest.fn().mockReturnThis(), -// }, -// }, -// }; -// }); - -const mockAuthApi = AuthApi as jest.Mocked; -const mockUseGoogleLogin = useGoogleLogin as jest.MockedFunction< - typeof useGoogleLogin ->; - -// Wrap the component with OnboardingProvider -const SignInWithGoogleWithProvider = withOnboardingProvider(SignInWithGoogle); - -// Shared MSW handlers for /user/metadata endpoints -const userMetadataHandlers = [ - rest.get(`${ENV_WEB.API_BASEURL}/user/metadata`, (_req, res, ctx) => { - return res(ctx.json({ skipOnboarding: true })); - }), - rest.post(`${ENV_WEB.API_BASEURL}/user/metadata`, (_req, res, ctx) => { - return res(ctx.json({ skipOnboarding: true })); - }), -]; - -describe("SignInWithGoogle", () => { - const mockOnNext = jest.fn(); - const mockOnPrevious = jest.fn(); - const mockOnComplete = jest.fn(); - const mockOnSkip = jest.fn(); - const defaultProps = { - currentStep: 1, - totalSteps: 3, - onNext: mockOnNext, - onPrevious: mockOnPrevious, - onComplete: mockOnComplete, - onSkip: mockOnSkip, - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockUseGoogleLogin.mockImplementation(() => ({ - login: mockLogin, - loading: false, - data: null, - })); - }); - - describe("Component Rendering", () => { - it("renders the Google sign-in button", () => { - render(); - - expect(screen.getByRole("button")).toBeInTheDocument(); - }); - - it("renders with correct step information", () => { - render(); - - // The OnboardingStepBoilerplate should receive the step props - // This is tested indirectly through the component structure - expect(defaultProps.currentStep).toBe(1); - expect(defaultProps.totalSteps).toBe(3); - }); - - it("shows loading state when Google login is in progress", () => { - mockUseGoogleLogin.mockImplementation(() => ({ - login: mockLogin, - loading: true, - data: null, - })); - - render(); - - // The AbsoluteOverflowLoader should be visible when loading - // Since AbsoluteOverflowLoader doesn't have a test id, we check for its presence indirectly - expect(screen.getByRole("button")).toBeInTheDocument(); - }); - }); - - describe("Google OAuth Success Flow", () => { - it("calls onNext after successful authentication", async () => { - userEvent.setup(); - let onSuccessCallback: ((data: SignInUpInput) => void) | undefined; - - // Mock the auth endpoint - server.use(...userMetadataHandlers); - - mockAuthApi.loginOrSignup.mockResolvedValue({ - createdNewRecipeUser: true, - status: "OK", - user: { - id: "user-id", - isPrimaryUser: false, - emails: [faker.internet.email()], - tenantIds: ["public"], - phoneNumbers: [], - thirdParty: [{ id: "google", userId: "google-user-id" }], - webauthn: { credentialIds: [] }, - loginMethods: [], - timeJoined: Date.now(), - toJson: jest.fn(), - }, - }); - - mockUseGoogleLogin.mockImplementation(({ onSuccess }) => { - onSuccessCallback = onSuccess; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - render(); - - // Simulate Google login success by calling the onSuccess callback - if (onSuccessCallback) { - onSuccessCallback({ - clientType: "web", - thirdPartyId: "google", - redirectURIInfo: { - redirectURIOnProviderDashboard: "", - redirectURIQueryParams: { - code: "test-auth-code", - scope: "email profile", - state: undefined, - }, - }, - }); - } - - // Wait for the async operations to complete - await waitFor(() => { - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe("Error Handling", () => { - it("handles Google login errors", async () => { - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - - const error = new Error("Google login failed"); - let onErrorCallback: ((error: any) => void) | undefined; - - // Mock the useGoogleLogin hook to simulate an error - mockUseGoogleLogin.mockImplementation(({ onError }) => { - onErrorCallback = onError; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - render(); - - // Simulate Google login error by calling the onError callback - if (onErrorCallback) { - onErrorCallback(error); - } - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith(error); - }); - - consoleSpy.mockRestore(); - }); - }); - - describe("Integration with MSW", () => { - it("works with mocked API responses", async () => { - let onSuccessCallback: ((data: SignInUpInput) => void) | undefined; - - // Mock the auth endpoint - server.use(...userMetadataHandlers); - - // Use real API calls instead of mocks - jest.unmock("@web/common/apis/auth.api"); - - mockUseGoogleLogin.mockImplementation(({ onSuccess }) => { - onSuccessCallback = onSuccess; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - render(); - - // Simulate Google login success by calling the onSuccess callback - if (onSuccessCallback) { - onSuccessCallback({ - clientType: "web", - thirdPartyId: "google", - redirectURIInfo: { - redirectURIOnProviderDashboard: "", - redirectURIQueryParams: { - code: "test-auth-code", - scope: "email profile", - state: undefined, - }, - }, - }); - } - - await waitFor(() => { - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogle.tsx b/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogle.tsx deleted file mode 100644 index 9e9d8af9b..000000000 --- a/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogle.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useEffect } from "react"; -import { Key } from "ts-key-enum"; -import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; -import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader"; -import { GoogleButton } from "@web/components/oauth/google/GoogleButton"; -import { OnboardingCardLayout } from "@web/views/Onboarding/components"; -import { OnboardingStepProps } from "@web/views/Onboarding/components/Onboarding"; - -export const SignInWithGoogle: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - const googleAuth = useGoogleAuth({ onNext }); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - (event.key === Key.Enter || event.key === Key.ArrowRight) && - !googleAuth.loading - ) { - event.preventDefault(); - event.stopPropagation(); // Prevent the centralized handler from also triggering - googleAuth.login(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [googleAuth.login, googleAuth.loading]); - - return ( - - - {googleAuth.loading ? : null} - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/oauth/SignInWithGooglePrelude.tsx b/packages/web/src/views/Onboarding/steps/oauth/SignInWithGooglePrelude.tsx deleted file mode 100644 index dd3f95c58..000000000 --- a/packages/web/src/views/Onboarding/steps/oauth/SignInWithGooglePrelude.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import compassGoogleOauthPerms from "@web/assets/png/google-oauth-preview.png"; -import { OnboardingCardLayout, OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; - -const StyledCompassGoogleOauthPerms = styled.img` - max-width: 100%; -`; - -export const SignInWithGooglePrelude: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - - Alas, the crew must get permission to take your bags before I can show - you around. - - - - Sign in with your Google account and check all the boxes on the next - screen. - - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogleSuccess.tsx b/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogleSuccess.tsx deleted file mode 100644 index fe00883dc..000000000 --- a/packages/web/src/views/Onboarding/steps/oauth/SignInWithGoogleSuccess.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; -import { OnboardingCardLayout } from "../../components/layouts/OnboardingCardLayout"; - -export const SignInWithGoogleSuccess: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - Thank you, good sir - - - The crew will attend to your things as we continue. - - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/outro/OutroQuote.tsx b/packages/web/src/views/Onboarding/steps/outro/OutroQuote.tsx deleted file mode 100644 index fb66d7368..000000000 --- a/packages/web/src/views/Onboarding/steps/outro/OutroQuote.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; - -const FADE_IN_MS = 4000; -const READ_MS = 3500; -const FADE_OUT_MS = 2000; - -/** - * This step fades the quote in when it comes into view, allows the user a few - * seconds to read it, and then fades the whole card out. The surrounding - * `div` handles the fade-out of the entire component while the text itself - * handles the fade-in. - */ -export const OutroQuote: React.FC = ({ onNext }) => { - const [showText, setShowText] = useState(false); - const [fadeOut, setFadeOut] = useState(false); - - useEffect(() => { - // Trigger fade-in very shortly after mount so the transition runs. - const fadeInTimer = setTimeout(() => setShowText(true), 50); - - // Fade-out after the text has appeared + the allotted read time. - const fadeOutTimer = setTimeout( - () => setFadeOut(true), - 50 + FADE_IN_MS + READ_MS, - ); - - return () => { - clearTimeout(fadeInTimer); - clearTimeout(fadeOutTimer); - }; - }, []); - - // Once we faded out, call onNext - useEffect(() => { - if (fadeOut) { - setTimeout( - () => { - onNext(); - }, - // Wait for the fade out to complete - FADE_OUT_MS + - // A little extra time for UX optimization purposes - 500, - ); - } - }, [fadeOut, onNext]); - - return ( -
- - To reach a port, we must set sail. -
- Sail, not tie at anchor. -
- Sail, not drift. -
-
- ); -}; diff --git a/packages/web/src/views/Onboarding/steps/outro/OutroTwo.test.tsx b/packages/web/src/views/Onboarding/steps/outro/OutroTwo.test.tsx deleted file mode 100644 index 3018ad7b8..000000000 --- a/packages/web/src/views/Onboarding/steps/outro/OutroTwo.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import "@testing-library/jest-dom"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { render } from "@web/__tests__/__mocks__/mock.render"; -import { OutroTwo } from "./OutroTwo"; - -// Mock the onboarding components -jest.mock("../../components", () => ({ - OnboardingCardLayout: ({ children, currentStep, totalSteps }: any) => ( -
-
- Step {currentStep} of {totalSteps} -
- {children} -
- ), - OnboardingText: ({ children }: any) => ( -
{children}
- ), - OnboardingButton: ({ children, onClick }: any) => ( - - ), - DynamicLogo: () =>
Dynamic Logo
, -})); - -describe("OutroTwo", () => { - const mockOnNext = jest.fn(); - const mockOnPrevious = jest.fn(); - const mockOnComplete = jest.fn(); - const mockOnSkip = jest.fn(); - - const defaultProps = { - currentStep: 10, - totalSteps: 12, - onNext: mockOnNext, - onPrevious: mockOnPrevious, - onComplete: mockOnComplete, - onSkip: mockOnSkip, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("Component Rendering", () => { - it("renders the component with correct step information", () => { - render(); - - expect(screen.getByTestId("onboarding-card-layout")).toBeInTheDocument(); - expect(screen.getByTestId("step-info")).toHaveTextContent( - "Step 10 of 12", - ); - }); - - it("renders all expected text content", () => { - render(); - - const textElements = screen.getAllByTestId("onboarding-text"); - expect(textElements).toHaveLength(3); - - expect(textElements[0]).toHaveTextContent( - "I can see that you now understand that Compass helps you focus on what matters to you.", - ); - expect(textElements[1]).toHaveTextContent( - "Your cabin is set up, and the crew is aboard.", - ); - expect(textElements[2]).toHaveTextContent(/It.*s finally time/); - }); - - it("renders the dynamic logo", () => { - render(); - - expect(screen.getByTestId("dynamic-logo")).toBeInTheDocument(); - }); - - it("renders the Enter button", () => { - render(); - - const enterButton = screen.getByTestId("onboarding-button"); - expect(enterButton).toBeInTheDocument(); - expect(enterButton).toHaveTextContent("Enter"); - }); - }); - - describe("Navigation Behavior", () => { - it("calls onNext when Enter button is clicked", async () => { - const user = userEvent.setup(); - render(); - - const enterButton = screen.getByTestId("onboarding-button"); - await user.click(enterButton); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - - it("does not call onPrevious when left arrow key is pressed", async () => { - const user = userEvent.setup(); - render(); - - // Focus on the component to ensure keyboard events are captured - const cardLayout = screen.getByTestId("onboarding-card-layout"); - cardLayout.focus(); - - // Press left arrow key - await user.keyboard("{ArrowLeft}"); - - // onPrevious should not be called - expect(mockOnPrevious).not.toHaveBeenCalled(); - }); - }); - - describe("Component Props", () => { - it("passes correct props to OnboardingCardLayout", () => { - render(); - - const stepInfo = screen.getByTestId("step-info"); - expect(stepInfo).toHaveTextContent("Step 10 of 12"); - }); - - it("handles different step numbers correctly", () => { - const customProps = { - ...defaultProps, - currentStep: 5, - totalSteps: 8, - }; - - render(); - - const stepInfo = screen.getByTestId("step-info"); - expect(stepInfo).toHaveTextContent("Step 5 of 8"); - }); - }); - - describe("Accessibility", () => { - it("has proper button accessibility", () => { - render(); - - const enterButton = screen.getByTestId("onboarding-button"); - expect(enterButton).toBeInTheDocument(); - expect(enterButton).toHaveAttribute("type", "button"); - }); - - it("is keyboard navigable", async () => { - const user = userEvent.setup(); - render(); - - const enterButton = screen.getByTestId("onboarding-button"); - - // Tab to the button - await user.tab(); - expect(enterButton).toHaveFocus(); - - // Press Enter on the focused button - await user.keyboard("{Enter}"); - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - }); - - describe("Edge Cases", () => { - it("handles rapid button clicks correctly", async () => { - const user = userEvent.setup(); - render(); - - const enterButton = screen.getByTestId("onboarding-button"); - - // Click the button multiple times rapidly - await user.click(enterButton); - await user.click(enterButton); - await user.click(enterButton); - - // Each click should call onNext - expect(mockOnNext).toHaveBeenCalledTimes(3); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/outro/OutroTwo.tsx b/packages/web/src/views/Onboarding/steps/outro/OutroTwo.tsx deleted file mode 100644 index e1458dc6f..000000000 --- a/packages/web/src/views/Onboarding/steps/outro/OutroTwo.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { - DynamicLogo, - OnboardingButton, - OnboardingCardLayout, - OnboardingText, -} from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; - -export const OutroTwo: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - - I can see that you now understand that Compass helps you focus on what - matters to you.{" "} - - - - Your cabin is set up, and the crew is aboard.{" "} - - - - - It’s finally time. - onNext()}>Enter - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroOne.test.tsx b/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroOne.test.tsx deleted file mode 100644 index 726db6e99..000000000 --- a/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroOne.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -describe("ReminderIntroOne Post-Authentication Security", () => { - it("should be configured with prevBtnDisabled=true to prevent button clicks", () => { - // Import the component - const { ReminderIntroOne } = require("./ReminderIntroOne"); - - // Check that the component source code contains prevBtnDisabled={true} - const componentStr = ReminderIntroOne.toString(); - expect(componentStr).toContain("prevBtnDisabled"); - - // This test ensures that the component is configured to disable the previous button - // which prevents users from clicking back to the authentication step after successful login - expect(true).toBe(true); - }); - - it("validates the security requirement: no backward navigation after authentication", () => { - // This test documents the security requirement: - // After successful Google authentication, users should NOT be able to: - // 1. Click the previous button to go back - // 2. Press 'j' or 'J' keys to navigate back - // - // This prevents users from getting stuck in a loop where they can - // re-authenticate multiple times or access the auth flow after login - - const securityRequirements = [ - "prevBtnDisabled={true} in ReminderIntroOne component", - "useOnboardingShortcuts hook respects disablePrevious flag", - "Button clicks on disabled buttons are ignored by browser", - "Keyboard shortcuts are prevented by useOnboardingShortcuts", - ]; - - // All security requirements are implemented - expect(securityRequirements).toHaveLength(4); - }); - - it("ensures forward navigation and skip still work after authentication", () => { - // This test documents that only backward navigation is prevented - // Forward navigation (next button, 'k' key, Enter key) should still work - // Skip functionality should still work - - const allowedActions = [ - "Next button click", - "'k' key navigation", - "'K' key navigation", - "Enter key navigation", - "Skip button click", - ]; - - // All forward actions remain available - expect(allowedActions).toHaveLength(5); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroOne.tsx b/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroOne.tsx deleted file mode 100644 index 36acf281a..000000000 --- a/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroOne.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import { OnboardingStepProps } from "../../components/Onboarding"; -import { OnboardingCardLayout } from "../../components/layouts/OnboardingCardLayout"; -import { OnboardingText } from "../../components/styled"; - -export const ReminderIntroOne: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - - Compass is where you'll make the most important decisions of your life: - how to spend your time. - - - - But making wise decisions about your time is hard when the seas are - stormy. - - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroTwo.tsx b/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroTwo.tsx deleted file mode 100644 index dc92ab52a..000000000 --- a/packages/web/src/views/Onboarding/steps/reminder/ReminderIntroTwo.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import { OnboardingCardLayout, OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; - -export const ReminderIntroTwo: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - - Compass solves this by showing a Reminder at the top of your calendar. - - - - A Reminder can be a goal, intention, or quote. - - - - The only thing that matters is it helps you invest your time wisely. - - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/reminder/SetReminder.tsx b/packages/web/src/views/Onboarding/steps/reminder/SetReminder.tsx deleted file mode 100644 index b8206b1d6..000000000 --- a/packages/web/src/views/Onboarding/steps/reminder/SetReminder.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { useEffect, useState } from "react"; -import styled from "styled-components"; -import { ShuffleAngular } from "@phosphor-icons/react"; -import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; -import IconButton from "@web/components/IconButton/IconButton"; -import { - OnboardingCardLayout, - OnboardingText, - OnboardingTextareaWhite, -} from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; -import { OnboardingForm } from "../../components/OnboardingForm"; -import { REMINDERS } from "./reminders"; - -const InputContainer = styled.div` - position: relative; - margin-top: ${({ theme }) => theme.spacing.l}; - margin-bottom: 40px; -`; - -const Input = styled(OnboardingTextareaWhite)``; - -const HelpText = styled(OnboardingText)` - position: absolute; - top: 100%; - left: 0; - right: 0; - margin: 8px 0; - font-size: ${({ theme }) => theme.text.size.l}; - color: rgb(87, 193, 255); - text-align: center; -`; - -const PLACEHOLDER = "Identify the essential. Eliminate the rest"; - -export const SetReminder: React.FC = ({ - currentStep, - totalSteps, - onNext, - onSkip, - onPrevious, -}) => { - const [reminder, setReminder] = useState(""); - const [showHelpText, setShowHelpText] = useState(false); - - // Set initial placeholder value in localStorage on mount - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.REMINDER, PLACEHOLDER); - }, []); - - useEffect(() => { - const timer = setTimeout(() => { - // Only show help text if the input is still empty - if (!reminder.trim()) { - setShowHelpText(true); - } - }, 5000); - - return () => clearTimeout(timer); - }, [reminder]); - - const persistReminder = (value: string) => { - setReminder(value); - const persistedValue = value.trim() !== "" ? value.trim() : PLACEHOLDER; - localStorage.setItem(STORAGE_KEYS.REMINDER, persistedValue); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onNext(); - }; - - const handleShuffle = () => { - const randomReminder = - REMINDERS[Math.floor(Math.random() * REMINDERS.length)]; - persistReminder(randomReminder); - }; - - const ShuffleIcon = (props: React.ComponentProps) => ( - - - - ); - - return ( - - - The sea is calm now. It's a good time to set a Reminder. - - What do you want to remember this week? - - - -
- persistReminder(e.target.value)} - autoFocus - style={{ flex: 1 }} - /> -
- -
-
- {showHelpText && ( - Fear not, you can always change this later. - )} -
-
-
- ); -}; diff --git a/packages/web/src/views/Onboarding/steps/reminder/SetReminderSuccess.tsx b/packages/web/src/views/Onboarding/steps/reminder/SetReminderSuccess.tsx deleted file mode 100644 index eb9a206c7..000000000 --- a/packages/web/src/views/Onboarding/steps/reminder/SetReminderSuccess.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; -import { OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; -import { OnboardingCardLayout } from "../../components/layouts/OnboardingCardLayout"; - -const CalendarContainer = styled.div` - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -`; - -const ReminderText = styled(OnboardingText)` - font-size: ${({ theme }) => theme.text.size["5xl"]}; - color: ${({ theme }) => theme.color.text.light}; - font-family: "Caveat", cursive; - font-style: italic; - text-align: center; - margin-bottom: ${({ theme }) => theme.spacing.l}; - color: #40b7f6; -`; - -export const SetReminderSuccess: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - const reminder = localStorage.getItem(STORAGE_KEYS.REMINDER) as string; - - return ( - - Excellent choice. - - - Compass will display this reminder above your calendar. - - - - As long as you're here, you'll never forget what matters most. - - - - - ); -}; - -const MockCalendar = ({ reminder }: { reminder: string }) => { - return ( - - {reminder} - - - - ); -}; - -const EventsSVG = () => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/reminder/reminders.ts b/packages/web/src/views/Onboarding/steps/reminder/reminders.ts deleted file mode 100644 index c6d1f6b7a..000000000 --- a/packages/web/src/views/Onboarding/steps/reminder/reminders.ts +++ /dev/null @@ -1,58 +0,0 @@ -export const REMINDERS = [ - "Create with the heart; build with the mind.", - "When you have to make a choice and don’t make it, that in itself is a choice", - "Strive not to be a success, but rather to be of value", - "The best way out is always through", - "Plans are nothing; planning is everything", - "Failure is an event, not a person", - "Lock in until the presentation", - "What we fear doing most is usually what we most need to do", - "You don’t need a new plan. You need a commitment", - "If you spend too much time thinking about a thing, you’ll never get it done", - "A small deed done is better than a great intention", - "You can do anything, but not everything", - "If you want an easy job to seem mighty hard, just keep putting it off", - "You may delay, but time will not", - "Edit your life frequently and ruthlessly. It’s your masterpiece after all", - "Identify the essential. Eliminate the rest", - "Intentionality beats complexity.", - "Focus on what you control; ignore the rest.", - "Do fewer things, work at a natural pace, and obsess over quality.", - "Keep only those things that speak to your heart.", - "There are 168 hours a week—plenty to fit what matters.", - "A schedule defends from chaos and whim.", - "Clear is kind. Unclear is unkind.", - "Clarity comes from action, not thought.", - "Nothing will work unless you do.", - "Do, or do not. There is no try.", - "This is your life and it's ending one minute at a time.", - "If you can't do great things, do small things in a great way", - "Do today what others won't and achieve tomorrow what others can't", - "Rule your mind or it will rule you", - "If you don’t risk anything, you risk even more", - "Procrastination is like a credit card: a lot of fun until you get the bill", - "Well done is better than well said.", - "Your time is limited, so don't waste it living someone else's life", - "The key is in not spending time, but in investing it.", - "Time you enjoy wasting, was not wasted", - "All we have to decide is what to do with the time that is given to us", - "The two most powerful warriors are patience and time", - "Lost time is never found again", - "Plans are nothing; planning is everything", - "Don’t wait. The time will never be just right", - "The secret of your future is hidden in your daily routine", - "The best thing about the future is that it comes one day at a time", - "Freedom without discipline is chaos", - "Discipline is choosing between what you want now, and what you want most", - "Don’t let yesterday take up too much of today", - "The successful warrior is the average man, with a laser-like focus", - "A year from now you may wish you had started today", - "Work hard in silence, let your success be your noise.", - "Success is walking from failure to failure with no loss of enthusiasm", - "It does not matter how slowly you go so long as you do not stop.", - "There is no innovation and creativity without failure.", - "Don’t wait for inspiration. It comes while working.", - "Failure is success in progress.", - "The problem with doing nothing is that you never know when you’re finished", - "Action is the foundational key to all success.", -]; diff --git a/packages/web/src/views/Onboarding/steps/tasks/DayTasksIntro/DayTasksIntro.tsx b/packages/web/src/views/Onboarding/steps/tasks/DayTasksIntro/DayTasksIntro.tsx deleted file mode 100644 index 605b5c29f..000000000 --- a/packages/web/src/views/Onboarding/steps/tasks/DayTasksIntro/DayTasksIntro.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import { OnboardingText } from "../../../components"; -import { OnboardingStepProps } from "../../../components/Onboarding"; -import { OnboardingCardLayout } from "../../../components/layouts/OnboardingCardLayout"; - -export const DayTasksIntro: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - - Having a fancy Reminder is great, but what about all the nitty-gritty - tasks that the sea requires? - - - - We cannot let those fall through the cracks. - - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/tasks/TasksIntro/TasksIntro.tsx b/packages/web/src/views/Onboarding/steps/tasks/TasksIntro/TasksIntro.tsx deleted file mode 100644 index b3c6f1170..000000000 --- a/packages/web/src/views/Onboarding/steps/tasks/TasksIntro/TasksIntro.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import { OnboardingText } from "../../../components"; -import { OnboardingStepProps } from "../../../components/Onboarding"; -import { OnboardingCardLayout } from "../../../components/layouts/OnboardingCardLayout"; - -export const TasksIntro: React.FC = ({ - currentStep, - totalSteps, - onNext, - onSkip, - onPrevious, -}) => { - return ( - - - You can capture all kinds of tasks in Compass. - - - - We'll always put them next to your schedule to help you stay on top - of things. - - - Let's see how that looks. - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/StaticAgenda.tsx b/packages/web/src/views/Onboarding/steps/tasks/TasksToday/StaticAgenda.tsx deleted file mode 100644 index 8aeaf3329..000000000 --- a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/StaticAgenda.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; - -export const StaticAgenda: React.FC = () => { - const staticEvents = [ - { time: "9:00 AM", title: "Morning standup", color: "#60a5fa" }, - { time: "2:00 PM", title: "Client presentation", color: "#3459d3" }, - { time: "4:00 PM", title: "Code review", color: "#908bfa" }, - { time: "5:30 PM", title: "Team sync", color: "#60a5fa" }, - ]; - - return ( -
- {staticEvents.map((event) => ( -
-
- {event.time} -
-
- {event.title} -
-
- ))} -
- ); -}; diff --git a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/TasksToday.test.tsx b/packages/web/src/views/Onboarding/steps/tasks/TasksToday/TasksToday.test.tsx deleted file mode 100644 index 643ec3f21..000000000 --- a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/TasksToday.test.tsx +++ /dev/null @@ -1,467 +0,0 @@ -import { act } from "react"; -import "@testing-library/jest-dom"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { render } from "@web/__tests__/__mocks__/mock.render"; -import { Task } from "@web/common/types/task.types"; -import { withOnboardingProvider } from "../../../components/OnboardingContext"; -import { TasksToday } from "./TasksToday"; -import { useTasksToday } from "./useTasksToday"; - -// Wrap the component with OnboardingProvider -const TasksTodayWithProvider = withOnboardingProvider( - TasksToday as React.ComponentType, -); - -// Mock the useTasksToday hook -jest.mock("./useTasksToday", () => ({ - useTasksToday: jest.fn(), - MAX_TASKS: 5, -})); - -// Mock StaticAgenda -jest.mock("./StaticAgenda", () => ({ - StaticAgenda: () =>
Static Agenda
, -})); - -// Mock OnboardingStep to avoid needing OnboardingProvider -jest.mock("../../../components/OnboardingStep", () => ({ - OnboardingStep: () =>
Step 1 of 3
, -})); - -// Mock dayjs to use a fixed date for consistent testing -jest.mock("@core/util/date/dayjs", () => { - const { default: originalDayjs } = jest.requireActual( - "@core/util/date/dayjs", - ); - - const mockDate = "2025-01-01T00:00:00Z"; - - const mockDayjs = (date?: unknown) => { - if (date === undefined) { - return originalDayjs(mockDate); - } - return originalDayjs(date); - }; - Object.assign(mockDayjs, originalDayjs); - return mockDayjs; -}); - -const mockUseTasksToday = useTasksToday as jest.MockedFunction< - typeof useTasksToday ->; - -describe("TasksToday", () => { - const mockOnNext = jest.fn(); - const mockOnPrevious = jest.fn(); - const mockOnSkip = jest.fn(); - const mockOnNavigationControlChange = jest.fn(); - - const defaultProps = { - currentStep: 1, - totalSteps: 3, - onNext: mockOnNext, - onPrevious: mockOnPrevious, - onSkip: mockOnSkip, - onComplete: jest.fn(), - onNavigationControlChange: mockOnNavigationControlChange, - isNavPrevented: false, - }; - - const defaultHookReturn = { - isTaskCreated: false, - tasks: [ - { - id: "task-1", - title: "Review project proposal", - status: "todo" as const, - createdAt: new Date().toISOString(), - }, - { - id: "task-2", - title: "Write weekly report", - status: "todo" as const, - createdAt: new Date().toISOString(), - }, - ], - newTask: "", - handleNext: jest.fn(), - handleAddTask: jest.fn(), - handleTaskKeyPress: jest.fn(), - setNewTask: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockUseTasksToday.mockReturnValue(defaultHookReturn); - }); - - describe("Component Rendering", () => { - it("renders the component with correct step information", () => { - render(); - - expect( - screen.getByText("What do you need to do today?"), - ).toBeInTheDocument(); - expect(screen.getByText("Add a task below")).toBeInTheDocument(); - }); - - it("renders the date header", () => { - render(); - - // Check that date header elements exist (day name and date) - // The exact values depend on timezone handling, so we check for presence - const dateHeader = screen.getByRole("heading", { level: 2 }); - expect(dateHeader).toBeInTheDocument(); - expect(dateHeader.textContent).toBeTruthy(); - - const dateSubheader = document.querySelector("p"); - expect(dateSubheader).toBeInTheDocument(); - expect(dateSubheader?.textContent).toBeTruthy(); - }); - - it("renders existing tasks", () => { - render(); - - expect(screen.getByText("Review project proposal")).toBeInTheDocument(); - expect(screen.getByText("Write weekly report")).toBeInTheDocument(); - }); - - it("renders the task input when tasks are less than 5", () => { - render(); - - const taskInput = screen.getByPlaceholderText("Create new task..."); - expect(taskInput).toBeInTheDocument(); - }); - - it("does not render task input when tasks reach 5", () => { - const tasksWithLimit: Task[] = Array.from({ length: 5 }, (_, i) => ({ - id: `task-${i}`, - title: `Task ${i}`, - status: "todo" as const, - createdAt: new Date().toISOString(), - })); - - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - tasks: tasksWithLimit, - }); - - render(); - - const taskInput = screen.queryByPlaceholderText("Create new task..."); - expect(taskInput).not.toBeInTheDocument(); - }); - - it("renders the static agenda", () => { - render(); - - expect(screen.getByTestId("static-agenda")).toBeInTheDocument(); - expect(screen.getByText("Agenda")).toBeInTheDocument(); - }); - - it("renders success message when task is created", () => { - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - isTaskCreated: true, - }); - - render(); - - expect( - screen.getByText( - /Great! You've created a task. You can add more and continue when you're done./, - ), - ).toBeInTheDocument(); - expect( - screen.queryByText("What do you need to do today?"), - ).not.toBeInTheDocument(); - }); - }); - - describe("User Interactions", () => { - it("calls setNewTask when input value changes", async () => { - const mockSetNewTask = jest.fn(); - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - setNewTask: mockSetNewTask, - }); - - render(); - - const taskInput = screen.getByPlaceholderText("Create new task..."); - await act(async () => { - await userEvent.type(taskInput, "New task"); - }); - - expect(mockSetNewTask).toHaveBeenCalled(); - }); - - it("calls handleTaskKeyPress when Enter is pressed", async () => { - const mockHandleTaskKeyPress = jest.fn(); - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - handleTaskKeyPress: mockHandleTaskKeyPress, - }); - - render(); - - const taskInput = screen.getByPlaceholderText("Create new task..."); - await act(async () => { - await userEvent.type(taskInput, "Test{enter}"); - }); - - expect(mockHandleTaskKeyPress).toHaveBeenCalled(); - }); - - it("calls handleAddTask when input loses focus with text", async () => { - const mockHandleAddTask = jest.fn(); - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - newTask: "Test task", - handleAddTask: mockHandleAddTask, - }); - - render(); - - const taskInput = screen.getByPlaceholderText("Create new task..."); - await act(async () => { - await userEvent.click(taskInput); - await userEvent.tab(); // Move focus away - }); - - expect(mockHandleAddTask).toHaveBeenCalled(); - }); - - it("calls handleNext when next button is clicked", async () => { - const mockHandleNext = jest.fn(); - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - isTaskCreated: true, - handleNext: mockHandleNext, - }); - - render(); - - const nextButton = screen.getByLabelText("Next"); - await act(async () => { - await userEvent.click(nextButton); - }); - - expect(mockHandleNext).toHaveBeenCalled(); - }); - }); - - describe("Autofocus Functionality", () => { - it("focuses input when component mounts with tasks.length < 5", () => { - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - tasks: [], - }); - - render(); - - const taskInput = screen.getByPlaceholderText("Create new task..."); - expect(taskInput).toHaveFocus(); - }); - - it("focuses input when tasks.length is less than 5", () => { - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - tasks: [ - { - id: "task-1", - title: "Task 1", - status: "todo" as const, - createdAt: new Date().toISOString(), - }, - ], - }); - - render(); - - const taskInput = screen.getByPlaceholderText("Create new task..."); - expect(taskInput).toHaveFocus(); - }); - - it("focuses input when tasks.length changes from >= 5 to < 5", () => { - const tasksWithLimit: Task[] = Array.from({ length: 5 }, (_, i) => ({ - id: `task-${i}`, - title: `Task ${i}`, - status: "todo" as const, - createdAt: new Date().toISOString(), - })); - - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - tasks: tasksWithLimit, - }); - - const { rerender } = render(); - - // Input should not be rendered when tasks.length === 5 - expect( - screen.queryByPlaceholderText("Create new task..."), - ).not.toBeInTheDocument(); - - // Now reduce tasks to less than 5 - const reducedTasks: Task[] = [ - { - id: "task-1", - title: "Task 1", - status: "todo" as const, - createdAt: new Date().toISOString(), - }, - ]; - - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - tasks: reducedTasks, - }); - - rerender(); - - const taskInput = screen.getByPlaceholderText("Create new task..."); - expect(taskInput).toBeInTheDocument(); - expect(taskInput).toHaveFocus(); - }); - - it("does not focus input when tasks.length >= 5", () => { - const tasksWithLimit: Task[] = Array.from({ length: 5 }, (_, i) => ({ - id: `task-${i}`, - title: `Task ${i}`, - status: "todo" as const, - createdAt: new Date().toISOString(), - })); - - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - tasks: tasksWithLimit, - }); - - render(); - - // Input should not be rendered when tasks.length >= 5 - expect( - screen.queryByPlaceholderText("Create new task..."), - ).not.toBeInTheDocument(); - }); - }); - - describe("Navigation Control", () => { - it("disables next button when task is not created", () => { - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - isTaskCreated: false, - }); - - render(); - - const nextButton = screen.getByLabelText("Next"); - expect(nextButton).toBeDisabled(); - }); - - it("enables next button when task is created", () => { - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - isTaskCreated: true, - }); - - render(); - - const nextButton = screen.getByLabelText("Next"); - expect(nextButton).not.toBeDisabled(); - }); - - it("calls onNavigationControlChange through hook", () => { - render(); - - expect(mockUseTasksToday).toHaveBeenCalledWith({ - onNext: mockOnNext, - onNavigationControlChange: mockOnNavigationControlChange, - }); - }); - }); - - describe("Props Handling", () => { - it("passes correct props to OnboardingTwoRowLayout", () => { - render(); - - // Verify the layout receives the correct props by checking rendered content - expect(screen.getByLabelText("Next")).toBeInTheDocument(); - expect(screen.getByLabelText("Previous")).toBeInTheDocument(); - }); - - it("handles isNavPrevented prop", () => { - render(); - - // Component should still render - expect( - screen.getByText("What do you need to do today?"), - ).toBeInTheDocument(); - }); - }); - - describe("Task Display", () => { - it("displays all tasks in the list", () => { - const tasks: Task[] = [ - { - id: "task-1", - title: "Task 1", - status: "todo", - createdAt: new Date().toISOString(), - }, - { - id: "task-2", - title: "Task 2", - status: "todo", - createdAt: new Date().toISOString(), - }, - { - id: "task-3", - title: "Task 3", - status: "todo", - createdAt: new Date().toISOString(), - }, - ]; - - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - tasks, - }); - - render(); - - expect(screen.getByText("Task 1")).toBeInTheDocument(); - expect(screen.getByText("Task 2")).toBeInTheDocument(); - expect(screen.getByText("Task 3")).toBeInTheDocument(); - }); - - it("updates task list when tasks change", () => { - const { rerender } = render(); - - expect(screen.getByText("Review project proposal")).toBeInTheDocument(); - - const newTasks: Task[] = [ - { - id: "task-new", - title: "New Task", - status: "todo", - createdAt: new Date().toISOString(), - }, - ]; - - mockUseTasksToday.mockReturnValue({ - ...defaultHookReturn, - tasks: newTasks, - }); - - rerender(); - - expect(screen.getByText("New Task")).toBeInTheDocument(); - expect( - screen.queryByText("Review project proposal"), - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/TasksToday.tsx b/packages/web/src/views/Onboarding/steps/tasks/TasksToday/TasksToday.tsx deleted file mode 100644 index cdbd8a6c2..000000000 --- a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/TasksToday.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import dayjs from "@core/util/date/dayjs"; -import { OnboardingStepProps } from "@web/views/Onboarding/components/Onboarding"; -import { OnboardingTwoRowLayout } from "@web/views/Onboarding/components/layouts/OnboardingTwoRowLayout"; -import { OnboardingText } from "@web/views/Onboarding/components/styled"; -import { StaticAgenda } from "./StaticAgenda"; -import { MAX_TASKS, useTasksToday } from "./useTasksToday"; - -export const TasksToday: React.FC = ({ - currentStep, - totalSteps, - onNext, - onSkip, - onPrevious, - onNavigationControlChange, - isNavPrevented = false, -}) => { - const { - isTaskCreated, - tasks, - newTask, - handleNext, - handleAddTask, - handleTaskKeyPress, - setNewTask, - } = useTasksToday({ - onNext, - onNavigationControlChange, - }); - - const inputRef = useRef(null); - - useEffect(() => { - if (tasks.length < MAX_TASKS && inputRef.current) { - inputRef.current.focus(); - } - }, [tasks.length]); - - const today = dayjs().startOf("day"); - const dateHeader = today.format("dddd"); - const dateSubheader = today.format("MMMM D"); - - const content = ( -
-
- {isTaskCreated ? ( - - Great! You've created a task. You can add more and continue - when you're done. - - ) : ( - <> - What do you need to do today? - Add a task below - Type ENTER to submit - - )} -
-
-
-
-

- {dateHeader} -

-

- {dateSubheader} -

- {tasks.length < MAX_TASKS && ( - setNewTask(e.target.value)} - onKeyDown={handleTaskKeyPress} - onBlur={() => { - if (newTask.trim()) { - handleAddTask(); - } - }} - className="w-full rounded border-2 border-[hsl(202_100_67)] bg-white px-3 py-2 font-[Rubik] text-sm text-black shadow-[0_0_8px_rgba(96,165,250,0.3)] placeholder:text-[#666] focus:border-[hsl(202_100_67)] focus:shadow-[0_0_12px_rgba(96,165,250,0.5)] focus:outline-none" - /> - )} -
- {tasks.map((task) => ( -
- {task.title} -
- ))} -
-
-
-
-

Agenda

- -
-
-
- ); - - return ( - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/useTasksToday.test.ts b/packages/web/src/views/Onboarding/steps/tasks/TasksToday/useTasksToday.test.ts deleted file mode 100644 index 3d2880f56..000000000 --- a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/useTasksToday.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { act } from "react"; -import { renderHook } from "@testing-library/react"; -import dayjs from "@core/util/date/dayjs"; -import { Task } from "@web/common/types/task.types"; -import { - loadTasksFromStorage, - saveTasksToStorage, -} from "@web/common/utils/storage/storage.util"; -import { useTasksToday } from "./useTasksToday"; - -// Mock the storage utilities while keeping real helpers like getDateKey -jest.mock("@web/common/utils/storage/storage.util", () => { - const actual = jest.requireActual("@web/common/utils/storage/storage.util"); - return { - ...actual, - loadTasksFromStorage: jest.fn(), - saveTasksToStorage: jest.fn(), - }; -}); - -describe("useTasksToday", () => { - const mockOnNext = jest.fn(); - const mockOnNavigationControlChange = jest.fn(); - - const defaultProps = { - onNext: mockOnNext, - onNavigationControlChange: mockOnNavigationControlChange, - }; - - beforeEach(() => { - jest.clearAllMocks(); - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it("should initialize with default state when no tasks exist", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - expect(result.current.isTaskCreated).toBe(false); - expect(result.current.tasks).toHaveLength(0); - expect(result.current.newTask).toBe(""); - expect(saveTasksToStorage).not.toHaveBeenCalled(); - }); - - it("should load existing tasks from storage", () => { - const existingTasks: Task[] = [ - { - id: "task-1", - title: "Existing task 1", - status: "todo", - createdAt: new Date().toISOString(), - order: 0, - }, - { - id: "task-2", - title: "Existing task 2", - status: "todo", - createdAt: new Date().toISOString(), - order: 0, - }, - { - id: "task-3", - title: "Existing task 3", - status: "todo", - createdAt: new Date().toISOString(), - order: 0, - }, - ]; - (loadTasksFromStorage as jest.Mock).mockReturnValue(existingTasks); - - const { result } = renderHook(() => useTasksToday(defaultProps)); - - expect(result.current.tasks).toEqual(existingTasks); - expect(result.current.isTaskCreated).toBe(true); // More than 2 tasks - }); - - it("should call onNavigationControlChange with true initially", () => { - renderHook(() => useTasksToday(defaultProps)); - - // Initially should prevent navigation (task not created) - expect(mockOnNavigationControlChange).toHaveBeenCalledWith(true); - }); - - it("should add a task when handleAddTask is called", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - act(() => { - result.current.setNewTask("Test task"); - }); - - act(() => { - result.current.handleAddTask(); - }); - - expect(result.current.tasks).toHaveLength(1); - expect(result.current.tasks[0].title).toBe("Test task"); - expect(result.current.tasks[0].status).toBe("todo"); - expect(result.current.newTask).toBe(""); - expect(result.current.isTaskCreated).toBe(true); - expect(saveTasksToStorage).toHaveBeenCalled(); - }); - - it("should not add empty task", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - const initialLength = result.current.tasks.length; - - act(() => { - result.current.setNewTask(" "); // Only whitespace - }); - - act(() => { - result.current.handleAddTask(); - }); - - expect(result.current.tasks).toHaveLength(initialLength); - expect(result.current.newTask).toBe(" "); - }); - - it("should not add task when max limit is reached", () => { - const existingTasks: Task[] = Array.from({ length: 20 }, (_, i) => ({ - id: `task-${i}`, - title: `Task ${i}`, - status: "todo" as const, - createdAt: new Date().toISOString(), - order: 0, - })); - (loadTasksFromStorage as jest.Mock).mockReturnValue(existingTasks); - - const { result } = renderHook(() => useTasksToday(defaultProps)); - - act(() => { - result.current.setNewTask("New task"); - }); - - act(() => { - result.current.handleAddTask(); - }); - - expect(result.current.tasks).toHaveLength(20); - expect(result.current.newTask).toBe("New task"); - }); - - it("should handle keyboard events for task input", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - act(() => { - result.current.setNewTask("Keyboard task"); - }); - - const mockEvent = { - key: "Enter", - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - } as unknown as React.KeyboardEvent; - - act(() => { - result.current.handleTaskKeyPress(mockEvent); - }); - - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(mockEvent.stopPropagation).toHaveBeenCalled(); - expect(result.current.tasks).toHaveLength(1); - expect(result.current.tasks[0].title).toBe("Keyboard task"); - }); - - it("should not handle non-Enter key events", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - act(() => { - result.current.setNewTask("Test task"); - }); - - const mockEvent = { - key: "Space", - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - } as unknown as React.KeyboardEvent; - - act(() => { - result.current.handleTaskKeyPress(mockEvent); - }); - - expect(mockEvent.preventDefault).not.toHaveBeenCalled(); - expect(mockEvent.stopPropagation).not.toHaveBeenCalled(); - expect(result.current.tasks).toHaveLength(0); // No new task added - }); - - it("should call onNext when handleNext is called", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - act(() => { - result.current.handleNext(); - }); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - - it("should call onNavigationControlChange when state changes", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - // Clear initial call - mockOnNavigationControlChange.mockClear(); - - // Test with unsaved changes - act(() => { - result.current.setNewTask("Unsaved task"); - }); - - expect(mockOnNavigationControlChange).toHaveBeenCalledWith(true); - - // Test with no unsaved changes but task not created - act(() => { - result.current.setNewTask(""); - }); - - expect(mockOnNavigationControlChange).toHaveBeenCalledWith(true); - - // Create a task - act(() => { - result.current.setNewTask("New task"); - }); - - act(() => { - result.current.handleAddTask(); - }); - - // Should allow navigation now - expect(mockOnNavigationControlChange).toHaveBeenLastCalledWith(false); - }); - - it("should trim whitespace from task text", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - act(() => { - result.current.setNewTask(" Trimmed task "); - }); - - act(() => { - result.current.handleAddTask(); - }); - - expect(result.current.tasks[0].title).toBe("Trimmed task"); - }); - - it("should not call onNavigationControlChange when it's not provided", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday({ onNext: mockOnNext })); - - act(() => { - result.current.setNewTask("Test task"); - }); - - // Should not throw error - expect(() => { - act(() => { - result.current.handleAddTask(); - }); - }).not.toThrow(); - }); - - it("should mark task as created when user adds a task", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - expect(result.current.isTaskCreated).toBe(false); - - act(() => { - result.current.setNewTask("New task"); - }); - - act(() => { - result.current.handleAddTask(); - }); - - expect(result.current.isTaskCreated).toBe(true); - }); - - it("should use today's date key for storage", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const today = dayjs(); - - const expectedDateKey = today.format( - dayjs.DateFormat.YEAR_MONTH_DAY_FORMAT, - ); - - renderHook(() => useTasksToday(defaultProps)); - - expect(loadTasksFromStorage).toHaveBeenCalledWith(expectedDateKey); - expect(saveTasksToStorage).not.toHaveBeenCalled(); - }); - - it("should generate unique task IDs", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - act(() => { - result.current.setNewTask("Task 1"); - }); - - act(() => { - result.current.handleAddTask(); - }); - - act(() => { - result.current.setNewTask("Task 2"); - }); - - act(() => { - result.current.handleAddTask(); - }); - - const taskIds = result.current.tasks.map((task) => task.id); - const uniqueIds = new Set(taskIds); - expect(uniqueIds.size).toBe(taskIds.length); - }); - - it("should set createdAt timestamp for new tasks", () => { - (loadTasksFromStorage as jest.Mock).mockReturnValue([]); - const { result } = renderHook(() => useTasksToday(defaultProps)); - - const beforeCreation = new Date().toISOString(); - - act(() => { - result.current.setNewTask("New task"); - }); - - act(() => { - result.current.handleAddTask(); - }); - - const afterCreation = new Date().toISOString(); - const newTask = result.current.tasks[0]; - - expect(newTask.createdAt).toBeDefined(); - expect(newTask.createdAt >= beforeCreation).toBe(true); - expect(newTask.createdAt <= afterCreation).toBe(true); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/useTasksToday.ts b/packages/web/src/views/Onboarding/steps/tasks/TasksToday/useTasksToday.ts deleted file mode 100644 index b4d46c53b..000000000 --- a/packages/web/src/views/Onboarding/steps/tasks/TasksToday/useTasksToday.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { v4 as uuidv4 } from "uuid"; -import { Task } from "@web/common/types/task.types"; -import { - getDateKey, - loadTasksFromStorage, - saveTasksToStorage, -} from "@web/common/utils/storage/storage.util"; - -export interface UseTasksTodayProps { - onNext: () => void; - onNavigationControlChange?: (shouldPrevent: boolean) => void; -} - -export interface UseTasksTodayReturn { - // State - isTaskCreated: boolean; - tasks: Task[]; - newTask: string; - - // Handlers - handleNext: () => void; - handleAddTask: () => void; - handleTaskKeyPress: (e: React.KeyboardEvent) => void; - - // Setters - setNewTask: (value: string) => void; -} - -// Constants -export const MAX_TASKS = 20; - -const hasUserCreatedTask = (tasks: Task[]): boolean => { - return tasks.length > 0; -}; - -// Custom hook for managing task state and operations -export const useTasksToday = ({ - onNext, - onNavigationControlChange, -}: UseTasksTodayProps): UseTasksTodayReturn => { - const dateKey = getDateKey(); - const initialTasks = loadTasksFromStorage(dateKey); - const hasCreatedTask = hasUserCreatedTask(initialTasks); - - // State - const [isTaskCreated, setIsTaskCreated] = useState(hasCreatedTask); - const [tasks, setTasks] = useState(initialTasks); - const [newTask, setNewTask] = useState(""); - - // Navigation prevention effect - useEffect(() => { - const hasUnsavedChanges = newTask.trim() !== ""; - const checkboxNotChecked = !isTaskCreated; - - const shouldPrevent = hasUnsavedChanges || checkboxNotChecked; - - if (onNavigationControlChange) { - onNavigationControlChange(shouldPrevent); - } - }, [newTask, isTaskCreated, onNavigationControlChange]); - - // Task management handlers - const handleAddTask = useCallback(() => { - if (newTask.trim() && tasks.length < MAX_TASKS) { - const newTaskObj: Task = { - id: uuidv4(), - title: newTask.trim(), - status: "todo", - createdAt: new Date().toISOString(), - order: 0, - }; - - const updatedTasks = [...tasks, newTaskObj]; - setTasks(updatedTasks); - saveTasksToStorage(dateKey, updatedTasks); - setNewTask(""); - - // Mark task as created - if (!isTaskCreated) { - setIsTaskCreated(true); - } - } - }, [newTask, tasks, dateKey, isTaskCreated]); - - // Keyboard event handlers - const handleTaskKeyPress = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - e.stopPropagation(); - handleAddTask(); - } - }, - [handleAddTask], - ); - - // Handle next step - const handleNext = useCallback(() => { - onNext(); - }, [onNext]); - - return { - // State - isTaskCreated, - tasks, - newTask, - - // Handlers - handleNext, - handleAddTask, - handleTaskKeyPress, - - // Setters - setNewTask, - }; -}; diff --git a/packages/web/src/views/Onboarding/steps/welcome/Welcome.test.tsx b/packages/web/src/views/Onboarding/steps/welcome/Welcome.test.tsx deleted file mode 100644 index 6c7b7126e..000000000 --- a/packages/web/src/views/Onboarding/steps/welcome/Welcome.test.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { act } from "react"; -import "@testing-library/jest-dom"; -import { screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { render } from "@web/__tests__/__mocks__/mock.render"; -import { WelcomeStep } from "./Welcome"; - -// Mock the onboarding components -jest.mock("../../components", () => ({ - OnboardingCardLayout: ({ children, currentStep, totalSteps }: any) => ( -
-
- Step {currentStep} of {totalSteps} -
- {children} -
- ), - OnboardingText: ({ children, visible, ...props }: any) => ( -
- {children} -
- ), -})); - -describe("WelcomeStep", () => { - const mockOnNext = jest.fn(); - const mockOnPrevious = jest.fn(); - const mockOnSkip = jest.fn(); - - const defaultProps = { - currentStep: 1, - totalSteps: 5, - onNext: mockOnNext, - onPrevious: mockOnPrevious, - onSkip: mockOnSkip, - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - }); - - afterEach(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); - jest.useRealTimers(); - }); - - describe("Component Rendering", () => { - it("renders the component with correct step information", () => { - render(); - - expect(screen.getByTestId("onboarding-card-layout")).toBeInTheDocument(); - expect(screen.getByTestId("step-info")).toHaveTextContent("Step 1 of 5"); - }); - - it("renders initial text lines", () => { - render(); - - expect(screen.getByText("COMPASS CALENDAR")).toBeInTheDocument(); - expect( - screen.getByText("The weekly planner for minimalists"), - ).toBeInTheDocument(); - expect( - screen.getByText("Copyright (c) 2025. All Rights Reserved"), - ).toBeInTheDocument(); - expect(screen.getByText("BIOS Version: 20250721")).toBeInTheDocument(); - expect(screen.getByText("2514 KB")).toBeInTheDocument(); - }); - - it("renders date and time", () => { - render(); - - // Date and time are rendered, exact format depends on locale - const textElements = screen.getAllByTestId("onboarding-text"); - const hasDateOrTime = textElements.some((el) => { - const text = el.textContent || ""; - return text.includes("2025") || /\d{1,2}:\d{2}/.test(text); - }); - expect(hasDateOrTime).toBe(true); - }); - }); - - describe("Animation Behavior", () => { - it("shows lines progressively over time", async () => { - render(); - - // After initial delay (200ms), first line should appear - act(() => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(screen.getByText("COMPASS CALENDAR")).toBeInTheDocument(); - }); - - // After another 800ms, second line should appear - act(() => { - jest.advanceTimersByTime(800); - }); - - await waitFor(() => { - expect( - screen.getByText("The weekly planner for minimalists"), - ).toBeInTheDocument(); - }); - }); - - it("shows check results after check text appears", async () => { - render(); - - // Fast-forward to first check line (7 text lines * 800ms + 200ms initial delay) - act(() => { - jest.advanceTimersByTime(5800); - }); - - await waitFor(() => { - expect(screen.getByText(/Night Vision Check/)).toBeInTheDocument(); - }); - - // After 150ms delay, result should appear - act(() => { - jest.advanceTimersByTime(150); - }); - - await waitFor(() => { - expect(screen.getByText("98% Lanterns Lit")).toBeInTheDocument(); - }); - }); - - it("completes animation after all lines are shown", async () => { - render(); - - // Fast-forward through entire animation - // Text lines: 7 * 800ms = 5600ms + 200ms initial = 5800ms - // Check lines: 10 * 400ms = 4000ms - // Final line: 300ms - // Total: ~10100ms - act(() => { - jest.advanceTimersByTime(11000); - }); - - await waitFor(() => { - expect(screen.getByText("Press Any Key to board")).toBeInTheDocument(); - }); - }); - }); - - describe("Click Interaction", () => { - it("skips animation when clicking container during animation", async () => { - const user = userEvent.setup({ delay: null }); - render(); - - // Start animation - act(() => { - jest.advanceTimersByTime(1000); - }); - - // Click on any text element (they're all inside the clickable container) - const textElement = screen.getByText("COMPASS CALENDAR"); - - await act(async () => { - await user.click(textElement); - }); - - // All lines should be visible immediately - await waitFor(() => { - expect(screen.getByText("COMPASS CALENDAR")).toBeInTheDocument(); - expect(screen.getByText("Press Any Key to board")).toBeInTheDocument(); - expect(screen.getByText("98% Lanterns Lit")).toBeInTheDocument(); - }); - }); - - it("calls onNext when clicking container after animation completes", async () => { - const user = userEvent.setup({ delay: null }); - render(); - - // Complete animation - act(() => { - jest.advanceTimersByTime(11000); - }); - - await waitFor(() => { - expect(screen.getByText("Press Any Key to board")).toBeInTheDocument(); - }); - - // Click on any text element (they're all inside the clickable container) - const textElement = screen.getByText("Press Any Key to board"); - - await act(async () => { - await user.click(textElement); - }); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - }); - - describe("Keyboard Interaction", () => { - it("skips animation when pressing Enter during animation", async () => { - const user = userEvent.setup({ delay: null }); - render(); - - // Start animation - act(() => { - jest.advanceTimersByTime(1000); - }); - - // Press Enter - await act(async () => { - await user.keyboard("{Enter}"); - }); - - // All content should be visible - await waitFor(() => { - expect(screen.getByText("COMPASS CALENDAR")).toBeInTheDocument(); - expect(screen.getByText("Press Any Key to board")).toBeInTheDocument(); - }); - }); - - it("skips animation when pressing ArrowRight during animation", async () => { - const user = userEvent.setup({ delay: null }); - render(); - - // Start animation - act(() => { - jest.advanceTimersByTime(1000); - }); - - // Press ArrowRight - await act(async () => { - await user.keyboard("{ArrowRight}"); - }); - - // All content should be visible - await waitFor(() => { - expect(screen.getByText("COMPASS CALENDAR")).toBeInTheDocument(); - expect(screen.getByText("Press Any Key to board")).toBeInTheDocument(); - }); - }); - - it("calls onNext when pressing Enter after animation completes", async () => { - const user = userEvent.setup({ delay: null }); - render(); - - // Complete animation - act(() => { - jest.advanceTimersByTime(11000); - }); - - await waitFor(() => { - expect(screen.getByText("Press Any Key to board")).toBeInTheDocument(); - }); - - // Press Enter - await act(async () => { - await user.keyboard("{Enter}"); - }); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - - it("calls onNext when pressing any key after animation completes", async () => { - const user = userEvent.setup({ delay: null }); - render(); - - // Complete animation - act(() => { - jest.advanceTimersByTime(11000); - }); - - await waitFor(() => { - expect(screen.getByText("Press Any Key to board")).toBeInTheDocument(); - }); - - // Press any key (e.g., Space) - await act(async () => { - await user.keyboard(" "); - }); - - expect(mockOnNext).toHaveBeenCalledTimes(1); - }); - }); - - describe("Check Lines Display", () => { - it("displays all check lines with their results when animation is skipped", async () => { - const user = userEvent.setup({ delay: null }); - render(); - - // Skip animation to see all checks immediately - const textElement = screen.getByText("COMPASS CALENDAR"); - - await act(async () => { - await user.click(textElement); - }); - - await waitFor(() => { - expect(screen.getByText(/Night Vision Check/)).toBeInTheDocument(); - expect(screen.getByText("98% Lanterns Lit")).toBeInTheDocument(); - expect( - screen.getByText(/Staff Emergency Contacts/), - ).toBeInTheDocument(); - expect(screen.getByText("Secured in Cabin")).toBeInTheDocument(); - expect( - screen.getByText(/Initializing Compass Alignment/), - ).toBeInTheDocument(); - expect(screen.getByText("Done")).toBeInTheDocument(); - expect(screen.getByText(/Final Anchor Check/)).toBeInTheDocument(); - expect(screen.getByText("Ready to Hoist")).toBeInTheDocument(); - expect(screen.getByText(/Sails Unfurled/)).toBeInTheDocument(); - expect(screen.getByText("Awaiting Orders")).toBeInTheDocument(); - }); - }); - }); - - describe("Props Handling", () => { - it("passes correct props to OnboardingCardLayout", () => { - render(); - - const stepInfo = screen.getByTestId("step-info"); - expect(stepInfo).toHaveTextContent("Step 1 of 5"); - }); - - it("handles different step numbers correctly", () => { - const customProps = { - ...defaultProps, - currentStep: 2, - totalSteps: 10, - }; - - render(); - - const stepInfo = screen.getByTestId("step-info"); - expect(stepInfo).toHaveTextContent("Step 2 of 10"); - }); - }); - - describe("Animation Skip Logic", () => { - it("shows all check results immediately when skipped", async () => { - const user = userEvent.setup({ delay: null }); - render(); - - const textElement = screen.getByText("COMPASS CALENDAR"); - - await act(async () => { - await user.click(textElement); - }); - - // All check results should be visible immediately - await waitFor(() => { - const checkResults = [ - "98% Lanterns Lit", - "Secured in Cabin", - "Done", - "Sufficient", - "All Lines Taut", - "Complete", - "One Missing", - "Favorable", - "Ready to Hoist", - "Awaiting Orders", - ]; - - checkResults.forEach((result) => { - expect(screen.getByText(result)).toBeInTheDocument(); - }); - }); - }); - }); -}); diff --git a/packages/web/src/views/Onboarding/steps/welcome/Welcome.tsx b/packages/web/src/views/Onboarding/steps/welcome/Welcome.tsx deleted file mode 100644 index 90badf8aa..000000000 --- a/packages/web/src/views/Onboarding/steps/welcome/Welcome.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import styled from "styled-components"; -import { OnboardingCardLayout, OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; - -const CRTContainer = styled.div` - position: relative; - text-align: left; - width: 100%; -`; - -const AnimatedText = styled(OnboardingText)<{ - delay: number; - visible: boolean; -}>` - opacity: ${({ visible }) => (visible ? 1 : 0)}; - transition: opacity 0.1s ease-in; - transition-delay: ${({ delay }) => delay}ms; -`; - -const CheckText = styled(OnboardingText)<{ delay: number; visible: boolean }>` - opacity: ${({ visible }) => (visible ? 1 : 0)}; - transition: opacity 0.1s ease-in; - transition-delay: ${({ delay }) => delay}ms; - display: inline; -`; - -const ResultText = styled.span<{ delay: number; visible: boolean }>` - font-family: "VT323", monospace; - font-size: 24px; - color: ${({ theme }) => theme.color.common.white}; - opacity: ${({ visible }) => (visible ? 1 : 0)}; - transition: opacity 0.1s ease-in; - transition-delay: ${({ delay }) => delay}ms; - display: inline; - margin: 0; -`; - -const BlinkingText = styled(OnboardingText)<{ - delay: number; - visible: boolean; -}>` - opacity: ${({ visible }) => (visible ? 1 : 0)}; - transition: opacity 0.1s ease-in; - transition-delay: ${({ delay }) => delay}ms; - - @keyframes blink { - 0%, - 50% { - opacity: 1; - } - 51%, - 100% { - opacity: 0; - } - } - - animation: ${({ visible }) => (visible ? "blink 1s infinite" : "none")}; -`; - -export const WelcomeStep: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - const [visibleLines, setVisibleLines] = useState(0); - const [checkResults, setCheckResults] = useState>({}); - const [animationComplete, setAnimationComplete] = useState(false); - const [animationSkipped, setAnimationSkipped] = useState(false); - - const now = new Date(); - const dateStr = now.toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); - const timeStr = now.toLocaleTimeString("en-US", { - hour12: false, - timeZoneName: "short", - }); - - const textLines = [ - "COMPASS CALENDAR", - "The weekly planner for minimalists", - "Copyright (c) 2025. All Rights Reserved", - "BIOS Version: 20250721", - "2514 KB", - dateStr, - timeStr, - ]; - - const checkLines = useMemo( - () => [ - { text: "Night Vision Check", result: "98% Lanterns Lit" }, - { text: "Staff Emergency Contacts", result: "Secured in Cabin" }, - { text: "Initializing Compass Alignment", result: "Done" }, - { text: "Provisions Check", result: "Sufficient" }, - { text: "Rigging Integrity Scan", result: "All Lines Taut" }, - { text: "Chart Room Calibration", result: "Complete" }, - { text: "Crew Roster Verification", result: "One Missing" }, - { text: "Wind Vectors Computed", result: "Favorable" }, - { text: "Final Anchor Check", result: "Ready to Hoist" }, - { text: "Sails Unfurled", result: "Awaiting Orders" }, - ], - [], - ); - - const finalLines = ["Press Any Key to board"]; - - const skipAnimation = useCallback(() => { - if (!animationComplete) { - // Skip animation - show all content immediately and stop timeouts - setAnimationSkipped(true); - setVisibleLines(textLines.length + checkLines.length + finalLines.length); - const allCheckResults: Record = {}; - checkLines.forEach((check) => { - allCheckResults[check.text] = true; - }); - setCheckResults(allCheckResults); - setAnimationComplete(true); - } else { - // Animation is complete, move to next step - onNext(); - } - }, [ - animationComplete, - checkLines, - finalLines.length, - onNext, - textLines.length, - ]); - - useEffect(() => { - if (animationSkipped) return; - - const totalTextLines = textLines.length; - const totalCheckLines = checkLines.length; - const totalFinalLines = finalLines.length; - const totalLines = totalTextLines + totalCheckLines + totalFinalLines; - - let currentLine = 0; - let timeoutId: NodeJS.Timeout; - - const showNextLine = () => { - if (animationSkipped) return; - - if (currentLine < totalTextLines) { - setVisibleLines(currentLine + 1); - currentLine++; - timeoutId = setTimeout(showNextLine, 800); - } else if (currentLine < totalTextLines + totalCheckLines) { - const checkIndex = currentLine - totalTextLines; - const checkKey = checkLines[checkIndex].text; - - // First show the check text - setVisibleLines(currentLine + 1); - - // Then show the result after a shorter delay - setTimeout(() => { - if (!animationSkipped) { - setCheckResults((prev) => ({ ...prev, [checkKey]: true })); - } - }, 150); - - currentLine++; - timeoutId = setTimeout(showNextLine, 400); - } else if (currentLine < totalLines) { - setVisibleLines( - totalTextLines + - totalCheckLines + - (currentLine - totalTextLines - totalCheckLines) + - 1, - ); - currentLine++; - timeoutId = setTimeout(showNextLine, 300); - } else { - setAnimationComplete(true); - } - }; - - const initialDelay = setTimeout(showNextLine, 200); - - return () => { - clearTimeout(initialDelay); - clearTimeout(timeoutId); - }; - }, [animationSkipped, checkLines, finalLines.length, textLines.length]); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isRightArrow = event.key === "ArrowRight"; - const isEnter = event.key === "Enter"; - - if (isRightArrow || isEnter) { - event.preventDefault(); - skipAnimation(); - } else if (animationComplete) { - // After animation is complete, any key press should trigger next - event.preventDefault(); - onNext(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [animationComplete, onNext, skipAnimation]); - - return ( - - - {textLines.map((line, index) => ( - - {line} - - ))} - - {checkLines.map((check, index) => { - const checkVisible = index < visibleLines - textLines.length; - const resultVisible = checkResults[check.text] || false; - - return ( -
- {checkVisible && ( - - {check.text} ... - {resultVisible && ( - - {check.result} - - )} - - )} -
- ); - })} - - {finalLines.map((line, index) => ( - - {line} - - ))} -
-
- ); -}; diff --git a/packages/web/src/views/Onboarding/steps/welcome/WelcomeNoteOne.tsx b/packages/web/src/views/Onboarding/steps/welcome/WelcomeNoteOne.tsx deleted file mode 100644 index 457687872..000000000 --- a/packages/web/src/views/Onboarding/steps/welcome/WelcomeNoteOne.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; -import { OnboardingCardLayout } from "../../components/layouts/OnboardingCardLayout"; - -export const WelcomeNoteOne: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - - I see you are eager to board, but I must warn you: - - - This journey is full of danger. - - - The winds above the surface will push you towards burnout, - - - - while the leviathan below will pull you away from your goals. - - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/welcome/WelcomeNoteTwo.tsx b/packages/web/src/views/Onboarding/steps/welcome/WelcomeNoteTwo.tsx deleted file mode 100644 index 644c81247..000000000 --- a/packages/web/src/views/Onboarding/steps/welcome/WelcomeNoteTwo.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { OnboardingCardLayout, OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; - -export const WelcomeNoteTwo: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - return ( - - - To protect yourself, you'll need this one thing at all times: - - - - FOCUS. - - - - Compass Calendar will help you stay focused on what matters to you, one - day at a time. - - - Let me show you how. - - ); -}; diff --git a/packages/web/src/views/Onboarding/steps/welcome/WelcomeScreen.tsx b/packages/web/src/views/Onboarding/steps/welcome/WelcomeScreen.tsx deleted file mode 100644 index 817edb05d..000000000 --- a/packages/web/src/views/Onboarding/steps/welcome/WelcomeScreen.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { OnboardingCardLayout, OnboardingText } from "../../components"; -import { OnboardingStepProps } from "../../components/Onboarding"; -import { useOnboarding } from "../../components/OnboardingContext"; - -const AsciiContainer = styled.div` - width: 100%; - overflow: hidden; - margin: 20px 0; - position: relative; -`; - -const AsciiArt = styled.pre` - font-family: monospace; - white-space: pre; - line-height: 1.2; - font-size: 18px; - color: ${({ theme }) => theme.color.common.white}; - text-align: left; - margin: 0; - position: relative; - left: -107px; -`; - -export interface WelcomeScreenProps extends OnboardingStepProps { - firstName: string; -} - -export const WelcomeScreen: React.FC = ({ - currentStep, - totalSteps, - onNext, - onPrevious, - onSkip, -}) => { - const { firstName } = useOnboarding(); - - return ( - - Welcome, Captain {firstName} - - - {` | | | - )_) )_) )_) - )___))___))___)\\ - )____)____)_____)\\\\ - _____|____|____|____\\\\__ - ---------\\ /--------- - ^^^^^ ^^^^^^^^^^^^^^^^^^^^^ - ^^^^ ^^^^ ^^^ ^^ - ^^^^ ^^^`} - - - - ); -}; diff --git a/packages/web/src/views/Onboarding/types/onboarding-notice.types.ts b/packages/web/src/views/Onboarding/types/onboarding-notice.types.ts deleted file mode 100644 index 4e4369ef7..000000000 --- a/packages/web/src/views/Onboarding/types/onboarding-notice.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface OnboardingNoticeAction { - label: string; - onClick: () => void; -} - -export interface OnboardingNotice { - id: string; - header: string; - body: string; - primaryAction?: OnboardingNoticeAction; - secondaryAction?: OnboardingNoticeAction; -}