From 7170ea43203113eb7561a7c0045968481ad767b2 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 20 Feb 2026 19:42:26 -0800 Subject: [PATCH 01/34] feat(auth): implement authentication modal with sign in, sign up, and forgot password functionality - Added `AuthModal` component to manage user authentication flows, including sign in, sign up, and password recovery. - Introduced `AccountIcon` for triggering the authentication modal. - Created forms for sign in, sign up, and forgot password, utilizing Zod for validation. - Implemented tab navigation between sign in and sign up views. - Integrated Google OAuth sign-in functionality. - Established context and hooks for managing modal state and feature flags. - Added comprehensive tests for modal behavior and form validation. --- .../web/src/auth/schemas/auth.schemas.test.ts | 240 ++++++++ packages/web/src/auth/schemas/auth.schemas.ts | 73 +++ .../src/components/AuthModal/AccountIcon.tsx | 40 ++ .../components/AuthModal/AuthModal.test.tsx | 550 ++++++++++++++++++ .../src/components/AuthModal/AuthModal.tsx | 147 +++++ .../AuthModal/AuthModalProvider.tsx | 22 + .../AuthModal/components/AuthButton.tsx | 59 ++ .../AuthModal/components/AuthInput.tsx | 62 ++ .../AuthModal/components/AuthTabs.tsx | 47 ++ .../AuthModal/forms/ForgotPasswordForm.tsx | 126 ++++ .../components/AuthModal/forms/SignInForm.tsx | 158 +++++ .../components/AuthModal/forms/SignUpForm.tsx | 163 ++++++ .../AuthModal/hooks/useAuthFeatureFlag.ts | 18 + .../AuthModal/hooks/useAuthModal.ts | 75 +++ .../web/src/components/AuthModal/index.ts | 5 + .../CompassProvider/CompassProvider.tsx | 8 +- .../Calendar/components/Header/Header.tsx | 2 + .../views/Day/components/Header/Header.tsx | 2 + 18 files changed, 1796 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/auth/schemas/auth.schemas.test.ts create mode 100644 packages/web/src/auth/schemas/auth.schemas.ts create mode 100644 packages/web/src/components/AuthModal/AccountIcon.tsx create mode 100644 packages/web/src/components/AuthModal/AuthModal.test.tsx create mode 100644 packages/web/src/components/AuthModal/AuthModal.tsx create mode 100644 packages/web/src/components/AuthModal/AuthModalProvider.tsx create mode 100644 packages/web/src/components/AuthModal/components/AuthButton.tsx create mode 100644 packages/web/src/components/AuthModal/components/AuthInput.tsx create mode 100644 packages/web/src/components/AuthModal/components/AuthTabs.tsx create mode 100644 packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx create mode 100644 packages/web/src/components/AuthModal/forms/SignInForm.tsx create mode 100644 packages/web/src/components/AuthModal/forms/SignUpForm.tsx create mode 100644 packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts create mode 100644 packages/web/src/components/AuthModal/hooks/useAuthModal.ts create mode 100644 packages/web/src/components/AuthModal/index.ts diff --git a/packages/web/src/auth/schemas/auth.schemas.test.ts b/packages/web/src/auth/schemas/auth.schemas.test.ts new file mode 100644 index 000000000..a7e0b1d8d --- /dev/null +++ b/packages/web/src/auth/schemas/auth.schemas.test.ts @@ -0,0 +1,240 @@ +import { + emailSchema, + forgotPasswordSchema, + nameSchema, + passwordSchema, + signInSchema, + signUpSchema, +} from "./auth.schemas"; + +describe("auth.schemas", () => { + describe("emailSchema", () => { + it("validates a correct email", () => { + const result = emailSchema.safeParse("test@example.com"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("test@example.com"); + } + }); + + it("transforms email to lowercase", () => { + const result = emailSchema.safeParse("Test@EXAMPLE.com"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("test@example.com"); + } + }); + + it("trims whitespace", () => { + const result = emailSchema.safeParse(" test@example.com "); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("test@example.com"); + } + }); + + it("rejects empty string", () => { + const result = emailSchema.safeParse(""); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errors[0].message).toBe("Email is required"); + } + }); + + it("rejects invalid email format", () => { + const result = emailSchema.safeParse("not-an-email"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errors[0].message).toBe( + "Please enter a valid email address", + ); + } + }); + + it("rejects email without domain", () => { + const result = emailSchema.safeParse("test@"); + expect(result.success).toBe(false); + }); + }); + + describe("passwordSchema", () => { + it("validates password with 8+ characters", () => { + const result = passwordSchema.safeParse("password123"); + expect(result.success).toBe(true); + }); + + it("validates password with exactly 8 characters", () => { + const result = passwordSchema.safeParse("12345678"); + expect(result.success).toBe(true); + }); + + it("rejects empty password", () => { + const result = passwordSchema.safeParse(""); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errors[0].message).toBe("Password is required"); + } + }); + + it("rejects password shorter than 8 characters", () => { + const result = passwordSchema.safeParse("1234567"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errors[0].message).toBe( + "Password must be at least 8 characters", + ); + } + }); + }); + + describe("nameSchema", () => { + it("validates a non-empty name", () => { + const result = nameSchema.safeParse("John Doe"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("John Doe"); + } + }); + + it("trims whitespace", () => { + const result = nameSchema.safeParse(" John Doe "); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("John Doe"); + } + }); + + it("rejects empty string", () => { + const result = nameSchema.safeParse(""); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errors[0].message).toBe("Name is required"); + } + }); + + it("rejects whitespace-only string", () => { + const result = nameSchema.safeParse(" "); + expect(result.success).toBe(false); + }); + }); + + describe("signUpSchema", () => { + it("validates complete sign up data", () => { + const result = signUpSchema.safeParse({ + name: "John Doe", + email: "john@example.com", + password: "password123", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + name: "John Doe", + email: "john@example.com", + password: "password123", + }); + } + }); + + it("transforms email and trims name", () => { + const result = signUpSchema.safeParse({ + name: " John Doe ", + email: "JOHN@EXAMPLE.COM", + password: "password123", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe("John Doe"); + expect(result.data.email).toBe("john@example.com"); + } + }); + + it("rejects missing name", () => { + const result = signUpSchema.safeParse({ + email: "john@example.com", + password: "password123", + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid email", () => { + const result = signUpSchema.safeParse({ + name: "John", + email: "invalid", + password: "password123", + }); + expect(result.success).toBe(false); + }); + + it("rejects short password", () => { + const result = signUpSchema.safeParse({ + name: "John", + email: "john@example.com", + password: "short", + }); + expect(result.success).toBe(false); + }); + }); + + describe("signInSchema", () => { + it("validates complete sign in data", () => { + const result = signInSchema.safeParse({ + email: "john@example.com", + password: "anypassword", + }); + expect(result.success).toBe(true); + }); + + it("accepts any non-empty password (no min length)", () => { + const result = signInSchema.safeParse({ + email: "john@example.com", + password: "a", + }); + expect(result.success).toBe(true); + }); + + it("rejects empty password", () => { + const result = signInSchema.safeParse({ + email: "john@example.com", + password: "", + }); + expect(result.success).toBe(false); + }); + + it("transforms email to lowercase", () => { + const result = signInSchema.safeParse({ + email: "JOHN@EXAMPLE.COM", + password: "password", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.email).toBe("john@example.com"); + } + }); + }); + + describe("forgotPasswordSchema", () => { + it("validates a correct email", () => { + const result = forgotPasswordSchema.safeParse({ + email: "john@example.com", + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid email", () => { + const result = forgotPasswordSchema.safeParse({ + email: "not-an-email", + }); + expect(result.success).toBe(false); + }); + + it("transforms email to lowercase", () => { + const result = forgotPasswordSchema.safeParse({ + email: "JOHN@EXAMPLE.COM", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.email).toBe("john@example.com"); + } + }); + }); +}); diff --git a/packages/web/src/auth/schemas/auth.schemas.ts b/packages/web/src/auth/schemas/auth.schemas.ts new file mode 100644 index 000000000..1d8db29f3 --- /dev/null +++ b/packages/web/src/auth/schemas/auth.schemas.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +/** + * Email validation schema + * - Required with meaningful error + * - Validates email format + * - Transforms to lowercase and trims whitespace + * + * Note: We use preprocess to trim/lowercase BEFORE validation so that + * " test@example.com " validates correctly after trimming. + */ +export const emailSchema = z.preprocess( + (val) => (typeof val === "string" ? val.trim().toLowerCase() : val), + z + .string() + .min(1, "Email is required") + .email("Please enter a valid email address"), +); + +/** + * Password validation schema + * - Required with meaningful error + * - Minimum 8 characters for security + */ +export const passwordSchema = z + .string() + .min(1, "Password is required") + .min(8, "Password must be at least 8 characters"); + +/** + * Name validation schema + * - Required with meaningful error + * - Trims whitespace + * + * Note: We use preprocess to trim BEFORE validation, then refine to check + * that the trimmed result is non-empty (rejects whitespace-only strings). + */ +export const nameSchema = z.preprocess( + (val) => (typeof val === "string" ? val.trim() : val), + z.string().min(1, "Name is required"), +); + +/** + * Sign up form schema + * Combines name, email, and password validation + */ +export const signUpSchema = z.object({ + name: nameSchema, + email: emailSchema, + password: passwordSchema, +}); + +/** + * Sign in form schema + * Email validation + password presence check (no min length on sign in) + */ +export const signInSchema = z.object({ + email: emailSchema, + password: z.string().min(1, "Password is required"), +}); + +/** + * Forgot password form schema + * Only requires valid email + */ +export const forgotPasswordSchema = z.object({ + email: emailSchema, +}); + +// Type exports for form data +export type SignUpFormData = z.infer; +export type SignInFormData = z.infer; +export type ForgotPasswordFormData = z.infer; diff --git a/packages/web/src/components/AuthModal/AccountIcon.tsx b/packages/web/src/components/AuthModal/AccountIcon.tsx new file mode 100644 index 000000000..dbca0255f --- /dev/null +++ b/packages/web/src/components/AuthModal/AccountIcon.tsx @@ -0,0 +1,40 @@ +import { FC } from "react"; +import { User } from "@phosphor-icons/react"; +import { useSession } from "@web/auth/hooks/session/useSession"; +import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; +import { useAuthFeatureFlag } from "./hooks/useAuthFeatureFlag"; +import { useAuthModal } from "./hooks/useAuthModal"; + +/** + * Account icon button for triggering the auth modal + * + * Only renders when: + * - User is not authenticated + * - Feature flag ?enableAuth=true is present in URL + * + * Clicking opens the auth modal with sign-in view + */ +export const AccountIcon: FC = () => { + const { authenticated } = useSession(); + const isEnabled = useAuthFeatureFlag(); + const { openModal } = useAuthModal(); + + // Don't show if user is already authenticated or feature is disabled + if (authenticated || !isEnabled) { + return null; + } + + const handleClick = () => { + openModal("signIn"); + }; + + return ( + + + + ); +}; diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx new file mode 100644 index 000000000..ab8e00b92 --- /dev/null +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -0,0 +1,550 @@ +import { MemoryRouter } from "react-router-dom"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AccountIcon } from "./AccountIcon"; +import { AuthModal } from "./AuthModal"; +import { AuthModalProvider } from "./AuthModalProvider"; +import { useAuthModal } from "./hooks/useAuthModal"; + +// Mock useSession +const mockUseSession = jest.fn(() => ({ + authenticated: false, + setAuthenticated: jest.fn(), +})); + +jest.mock("@web/auth/hooks/session/useSession", () => ({ + useSession: () => mockUseSession(), +})); + +// Mock useGoogleAuth +const mockGoogleLogin = jest.fn(); +jest.mock("@web/auth/hooks/oauth/useGoogleAuth", () => ({ + useGoogleAuth: () => ({ + login: mockGoogleLogin, + }), +})); + +// Mock GoogleButton +jest.mock("@web/components/oauth/google/GoogleButton", () => ({ + GoogleButton: ({ + onClick, + label, + }: { + onClick: () => void; + label: string; + }) => ( + + ), +})); + +// Mock TooltipWrapper +jest.mock("@web/components/Tooltip/TooltipWrapper", () => ({ + TooltipWrapper: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + description?: string; + }) => ( +
+ {children} +
+ ), +})); + +// Helper component to trigger modal open +const ModalTrigger = () => { + const { openModal } = useAuthModal(); + return ( + + ); +}; + +const renderWithProviders = ( + component: React.ReactElement, + initialRoute: string = "/day", +) => { + return render( + + + {component} + + + , + ); +}; + +describe("AuthModal", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSession.mockReturnValue({ + authenticated: false, + setAuthenticated: jest.fn(), + }); + }); + + describe("Modal Open/Close", () => { + it("opens modal when triggered", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect( + screen.queryByRole("heading", { name: /welcome to compass/i }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /welcome to compass/i }), + ).toBeInTheDocument(); + }); + }); + + it("closes modal when backdrop is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /welcome to compass/i }), + ).toBeInTheDocument(); + }); + + // Click on backdrop (the presentation div) + const backdrop = document.querySelector('[role="presentation"]'); + expect(backdrop).toBeInTheDocument(); + + await user.click(backdrop!); + + await waitFor(() => { + expect( + screen.queryByRole("heading", { name: /welcome to compass/i }), + ).not.toBeInTheDocument(); + }); + }); + + it("closes modal when Escape key is pressed", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /welcome to compass/i }), + ).toBeInTheDocument(); + }); + + // Focus the backdrop so it can receive keyboard events + const backdrop = document.querySelector('[role="presentation"]'); + (backdrop as HTMLElement)?.focus(); + + await user.keyboard("{Escape}"); + + await waitFor(() => { + expect( + screen.queryByRole("heading", { name: /welcome to compass/i }), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("Tab Navigation", () => { + it("shows Sign In tab as active by default", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + const signInTab = screen.getByRole("tab", { name: /sign in/i }); + expect(signInTab).toHaveAttribute("aria-selected", "true"); + }); + }); + + it("switches to Sign Up tab when clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect( + screen.getByRole("tab", { name: /sign up/i }), + ).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("tab", { name: /sign up/i })); + + await waitFor(() => { + const signUpTab = screen.getByRole("tab", { name: /sign up/i }); + expect(signUpTab).toHaveAttribute("aria-selected", "true"); + }); + }); + + it("shows Name field only on Sign Up form", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + // Sign In tab - no Name field + await waitFor(() => { + expect(screen.queryByLabelText(/name/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + // Switch to Sign Up + await user.click(screen.getByRole("tab", { name: /sign up/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + }); + }); + }); + + describe("Sign In Form", () => { + it("renders email and password fields", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + }); + + it("renders submit button", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + // Look for the submit button by type + const submitButton = screen.getByRole("button", { name: /^sign in$/i }); + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toHaveAttribute("type", "submit"); + }); + }); + + it("shows email error on blur with invalid email", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText(/email/i), "invalid-email"); + await user.tab(); // Blur the field + + await waitFor(() => { + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); + }); + }); + + it("navigates to forgot password when link is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /forgot password/i }), + ).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole("button", { name: /forgot password/i }), + ); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /reset password/i }), + ).toBeInTheDocument(); + }); + }); + }); + + describe("Sign Up Form", () => { + it("renders name, email, and password fields", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + }); + + it("shows password error for short password", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("tab", { name: /sign up/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText(/password/i), "short"); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText(/password must be at least 8 characters/i), + ).toBeInTheDocument(); + }); + }); + }); + + describe("Forgot Password Form", () => { + it("renders email field and instructions", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + await user.click( + screen.getByRole("button", { name: /forgot password/i }), + ); + + await waitFor(() => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect( + screen.getByText(/enter your email address/i), + ).toBeInTheDocument(); + }); + }); + + it("shows success message after submission", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + await user.click( + screen.getByRole("button", { name: /forgot password/i }), + ); + + await waitFor(() => { + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.click( + screen.getByRole("button", { name: /send reset link/i }), + ); + + await waitFor(() => { + expect(screen.getByText(/check your email/i)).toBeInTheDocument(); + }); + }); + + it("navigates back to sign in when link is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + await user.click( + screen.getByRole("button", { name: /forgot password/i }), + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /back to sign in/i }), + ).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole("button", { name: /back to sign in/i }), + ); + + await waitFor(() => { + expect( + screen.getByRole("tab", { name: /sign in/i }), + ).toBeInTheDocument(); + }); + }); + }); + + describe("Google Sign In", () => { + it("renders Google sign in button", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect(screen.getByTestId("google-button")).toBeInTheDocument(); + expect(screen.getByTestId("google-button")).toHaveTextContent( + /sign in with google/i, + ); + }); + }); + + it("calls googleLogin when Google button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect(screen.getByTestId("google-button")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("google-button")); + + expect(mockGoogleLogin).toHaveBeenCalled(); + }); + + it("changes button label based on active tab", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect(screen.getByTestId("google-button")).toHaveTextContent( + /sign in with google/i, + ); + }); + + await user.click(screen.getByRole("tab", { name: /sign up/i })); + + await waitFor(() => { + expect(screen.getByTestId("google-button")).toHaveTextContent( + /sign up with google/i, + ); + }); + }); + }); + + describe("Privacy and Terms Links", () => { + it("renders privacy and terms links", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + expect( + screen.getByRole("link", { name: /terms of service/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /privacy policy/i }), + ).toBeInTheDocument(); + }); + }); + + it("links open in new tab", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("open-modal")); + + await waitFor(() => { + const termsLink = screen.getByRole("link", { + name: /terms of service/i, + }); + const privacyLink = screen.getByRole("link", { + name: /privacy policy/i, + }); + + expect(termsLink).toHaveAttribute("target", "_blank"); + expect(privacyLink).toHaveAttribute("target", "_blank"); + expect(termsLink).toHaveAttribute("rel", "noopener noreferrer"); + expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + }); +}); + +describe("AccountIcon", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders when user is not authenticated and feature flag is enabled", async () => { + mockUseSession.mockReturnValue({ + authenticated: false, + setAuthenticated: jest.fn(), + }); + + renderWithProviders(, "/day?enableAuth=true"); + + await waitFor(() => { + expect(screen.getByLabelText(/sign in/i)).toBeInTheDocument(); + }); + }); + + it("does not render when user is authenticated", () => { + mockUseSession.mockReturnValue({ + authenticated: true, + setAuthenticated: jest.fn(), + }); + + renderWithProviders(, "/day?enableAuth=true"); + + expect(screen.queryByLabelText(/sign in/i)).not.toBeInTheDocument(); + }); + + it("does not render when feature flag is disabled", () => { + mockUseSession.mockReturnValue({ + authenticated: false, + setAuthenticated: jest.fn(), + }); + + renderWithProviders(, "/day"); + + expect(screen.queryByLabelText(/sign in/i)).not.toBeInTheDocument(); + }); + + it("opens modal when clicked", async () => { + const user = userEvent.setup(); + mockUseSession.mockReturnValue({ + authenticated: false, + setAuthenticated: jest.fn(), + }); + + renderWithProviders(, "/day?enableAuth=true"); + + await waitFor(() => { + expect(screen.getByLabelText(/sign in/i)).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("tooltip-wrapper")); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /welcome to compass/i }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx new file mode 100644 index 000000000..e9015f7a6 --- /dev/null +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -0,0 +1,147 @@ +import { FC, useCallback } from "react"; +import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; +import { SignInFormData, SignUpFormData } from "@web/auth/schemas/auth.schemas"; +import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; +import { GoogleButton } from "@web/components/oauth/google/GoogleButton"; +import { AuthTabs } from "./components/AuthTabs"; +import { ForgotPasswordForm } from "./forms/ForgotPasswordForm"; +import { SignInForm } from "./forms/SignInForm"; +import { SignUpForm } from "./forms/SignUpForm"; +import { AuthView, useAuthModal } from "./hooks/useAuthModal"; + +/** + * Authentication modal with Sign In, Sign Up, and Forgot Password views + * + * Features: + * - Tab navigation between Sign In and Sign Up + * - Google OAuth integration via existing useGoogleAuth hook + * - Email/password forms with Zod validation + * - Forgot password flow with generic success message + * - Accessible modal with proper ARIA attributes + */ +export const AuthModal: FC = () => { + const { isOpen, currentView, closeModal, setView } = useAuthModal(); + const googleAuth = useGoogleAuth(); + + const handleTabChange = useCallback( + (tab: AuthView) => { + setView(tab); + }, + [setView], + ); + + const handleGoogleSignIn = useCallback(() => { + googleAuth.login(); + closeModal(); + }, [googleAuth, closeModal]); + + const handleSignUp = useCallback((_data: SignUpFormData) => { + // TODO: Implement email/password sign up API call + // For now, this is UI-only - backend integration will be added later + console.log("Sign up submitted:", _data); + }, []); + + const handleSignIn = useCallback((_data: SignInFormData) => { + // TODO: Implement email/password sign in API call + // For now, this is UI-only - backend integration will be added later + console.log("Sign in submitted:", _data); + }, []); + + const handleForgotPassword = useCallback(() => { + setView("forgotPassword"); + }, [setView]); + + const handleBackToSignIn = useCallback(() => { + setView("signIn"); + }, [setView]); + + const handleForgotPasswordSubmit = useCallback((_data: { email: string }) => { + // TODO: Implement forgot password API call + // For now, this is UI-only - backend integration will be added later + console.log("Forgot password submitted:", _data); + }, []); + + if (!isOpen) { + return null; + } + + const showTabs = currentView !== "forgotPassword"; + const title = + currentView === "forgotPassword" ? "Reset Password" : "Welcome to Compass"; + + return ( + +
+ {/* Tabs for Sign In / Sign Up */} + {showTabs && ( + + )} + + {/* Google Sign In */} + {showTabs && ( + <> + + +
+
+ or +
+
+ + )} + + {/* Form based on current view */} + {currentView === "signUp" && } + {currentView === "signIn" && ( + + )} + {currentView === "forgotPassword" && ( + + )} + + {/* Privacy & Terms links */} + {showTabs && ( +

+ By continuing, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + +

+ )} +
+ + ); +}; diff --git a/packages/web/src/components/AuthModal/AuthModalProvider.tsx b/packages/web/src/components/AuthModal/AuthModalProvider.tsx new file mode 100644 index 000000000..b20f1bdf2 --- /dev/null +++ b/packages/web/src/components/AuthModal/AuthModalProvider.tsx @@ -0,0 +1,22 @@ +import { FC, ReactNode } from "react"; +import { AuthModalContext, useAuthModalState } from "./hooks/useAuthModal"; + +interface AuthModalProviderProps { + children: ReactNode; +} + +/** + * Provider for auth modal state + * + * Wrap your app (or relevant subtree) with this provider to enable + * useAuthModal hook functionality throughout the component tree. + */ +export const AuthModalProvider: FC = ({ children }) => { + const value = useAuthModalState(); + + return ( + + {children} + + ); +}; diff --git a/packages/web/src/components/AuthModal/components/AuthButton.tsx b/packages/web/src/components/AuthModal/components/AuthButton.tsx new file mode 100644 index 000000000..1e4428b68 --- /dev/null +++ b/packages/web/src/components/AuthModal/components/AuthButton.tsx @@ -0,0 +1,59 @@ +import clsx from "clsx"; +import { ButtonHTMLAttributes, FC } from "react"; + +interface AuthButtonProps extends ButtonHTMLAttributes { + /** Visual variant of the button */ + variant?: "primary" | "secondary" | "link"; + /** Whether the button is in a loading state */ + isLoading?: boolean; +} + +/** + * Styled button component for auth forms + * + * Variants: + * - primary: Solid accent color background (for CTAs) + * - secondary: Subtle background (for secondary actions) + * - link: Text-only style (for inline links like "Forgot password") + */ +export const AuthButton: FC = ({ + children, + variant = "primary", + isLoading, + disabled, + className, + ...buttonProps +}) => { + const isDisabled = disabled || isLoading; + + return ( + + ); +}; diff --git a/packages/web/src/components/AuthModal/components/AuthInput.tsx b/packages/web/src/components/AuthModal/components/AuthInput.tsx new file mode 100644 index 000000000..27e76cc2a --- /dev/null +++ b/packages/web/src/components/AuthModal/components/AuthInput.tsx @@ -0,0 +1,62 @@ +import clsx from "clsx"; +import { InputHTMLAttributes, forwardRef, useId } from "react"; + +interface AuthInputProps + extends Omit, "className"> { + /** Label text displayed above the input */ + label: string; + /** Error message to display below the input */ + error?: string; + /** Whether the input is in an error state */ + hasError?: boolean; +} + +/** + * Styled input component for auth forms + * + * Features: + * - Accessible label association via generated ID + * - Error message display with proper aria attributes + * - Tailwind styling consistent with app theme + */ +export const AuthInput = forwardRef( + ({ label, error, hasError, ...inputProps }, ref) => { + const generatedId = useId(); + const inputId = inputProps.id || generatedId; + const errorId = `${inputId}-error`; + const showError = hasError && error; + + return ( +
+ + + {showError && ( + + {error} + + )} +
+ ); + }, +); + +AuthInput.displayName = "AuthInput"; diff --git a/packages/web/src/components/AuthModal/components/AuthTabs.tsx b/packages/web/src/components/AuthModal/components/AuthTabs.tsx new file mode 100644 index 000000000..137ef226e --- /dev/null +++ b/packages/web/src/components/AuthModal/components/AuthTabs.tsx @@ -0,0 +1,47 @@ +import clsx from "clsx"; +import { FC } from "react"; +import { AuthView } from "../hooks/useAuthModal"; + +interface AuthTabsProps { + /** Currently active tab */ + activeTab: AuthView; + /** Callback when tab is clicked */ + onTabChange: (tab: AuthView) => void; +} + +/** + * Tab navigation for switching between Sign In and Sign Up views + * + * Uses accessible button pattern with aria-selected + */ +export const AuthTabs: FC = ({ activeTab, onTabChange }) => { + const tabs: { id: AuthView; label: string }[] = [ + { id: "signIn", label: "Sign In" }, + { id: "signUp", label: "Sign Up" }, + ]; + + return ( +
+ {tabs.map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ ); +}; diff --git a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx new file mode 100644 index 000000000..26a8c8f6f --- /dev/null +++ b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx @@ -0,0 +1,126 @@ +import { FC, FormEvent, useCallback, useMemo, useState } from "react"; +import { + ForgotPasswordFormData, + forgotPasswordSchema, +} from "@web/auth/schemas/auth.schemas"; +import { AuthButton } from "../components/AuthButton"; +import { AuthInput } from "../components/AuthInput"; + +interface ForgotPasswordFormProps { + /** Callback when form is submitted with valid data */ + onSubmit: (data: ForgotPasswordFormData) => void; + /** Callback when "Back to sign in" is clicked */ + onBackToSignIn: () => void; + /** Whether form submission is in progress */ + isSubmitting?: boolean; +} + +/** + * Forgot password form with email field + * + * Shows a generic success message after submission to prevent + * email enumeration attacks (always shows success, even if email not found) + */ +export const ForgotPasswordForm: FC = ({ + onSubmit, + onBackToSignIn, + isSubmitting, +}) => { + const [email, setEmail] = useState(""); + const [touched, setTouched] = useState(false); + const [error, setError] = useState(); + const [isSubmitted, setIsSubmitted] = useState(false); + + const validateEmail = useCallback((value: string): string | undefined => { + const result = forgotPasswordSchema.safeParse({ email: value }); + if (!result.success) { + return result.error.errors[0]?.message; + } + return undefined; + }, []); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setEmail(value); + + if (touched) { + setError(validateEmail(value)); + } + }, + [touched, validateEmail], + ); + + const handleBlur = useCallback(() => { + setTouched(true); + setError(validateEmail(email)); + }, [email, validateEmail]); + + const isValid = useMemo(() => { + const result = forgotPasswordSchema.safeParse({ email }); + return result.success; + }, [email]); + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + setTouched(true); + + const result = forgotPasswordSchema.safeParse({ email }); + if (result.success) { + onSubmit(result.data); + setIsSubmitted(true); + } else { + setError(result.error.errors[0]?.message); + } + }, + [email, onSubmit], + ); + + // Show success state after submission + if (isSubmitted) { + return ( +
+
+

Check your email

+

+ If an account exists for {email}, you will receive a password reset + link shortly. +

+
+ + Back to sign in + +
+ ); + } + + return ( +
+

+ Enter your email address and we'll send you a link to reset your + password. +

+ + + + + Send reset link + + + + Back to sign in + + + ); +}; diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.tsx b/packages/web/src/components/AuthModal/forms/SignInForm.tsx new file mode 100644 index 000000000..147c9aa4b --- /dev/null +++ b/packages/web/src/components/AuthModal/forms/SignInForm.tsx @@ -0,0 +1,158 @@ +import { FC, FormEvent, useCallback, useMemo, useState } from "react"; +import { SignInFormData, signInSchema } from "@web/auth/schemas/auth.schemas"; +import { AuthButton } from "../components/AuthButton"; +import { AuthInput } from "../components/AuthInput"; + +interface SignInFormProps { + /** Callback when form is submitted with valid data */ + onSubmit: (data: SignInFormData) => void; + /** Callback when "Forgot password" is clicked */ + onForgotPassword: () => void; + /** Whether form submission is in progress */ + isSubmitting?: boolean; +} + +interface FormState { + email: string; + password: string; +} + +interface FormErrors { + email?: string; + password?: string; +} + +interface TouchedFields { + email: boolean; + password: boolean; +} + +/** + * Sign in form with email and password fields + * + * Includes "Forgot password" link and validates on blur + */ +export const SignInForm: FC = ({ + onSubmit, + onForgotPassword, + isSubmitting, +}) => { + const [formState, setFormState] = useState({ + email: "", + password: "", + }); + + const [touched, setTouched] = useState({ + email: false, + password: false, + }); + + const [errors, setErrors] = useState({}); + + const validateField = useCallback( + (field: keyof FormState, value: string): string | undefined => { + const testData = { ...formState, [field]: value }; + const result = signInSchema.safeParse(testData); + + if (!result.success) { + const fieldError = result.error.errors.find( + (err) => err.path[0] === field, + ); + return fieldError?.message; + } + return undefined; + }, + [formState], + ); + + const handleChange = useCallback( + (field: keyof FormState) => (e: React.ChangeEvent) => { + const value = e.target.value; + setFormState((prev) => ({ ...prev, [field]: value })); + + // Clear error on change if field was touched + if (touched[field]) { + const error = validateField(field, value); + setErrors((prev) => ({ ...prev, [field]: error })); + } + }, + [touched, validateField], + ); + + const handleBlur = useCallback( + (field: keyof FormState) => () => { + setTouched((prev) => ({ ...prev, [field]: true })); + const error = validateField(field, formState[field]); + setErrors((prev) => ({ ...prev, [field]: error })); + }, + [formState, validateField], + ); + + const isValid = useMemo(() => { + const result = signInSchema.safeParse(formState); + return result.success; + }, [formState]); + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + + // Mark all fields as touched + setTouched({ email: true, password: true }); + + const result = signInSchema.safeParse(formState); + if (result.success) { + onSubmit(result.data); + } else { + // Set all errors + const newErrors: FormErrors = {}; + result.error.errors.forEach((err) => { + const field = err.path[0] as keyof FormErrors; + if (!newErrors[field]) { + newErrors[field] = err.message; + } + }); + setErrors(newErrors); + } + }, + [formState, onSubmit], + ); + + return ( +
+ + + + +
+ + Forgot password? + +
+ + + Sign in + + + ); +}; diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx new file mode 100644 index 000000000..2cf8cbfc7 --- /dev/null +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx @@ -0,0 +1,163 @@ +import { FC, FormEvent, useCallback, useMemo, useState } from "react"; +import { SignUpFormData, signUpSchema } from "@web/auth/schemas/auth.schemas"; +import { AuthButton } from "../components/AuthButton"; +import { AuthInput } from "../components/AuthInput"; + +interface SignUpFormProps { + /** Callback when form is submitted with valid data */ + onSubmit: (data: SignUpFormData) => void; + /** Whether form submission is in progress */ + isSubmitting?: boolean; +} + +interface FormState { + name: string; + email: string; + password: string; +} + +interface FormErrors { + name?: string; + email?: string; + password?: string; +} + +interface TouchedFields { + name: boolean; + email: boolean; + password: boolean; +} + +/** + * Sign up form with name, email, and password fields + * + * Validates on blur and enables CTA only when all fields are valid + */ +export const SignUpForm: FC = ({ onSubmit, isSubmitting }) => { + const [formState, setFormState] = useState({ + name: "", + email: "", + password: "", + }); + + const [touched, setTouched] = useState({ + name: false, + email: false, + password: false, + }); + + const [errors, setErrors] = useState({}); + + const validateField = useCallback( + (field: keyof FormState, value: string): string | undefined => { + const testData = { ...formState, [field]: value }; + const result = signUpSchema.safeParse(testData); + + if (!result.success) { + const fieldError = result.error.errors.find( + (err) => err.path[0] === field, + ); + return fieldError?.message; + } + return undefined; + }, + [formState], + ); + + const handleChange = useCallback( + (field: keyof FormState) => (e: React.ChangeEvent) => { + const value = e.target.value; + setFormState((prev) => ({ ...prev, [field]: value })); + + // Clear error on change if field was touched + if (touched[field]) { + const error = validateField(field, value); + setErrors((prev) => ({ ...prev, [field]: error })); + } + }, + [touched, validateField], + ); + + const handleBlur = useCallback( + (field: keyof FormState) => () => { + setTouched((prev) => ({ ...prev, [field]: true })); + const error = validateField(field, formState[field]); + setErrors((prev) => ({ ...prev, [field]: error })); + }, + [formState, validateField], + ); + + const isValid = useMemo(() => { + const result = signUpSchema.safeParse(formState); + return result.success; + }, [formState]); + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + + // Mark all fields as touched + setTouched({ name: true, email: true, password: true }); + + const result = signUpSchema.safeParse(formState); + if (result.success) { + onSubmit(result.data); + } else { + // Set all errors + const newErrors: FormErrors = {}; + result.error.errors.forEach((err) => { + const field = err.path[0] as keyof FormErrors; + if (!newErrors[field]) { + newErrors[field] = err.message; + } + }); + setErrors(newErrors); + } + }, + [formState, onSubmit], + ); + + return ( +
+ + + + + + + + Create account + + + ); +}; diff --git a/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts b/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts new file mode 100644 index 000000000..28f3f2af0 --- /dev/null +++ b/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts @@ -0,0 +1,18 @@ +import { useSearchParams } from "react-router-dom"; + +/** + * Feature flag hook for email/password authentication UI + * + * Checks for the URL parameter `?enableAuth=true` to conditionally + * show the auth modal and related UI elements. + * + * @returns boolean - true if auth feature is enabled via URL param + * + * @example + * // Navigate to /day?enableAuth=true to enable + * const isAuthEnabled = useAuthFeatureFlag(); + */ +export function useAuthFeatureFlag(): boolean { + const [searchParams] = useSearchParams(); + return searchParams.get("enableAuth") === "true"; +} diff --git a/packages/web/src/components/AuthModal/hooks/useAuthModal.ts b/packages/web/src/components/AuthModal/hooks/useAuthModal.ts new file mode 100644 index 000000000..a2813c9af --- /dev/null +++ b/packages/web/src/components/AuthModal/hooks/useAuthModal.ts @@ -0,0 +1,75 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +export type AuthView = "signIn" | "signUp" | "forgotPassword"; + +interface AuthModalContextValue { + isOpen: boolean; + currentView: AuthView; + openModal: (view?: AuthView) => void; + closeModal: () => void; + setView: (view: AuthView) => void; +} + +const defaultContextValue: AuthModalContextValue = { + isOpen: false, + currentView: "signIn", + openModal: () => {}, + closeModal: () => {}, + setView: () => {}, +}; + +export const AuthModalContext = + createContext(defaultContextValue); + +/** + * Hook to access auth modal state and controls + * + * Must be used within an AuthModalProvider + */ +export function useAuthModal(): AuthModalContextValue { + return useContext(AuthModalContext); +} + +/** + * Hook to create auth modal state + * + * Used by AuthModalProvider to create the context value + */ +export function useAuthModalState() { + const [isOpen, setIsOpen] = useState(false); + const [currentView, setCurrentView] = useState("signIn"); + + const openModal = useCallback((view: AuthView = "signIn") => { + setCurrentView(view); + setIsOpen(true); + }, []); + + const closeModal = useCallback(() => { + setIsOpen(false); + // Reset to signIn view after closing + setCurrentView("signIn"); + }, []); + + const setView = useCallback((view: AuthView) => { + setCurrentView(view); + }, []); + + const value = useMemo( + () => ({ + isOpen, + currentView, + openModal, + closeModal, + setView, + }), + [isOpen, currentView, openModal, closeModal, setView], + ); + + return value; +} diff --git a/packages/web/src/components/AuthModal/index.ts b/packages/web/src/components/AuthModal/index.ts new file mode 100644 index 000000000..aedce99f2 --- /dev/null +++ b/packages/web/src/components/AuthModal/index.ts @@ -0,0 +1,5 @@ +export { AccountIcon } from "./AccountIcon"; +export { AuthModal } from "./AuthModal"; +export { AuthModalProvider } from "./AuthModalProvider"; +export { useAuthModal, type AuthView } from "./hooks/useAuthModal"; +export { useAuthFeatureFlag } from "./hooks/useAuthFeatureFlag"; diff --git a/packages/web/src/components/CompassProvider/CompassProvider.tsx b/packages/web/src/components/CompassProvider/CompassProvider.tsx index 81266d501..6714475dc 100644 --- a/packages/web/src/components/CompassProvider/CompassProvider.tsx +++ b/packages/web/src/components/CompassProvider/CompassProvider.tsx @@ -9,6 +9,7 @@ import { ENV_WEB } from "@web/common/constants/env.constants"; import { CompassRefsProvider } from "@web/common/context/compass-refs"; import { PointerPositionProvider } from "@web/common/context/pointer-position"; import { theme } from "@web/common/styles/theme"; +import { AuthModal, AuthModalProvider } from "@web/components/AuthModal"; import { DNDContext } from "@web/components/DND/DNDContext"; import { DNDOverlay } from "@web/components/DND/DNDOverlay"; import { IconProvider } from "@web/components/IconProvider/IconProvider"; @@ -28,7 +29,12 @@ export const CompassRequiredProviders = ( - {props.children} + + + {props.children} + + + = ({ scrollUtil, today, weekProps }) => { +
diff --git a/packages/web/src/views/Day/components/Header/Header.tsx b/packages/web/src/views/Day/components/Header/Header.tsx index 9b4fc97fd..f2837928f 100644 --- a/packages/web/src/views/Day/components/Header/Header.tsx +++ b/packages/web/src/views/Day/components/Header/Header.tsx @@ -1,4 +1,5 @@ import { FC, useCallback, useRef } from "react"; +import { AccountIcon } from "@web/components/AuthModal"; import { AlignItems } from "@web/components/Flex/styled"; import { SelectView } from "@web/components/SelectView/SelectView"; import { Reminder } from "@web/views/Calendar/components/Header/Reminder/Reminder"; @@ -30,6 +31,7 @@ export const Header: FC = ({ showReminder = false }) => { {showReminder && } + ); }; From 5c1ede296fb303f4d4bdd3bff31858156a3c89a5 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 21 Feb 2026 18:47:53 -0800 Subject: [PATCH 02/34] refactor(auth): update authentication modal components and improve test coverage - Renamed `User` icon to `UserIcon` for clarity in `AccountIcon`. - Enhanced `AuthModal` and its forms with consistent button labels and improved comments for future implementation. - Updated test cases to use semantic queries and ensure consistent button labels across tabs. - Refactored `AuthInput` error handling styles for better clarity. - Removed unused `index.ts` file from `AuthModal` directory to streamline imports. - Adjusted import paths in various components to reflect the new structure. --- .../src/components/AuthModal/AccountIcon.tsx | 4 ++-- .../components/AuthModal/AuthModal.test.tsx | 24 ++++++++++--------- .../src/components/AuthModal/AuthModal.tsx | 19 +++++---------- .../AuthModal/components/AuthInput.tsx | 4 ++-- .../AuthModal/forms/ForgotPasswordForm.tsx | 11 +++++++-- .../components/AuthModal/forms/SignInForm.tsx | 13 +++++++--- .../components/AuthModal/forms/SignUpForm.tsx | 11 +++++++-- .../web/src/components/AuthModal/index.ts | 5 ---- .../CompassProvider/CompassProvider.tsx | 3 ++- .../Calendar/components/Header/Header.tsx | 2 +- .../views/Day/components/Header/Header.tsx | 2 +- 11 files changed, 55 insertions(+), 43 deletions(-) delete mode 100644 packages/web/src/components/AuthModal/index.ts diff --git a/packages/web/src/components/AuthModal/AccountIcon.tsx b/packages/web/src/components/AuthModal/AccountIcon.tsx index dbca0255f..4c0238534 100644 --- a/packages/web/src/components/AuthModal/AccountIcon.tsx +++ b/packages/web/src/components/AuthModal/AccountIcon.tsx @@ -1,5 +1,5 @@ import { FC } from "react"; -import { User } from "@phosphor-icons/react"; +import { UserIcon } from "@phosphor-icons/react"; import { useSession } from "@web/auth/hooks/session/useSession"; import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; import { useAuthFeatureFlag } from "./hooks/useAuthFeatureFlag"; @@ -30,7 +30,7 @@ export const AccountIcon: FC = () => { return ( - ({ children, onClick, }: { - children: React.ReactNode; + children: ReactNode; onClick?: () => void; description?: string; }) => ( @@ -67,7 +68,7 @@ const ModalTrigger = () => { }; const renderWithProviders = ( - component: React.ReactElement, + component: ReactElement, initialRoute: string = "/day", ) => { return render( @@ -125,11 +126,11 @@ describe("AuthModal", () => { ).toBeInTheDocument(); }); - // Click on backdrop (the presentation div) - const backdrop = document.querySelector('[role="presentation"]'); + // Click on backdrop using semantic role query + const backdrop = screen.getByRole("presentation"); expect(backdrop).toBeInTheDocument(); - await user.click(backdrop!); + await user.click(backdrop); await waitFor(() => { expect( @@ -151,8 +152,8 @@ describe("AuthModal", () => { }); // Focus the backdrop so it can receive keyboard events - const backdrop = document.querySelector('[role="presentation"]'); - (backdrop as HTMLElement)?.focus(); + const backdrop = screen.getByRole("presentation"); + backdrop.focus(); await user.keyboard("{Escape}"); @@ -238,8 +239,8 @@ describe("AuthModal", () => { await user.click(screen.getByTestId("open-modal")); await waitFor(() => { - // Look for the submit button by type - const submitButton = screen.getByRole("button", { name: /^sign in$/i }); + // Look for the submit button by type - CTA is "Enter" per spec + const submitButton = screen.getByRole("button", { name: /^enter$/i }); expect(submitButton).toBeInTheDocument(); expect(submitButton).toHaveAttribute("type", "submit"); }); @@ -424,7 +425,7 @@ describe("AuthModal", () => { expect(mockGoogleLogin).toHaveBeenCalled(); }); - it("changes button label based on active tab", async () => { + it("keeps consistent button label across tabs", async () => { const user = userEvent.setup(); renderWithProviders(); @@ -438,9 +439,10 @@ describe("AuthModal", () => { await user.click(screen.getByRole("tab", { name: /sign up/i })); + // Google button label stays consistent as "Sign in with Google" per spec await waitFor(() => { expect(screen.getByTestId("google-button")).toHaveTextContent( - /sign up with google/i, + /sign in with google/i, ); }); }); diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index e9015f7a6..a40e02ede 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -36,15 +36,13 @@ export const AuthModal: FC = () => { }, [googleAuth, closeModal]); const handleSignUp = useCallback((_data: SignUpFormData) => { - // TODO: Implement email/password sign up API call + // TODO: Implement email/password sign up API call in Phase 2 // For now, this is UI-only - backend integration will be added later - console.log("Sign up submitted:", _data); }, []); const handleSignIn = useCallback((_data: SignInFormData) => { - // TODO: Implement email/password sign in API call + // TODO: Implement email/password sign in API call in Phase 2 // For now, this is UI-only - backend integration will be added later - console.log("Sign in submitted:", _data); }, []); const handleForgotPassword = useCallback(() => { @@ -56,9 +54,8 @@ export const AuthModal: FC = () => { }, [setView]); const handleForgotPasswordSubmit = useCallback((_data: { email: string }) => { - // TODO: Implement forgot password API call + // TODO: Implement forgot password API call in Phase 2 // For now, this is UI-only - backend integration will be added later - console.log("Forgot password submitted:", _data); }, []); if (!isOpen) { @@ -87,11 +84,7 @@ export const AuthModal: FC = () => { <> @@ -123,7 +116,7 @@ export const AuthModal: FC = () => {

By continuing, you agree to our{" "} { {" "} and{" "} ( "text-text-lighter placeholder:text-text-darkPlaceholder", "focus:border-accent-primary focus:outline-none", { - "border-red-500 focus:border-red-500": showError, + "border-status-error focus:border-status-error": showError, }, )} aria-invalid={showError ? true : undefined} @@ -50,7 +50,7 @@ export const AuthInput = forwardRef( {...inputProps} /> {showError && ( - + {error} )} diff --git a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx index 26a8c8f6f..1119abcbd 100644 --- a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx +++ b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx @@ -1,4 +1,11 @@ -import { FC, FormEvent, useCallback, useMemo, useState } from "react"; +import { + ChangeEvent, + FC, + FormEvent, + useCallback, + useMemo, + useState, +} from "react"; import { ForgotPasswordFormData, forgotPasswordSchema, @@ -40,7 +47,7 @@ export const ForgotPasswordForm: FC = ({ }, []); const handleChange = useCallback( - (e: React.ChangeEvent) => { + (e: ChangeEvent) => { const value = e.target.value; setEmail(value); diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.tsx b/packages/web/src/components/AuthModal/forms/SignInForm.tsx index 147c9aa4b..4705d64dd 100644 --- a/packages/web/src/components/AuthModal/forms/SignInForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignInForm.tsx @@ -1,4 +1,11 @@ -import { FC, FormEvent, useCallback, useMemo, useState } from "react"; +import { + ChangeEvent, + FC, + FormEvent, + useCallback, + useMemo, + useState, +} from "react"; import { SignInFormData, signInSchema } from "@web/auth/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; @@ -66,7 +73,7 @@ export const SignInForm: FC = ({ ); const handleChange = useCallback( - (field: keyof FormState) => (e: React.ChangeEvent) => { + (field: keyof FormState) => (e: ChangeEvent) => { const value = e.target.value; setFormState((prev) => ({ ...prev, [field]: value })); @@ -151,7 +158,7 @@ export const SignInForm: FC = ({

- Sign in + Enter ); diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx index 2cf8cbfc7..56c59b8f4 100644 --- a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx @@ -1,4 +1,11 @@ -import { FC, FormEvent, useCallback, useMemo, useState } from "react"; +import { + ChangeEvent, + FC, + FormEvent, + useCallback, + useMemo, + useState, +} from "react"; import { SignUpFormData, signUpSchema } from "@web/auth/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; @@ -65,7 +72,7 @@ export const SignUpForm: FC = ({ onSubmit, isSubmitting }) => { ); const handleChange = useCallback( - (field: keyof FormState) => (e: React.ChangeEvent) => { + (field: keyof FormState) => (e: ChangeEvent) => { const value = e.target.value; setFormState((prev) => ({ ...prev, [field]: value })); diff --git a/packages/web/src/components/AuthModal/index.ts b/packages/web/src/components/AuthModal/index.ts deleted file mode 100644 index aedce99f2..000000000 --- a/packages/web/src/components/AuthModal/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { AccountIcon } from "./AccountIcon"; -export { AuthModal } from "./AuthModal"; -export { AuthModalProvider } from "./AuthModalProvider"; -export { useAuthModal, type AuthView } from "./hooks/useAuthModal"; -export { useAuthFeatureFlag } from "./hooks/useAuthFeatureFlag"; diff --git a/packages/web/src/components/CompassProvider/CompassProvider.tsx b/packages/web/src/components/CompassProvider/CompassProvider.tsx index 6714475dc..a9fd23f8a 100644 --- a/packages/web/src/components/CompassProvider/CompassProvider.tsx +++ b/packages/web/src/components/CompassProvider/CompassProvider.tsx @@ -9,7 +9,8 @@ import { ENV_WEB } from "@web/common/constants/env.constants"; import { CompassRefsProvider } from "@web/common/context/compass-refs"; import { PointerPositionProvider } from "@web/common/context/pointer-position"; import { theme } from "@web/common/styles/theme"; -import { AuthModal, AuthModalProvider } from "@web/components/AuthModal"; +import { AuthModal } from "@web/components/AuthModal/AuthModal"; +import { AuthModalProvider } from "@web/components/AuthModal/AuthModalProvider"; import { DNDContext } from "@web/components/DND/DNDContext"; import { DNDOverlay } from "@web/components/DND/DNDOverlay"; import { IconProvider } from "@web/components/IconProvider/IconProvider"; diff --git a/packages/web/src/views/Calendar/components/Header/Header.tsx b/packages/web/src/views/Calendar/components/Header/Header.tsx index 648fe6bee..cf6c821c9 100644 --- a/packages/web/src/views/Calendar/components/Header/Header.tsx +++ b/packages/web/src/views/Calendar/components/Header/Header.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import dayjs, { Dayjs } from "@core/util/date/dayjs"; import { theme } from "@web/common/styles/theme"; import { getCalendarHeadingLabel } from "@web/common/utils/datetime/web.date.util"; -import { AccountIcon } from "@web/components/AuthModal"; +import { AccountIcon } from "@web/components/AuthModal/AccountIcon"; import { AlignItems } from "@web/components/Flex/styled"; import { SidebarIcon } from "@web/components/Icons/Sidebar"; import { SelectView } from "@web/components/SelectView/SelectView"; diff --git a/packages/web/src/views/Day/components/Header/Header.tsx b/packages/web/src/views/Day/components/Header/Header.tsx index f2837928f..68f418e40 100644 --- a/packages/web/src/views/Day/components/Header/Header.tsx +++ b/packages/web/src/views/Day/components/Header/Header.tsx @@ -1,5 +1,5 @@ import { FC, useCallback, useRef } from "react"; -import { AccountIcon } from "@web/components/AuthModal"; +import { AccountIcon } from "@web/components/AuthModal/AccountIcon"; import { AlignItems } from "@web/components/Flex/styled"; import { SelectView } from "@web/components/SelectView/SelectView"; import { Reminder } from "@web/views/Calendar/components/Header/Reminder/Reminder"; From 960661e8d293ecc07aa5fae98fd7b3c0f330c638 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 21 Feb 2026 19:01:10 -0800 Subject: [PATCH 03/34] refactor(auth): update AccountIcon component and improve tooltip functionality - Replaced `UserIcon` with `UserCircleDashedIcon` and `UserCircleIcon` for better visual representation based on authentication state. - Enhanced tooltip descriptions to reflect user login status dynamically. - Updated tests to verify correct rendering of icons and tooltip descriptions based on authentication state. - Adjusted test cases to ensure consistent labeling and improved clarity in user interactions. --- .../src/components/AuthModal/AccountIcon.tsx | 29 +++++++++++-------- .../components/AuthModal/AuthModal.test.tsx | 20 ++++++++----- .../Calendar/components/Header/styled.ts | 2 +- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/web/src/components/AuthModal/AccountIcon.tsx b/packages/web/src/components/AuthModal/AccountIcon.tsx index 4c0238534..2035f76b7 100644 --- a/packages/web/src/components/AuthModal/AccountIcon.tsx +++ b/packages/web/src/components/AuthModal/AccountIcon.tsx @@ -1,5 +1,5 @@ import { FC } from "react"; -import { UserIcon } from "@phosphor-icons/react"; +import { UserCircleDashedIcon, UserCircleIcon } from "@phosphor-icons/react"; import { useSession } from "@web/auth/hooks/session/useSession"; import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; import { useAuthFeatureFlag } from "./hooks/useAuthFeatureFlag"; @@ -8,11 +8,6 @@ import { useAuthModal } from "./hooks/useAuthModal"; /** * Account icon button for triggering the auth modal * - * Only renders when: - * - User is not authenticated - * - Feature flag ?enableAuth=true is present in URL - * - * Clicking opens the auth modal with sign-in view */ export const AccountIcon: FC = () => { const { authenticated } = useSession(); @@ -28,13 +23,23 @@ export const AccountIcon: FC = () => { openModal("signIn"); }; + const tipDescription = authenticated ? "You're logged in" : "Log in"; + return ( - - + + {authenticated ? ( + + ) : ( + + )} ); }; diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 385ed5f0c..896d07e65 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -46,12 +46,16 @@ jest.mock("@web/components/Tooltip/TooltipWrapper", () => ({ TooltipWrapper: ({ children, onClick, + description, }: { children: ReactNode; onClick?: () => void; description?: string; }) => ( -
+
+ {description && ( + {description} + )} {children}
), @@ -502,19 +506,21 @@ describe("AccountIcon", () => { renderWithProviders(, "/day?enableAuth=true"); await waitFor(() => { - expect(screen.getByLabelText(/sign in/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/log in/i)).toBeInTheDocument(); }); }); - it("does not render when user is authenticated", () => { + it("shows 'Log in' when user is not authenticated", () => { mockUseSession.mockReturnValue({ - authenticated: true, + authenticated: false, setAuthenticated: jest.fn(), }); renderWithProviders(, "/day?enableAuth=true"); - expect(screen.queryByLabelText(/sign in/i)).not.toBeInTheDocument(); + expect(screen.getByTestId("tooltip-description")).toHaveTextContent( + "Log in", + ); }); it("does not render when feature flag is disabled", () => { @@ -525,7 +531,7 @@ describe("AccountIcon", () => { renderWithProviders(, "/day"); - expect(screen.queryByLabelText(/sign in/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/log in/i)).not.toBeInTheDocument(); }); it("opens modal when clicked", async () => { @@ -538,7 +544,7 @@ describe("AccountIcon", () => { renderWithProviders(, "/day?enableAuth=true"); await waitFor(() => { - expect(screen.getByLabelText(/sign in/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/log in/i)).toBeInTheDocument(); }); await user.click(screen.getByTestId("tooltip-wrapper")); diff --git a/packages/web/src/views/Calendar/components/Header/styled.ts b/packages/web/src/views/Calendar/components/Header/styled.ts index 0a0d5c097..529ddf3fe 100644 --- a/packages/web/src/views/Calendar/components/Header/styled.ts +++ b/packages/web/src/views/Calendar/components/Header/styled.ts @@ -30,7 +30,7 @@ export const StyledLeftGroup = styled(Flex)` export const StyledRightGroup = styled(Flex)` align-items: center; - flex-direction: column; + flex-direction: row; justify-content: space-between; height: 100%; z-index: ${ZIndex.LAYER_2}; From ddd608cfab4270ee5a4fc137566d971adec27d22 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 21 Feb 2026 19:10:42 -0800 Subject: [PATCH 04/34] refactor(auth): enhance AuthModal tests and improve semantic accessibility - Updated test cases in `AuthModal.test.tsx` to utilize semantic queries for button interactions, replacing `data-testid` with `role` and `name` attributes for better accessibility. - Refactored mock components for `GoogleButton` and `TooltipWrapper` to align with semantic best practices, improving test clarity and maintainability. - Adjusted `AuthModal.tsx` to include a `DotIcon` for improved visual separation in the privacy and terms section, enhancing user experience. --- .../components/AuthModal/AuthModal.test.tsx | 101 +++++++++--------- .../src/components/AuthModal/AuthModal.tsx | 14 +-- 2 files changed, 56 insertions(+), 59 deletions(-) diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 896d07e65..370a6928b 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -26,7 +26,7 @@ jest.mock("@web/auth/hooks/oauth/useGoogleAuth", () => ({ }), })); -// Mock GoogleButton +// Mock GoogleButton - uses button with label for semantic queries (matches real component's aria-label) jest.mock("@web/components/oauth/google/GoogleButton", () => ({ GoogleButton: ({ onClick, @@ -35,13 +35,13 @@ jest.mock("@web/components/oauth/google/GoogleButton", () => ({ onClick: () => void; label: string; }) => ( - ), })); -// Mock TooltipWrapper +// Mock TooltipWrapper - no data-testid; use semantic queries (role/name/text) jest.mock("@web/components/Tooltip/TooltipWrapper", () => ({ TooltipWrapper: ({ children, @@ -52,10 +52,8 @@ jest.mock("@web/components/Tooltip/TooltipWrapper", () => ({ onClick?: () => void; description?: string; }) => ( -
- {description && ( - {description} - )} +
+ {description && {description}} {children}
), @@ -64,11 +62,7 @@ jest.mock("@web/components/Tooltip/TooltipWrapper", () => ({ // Helper component to trigger modal open const ModalTrigger = () => { const { openModal } = useAuthModal(); - return ( - - ); + return ; }; const renderWithProviders = ( @@ -109,7 +103,7 @@ describe("AuthModal", () => { screen.queryByRole("heading", { name: /welcome to compass/i }), ).not.toBeInTheDocument(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { expect( @@ -122,7 +116,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { expect( @@ -147,7 +141,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { expect( @@ -174,7 +168,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { const signInTab = screen.getByRole("tab", { name: /sign in/i }); @@ -186,7 +180,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { expect( @@ -206,7 +200,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); // Sign In tab - no Name field await waitFor(() => { @@ -228,7 +222,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); @@ -240,7 +234,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { // Look for the submit button by type - CTA is "Enter" per spec @@ -254,7 +248,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); @@ -274,7 +268,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { expect( @@ -299,7 +293,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await user.click(screen.getByRole("tab", { name: /sign up/i })); await waitFor(() => { @@ -313,7 +307,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await user.click(screen.getByRole("tab", { name: /sign up/i })); await waitFor(() => { @@ -336,7 +330,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await user.click( screen.getByRole("button", { name: /forgot password/i }), ); @@ -353,7 +347,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await user.click( screen.getByRole("button", { name: /forgot password/i }), ); @@ -376,7 +370,7 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await user.click( screen.getByRole("button", { name: /forgot password/i }), ); @@ -404,13 +398,14 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { - expect(screen.getByTestId("google-button")).toBeInTheDocument(); - expect(screen.getByTestId("google-button")).toHaveTextContent( - /sign in with google/i, - ); + const googleButton = screen.getByRole("button", { + name: /sign in with google/i, + }); + expect(googleButton).toBeInTheDocument(); + expect(googleButton).toHaveTextContent(/sign in with google/i); }); }); @@ -418,13 +413,17 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { - expect(screen.getByTestId("google-button")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /sign in with google/i }), + ).toBeInTheDocument(); }); - await user.click(screen.getByTestId("google-button")); + await user.click( + screen.getByRole("button", { name: /sign in with google/i }), + ); expect(mockGoogleLogin).toHaveBeenCalled(); }); @@ -433,21 +432,21 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { - expect(screen.getByTestId("google-button")).toHaveTextContent( - /sign in with google/i, - ); + expect( + screen.getByRole("button", { name: /sign in with google/i }), + ).toHaveTextContent(/sign in with google/i); }); await user.click(screen.getByRole("tab", { name: /sign up/i })); // Google button label stays consistent as "Sign in with Google" per spec await waitFor(() => { - expect(screen.getByTestId("google-button")).toHaveTextContent( - /sign in with google/i, - ); + expect( + screen.getByRole("button", { name: /sign in with google/i }), + ).toHaveTextContent(/sign in with google/i); }); }); }); @@ -457,14 +456,14 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { expect( - screen.getByRole("link", { name: /terms of service/i }), + screen.getByRole("link", { name: /terms/i }), ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: /privacy policy/i }), + screen.getByRole("link", { name: /privacy/i }), ).toBeInTheDocument(); }); }); @@ -473,14 +472,14 @@ describe("AuthModal", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("open-modal")); + await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { const termsLink = screen.getByRole("link", { - name: /terms of service/i, + name: /terms/i, }); const privacyLink = screen.getByRole("link", { - name: /privacy policy/i, + name: /privacy/i, }); expect(termsLink).toHaveAttribute("target", "_blank"); @@ -518,9 +517,7 @@ describe("AccountIcon", () => { renderWithProviders(, "/day?enableAuth=true"); - expect(screen.getByTestId("tooltip-description")).toHaveTextContent( - "Log in", - ); + expect(screen.getByText("Log in")).toBeInTheDocument(); }); it("does not render when feature flag is disabled", () => { @@ -547,7 +544,7 @@ describe("AccountIcon", () => { expect(screen.getByLabelText(/log in/i)).toBeInTheDocument(); }); - await user.click(screen.getByTestId("tooltip-wrapper")); + await user.click(screen.getByLabelText(/log in/i)); await waitFor(() => { expect( diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index a40e02ede..47e9008c5 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -1,4 +1,5 @@ import { FC, useCallback } from "react"; +import { DotIcon } from "@phosphor-icons/react"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; import { SignInFormData, SignUpFormData } from "@web/auth/schemas/auth.schemas"; import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; @@ -113,26 +114,25 @@ export const AuthModal: FC = () => { {/* Privacy & Terms links */} {showTabs && ( -

- By continuing, you agree to our{" "} +

)}
From dd4beac37388f530cc8b62bb854c1bd2934b51d3 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 21 Feb 2026 19:25:08 -0800 Subject: [PATCH 05/34] feat(oauth): implement custom Google button with SVG logo and enhanced styling - Replaced the existing GoogleButtonBase with a custom button component that includes a monochrome Google "G" logo. - Added styling to align with Google design guidelines, including padding, border radius, and hover effects. - Improved accessibility by ensuring proper aria-label usage and button interaction handling. - Enhanced the button's visual appearance and user experience through refined layout and interaction feedback. --- .../components/oauth/google/GoogleButton.tsx | 75 +++++++++++++++++-- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/packages/web/src/components/oauth/google/GoogleButton.tsx b/packages/web/src/components/oauth/google/GoogleButton.tsx index a90802251..7b0f9aa52 100644 --- a/packages/web/src/components/oauth/google/GoogleButton.tsx +++ b/packages/web/src/components/oauth/google/GoogleButton.tsx @@ -1,5 +1,36 @@ import React from "react"; -import GoogleButtonBase from "react-google-button"; + +/** + * Monochrome Google "G" logo SVG + * Based on the official Google "G" but rendered in black for monochromatic design + */ +const GoogleGLogo = ({ size = 18 }: { size?: number }) => ( + +); export const GoogleButton = ({ onClick, @@ -13,13 +44,43 @@ export const GoogleButton = ({ style?: React.CSSProperties; }) => { return ( - + aria-label={label} + style={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: "10px", // Google guideline: 10px between icon and text + height: "40px", + paddingLeft: "12px", // Google guideline: 12px left padding + paddingRight: "12px", // Google guideline: 12px right padding + backgroundColor: "#ffffff", + border: "1px solid #1f1f1f", + borderRadius: "9999px", // Pill shape + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.6 : 1, + fontFamily: "'Roboto', sans-serif", + fontSize: "14px", + fontWeight: 500, + color: "#1f1f1f", + whiteSpace: "nowrap", + transition: "background-color 0.2s ease", + ...style, + }} + onMouseEnter={(e) => { + if (!disabled) { + e.currentTarget.style.backgroundColor = "#f8f8f8"; + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = "#ffffff"; + }} + > + + {label} + ); }; From e154fdb522ba5ebb6565dc7b93fc209e1083f570 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 21 Feb 2026 19:27:06 -0800 Subject: [PATCH 06/34] refactor(auth): reorganize Google Sign In button placement in AuthModal - Moved the Google Sign In button to a more visually distinct position within the AuthModal component for improved user experience. - Updated button styling to maintain consistency with the overall design. - Cleaned up the code by removing redundant elements and ensuring better readability. --- .../src/components/AuthModal/AuthModal.tsx | 34 ++++++++----------- .../AuthModal/components/AuthButton.tsx | 2 +- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index 47e9008c5..8c53d24b6 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -79,24 +79,6 @@ export const AuthModal: FC = () => { {showTabs && ( )} - - {/* Google Sign In */} - {showTabs && ( - <> - - -
-
- or -
-
- - )} - {/* Form based on current view */} {currentView === "signUp" && } {currentView === "signIn" && ( @@ -111,7 +93,21 @@ export const AuthModal: FC = () => { onBackToSignIn={handleBackToSignIn} /> )} - + {/* Google Sign In */} + {showTabs && ( + <> +
+
+ or +
+
+ + + )} {/* Privacy & Terms links */} {showTabs && (
diff --git a/packages/web/src/components/AuthModal/components/AuthButton.tsx b/packages/web/src/components/AuthModal/components/AuthButton.tsx index 1e4428b68..764350853 100644 --- a/packages/web/src/components/AuthModal/components/AuthButton.tsx +++ b/packages/web/src/components/AuthModal/components/AuthButton.tsx @@ -30,7 +30,7 @@ export const AuthButton: FC = ({ diff --git a/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx b/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx index 77161cf64..81fea7403 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx @@ -8,6 +8,7 @@ import { import { useObservable } from "@ngneat/use-observable"; import { Priorities } from "@core/constants/core.constants"; import { darken, isDark } from "@core/util/color.utils"; +import { ZIndex } from "@web/common/constants/web.constants"; import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; import { CursorItem, @@ -61,7 +62,7 @@ export function AgendaEventPreview({ role="dialog" aria-labelledby="event-title" aria-describedby={event?.description ? "event-description" : undefined} - className="z-50 max-w-80 min-w-64 rounded-lg p-4 shadow-lg" + className={`z-${ZIndex.LAYER_5} max-w-80 min-w-64 rounded-lg p-4 shadow-lg`} style={{ ...floating.context.floatingStyles, backgroundColor: darkPriorityColor, diff --git a/packages/web/src/views/Day/components/ContextMenu/BaseContextMenu.tsx b/packages/web/src/views/Day/components/ContextMenu/BaseContextMenu.tsx index 1d76d09c9..7b44fc708 100644 --- a/packages/web/src/views/Day/components/ContextMenu/BaseContextMenu.tsx +++ b/packages/web/src/views/Day/components/ContextMenu/BaseContextMenu.tsx @@ -12,6 +12,7 @@ import { useInteractions, useRole, } from "@floating-ui/react"; +import { ZIndex } from "@web/common/constants/web.constants"; interface BaseContextMenuProps { onOutsideClick: () => void; @@ -43,9 +44,13 @@ export const BaseContextMenu = forwardRef( return createElement("ul", { ...getFloatingProps(props), className: classNames( - "bg-bg-secondary absolute z-[1000] min-w-[160px] list-none rounded", + "bg-bg-secondary absolute min-w-[160px] list-none rounded", "border border-gray-600 shadow-md", ), + style: { + ...props.style, + zIndex: ZIndex.LAYER_5, + }, ref, }); }, diff --git a/packages/web/src/views/Day/components/Shortcuts/ShortcutTip.tsx b/packages/web/src/views/Day/components/Shortcuts/ShortcutTip.tsx index aa845f33e..d50be03e0 100644 --- a/packages/web/src/views/Day/components/Shortcuts/ShortcutTip.tsx +++ b/packages/web/src/views/Day/components/Shortcuts/ShortcutTip.tsx @@ -1,11 +1,9 @@ -import { ReactNode, useState } from "react"; - interface ShortcutProps { shortcut: string | string[]; "aria-label"?: string; } -export const Shortcut = ({ +export const ShortcutTip = ({ shortcut, "aria-label": ariaLabel, }: ShortcutProps) => { @@ -22,40 +20,3 @@ export const Shortcut = ({ ); }; - -interface ShortcutTipProps { - shortcut: string | string[]; - children?: ReactNode; - "aria-label"?: string; -} - -export const ShortcutTip = ({ - shortcut, - children, - "aria-label": ariaLabel, -}: ShortcutTipProps) => { - const [isHovered, setIsHovered] = useState(false); - - if (children) { - return ( -
-
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {children} -
- {isHovered && ( -
-
- -
-
-
- )} -
- ); - } - - return ; -}; diff --git a/packages/web/src/views/Day/components/StorageInfoModal/StorageInfoModal.tsx b/packages/web/src/views/Day/components/StorageInfoModal/StorageInfoModal.tsx index 8bc713169..92c7fc0b1 100644 --- a/packages/web/src/views/Day/components/StorageInfoModal/StorageInfoModal.tsx +++ b/packages/web/src/views/Day/components/StorageInfoModal/StorageInfoModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { ZIndex } from "@web/common/constants/web.constants"; import { theme } from "@web/common/styles/theme"; import { InfoIcon } from "@web/components/Icons/Info"; import { StyledXIcon } from "@web/components/Icons/X"; @@ -97,13 +98,19 @@ export const StorageInfoModal = ({ return (
+ ) : undefined; + return ( - +
- {/* Tabs for Sign In / Sign Up */} - {showTabs && ( - - )} {/* Form based on current view */} {currentView === "signUp" && ( @@ -107,42 +115,38 @@ export const AuthModal: FC = () => { /> )} {/* Google Sign In */} - {showTabs && ( - <> -
-
- or -
-
- - - )} - {/* Privacy & Terms links */} - {showTabs && ( -
- - Terms - - - - Privacy - + <> +
+
+ or +
- )} + + + {/* Privacy & Terms links */} +
); diff --git a/packages/web/src/components/AuthModal/components/AuthTabs.tsx b/packages/web/src/components/AuthModal/components/AuthTabs.tsx deleted file mode 100644 index 0e642ace4..000000000 --- a/packages/web/src/components/AuthModal/components/AuthTabs.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import clsx from "clsx"; -import { FC } from "react"; -import { AuthView } from "../hooks/useAuthModal"; - -interface AuthTabsProps { - /** Currently active tab */ - activeTab: AuthView; - /** Callback when tab is clicked */ - onTabChange: (tab: AuthView) => void; -} - -/** - * Tab navigation for switching between Sign In and Sign Up views - * - * Uses accessible button pattern with aria-selected - */ -export const AuthTabs: FC = ({ activeTab, onTabChange }) => { - const tabs: { id: AuthView; label: string }[] = [ - { id: "signIn", label: "Sign In" }, - { id: "signUp", label: "Sign Up" }, - ]; - - return ( -
- {tabs.map((tab) => { - const isActive = activeTab === tab.id; - return ( - - ); - })} -
- ); -}; diff --git a/packages/web/src/components/OverlayPanel/OverlayPanel.tsx b/packages/web/src/components/OverlayPanel/OverlayPanel.tsx index 197aff572..c19d029a5 100644 --- a/packages/web/src/components/OverlayPanel/OverlayPanel.tsx +++ b/packages/web/src/components/OverlayPanel/OverlayPanel.tsx @@ -6,6 +6,8 @@ interface Props { icon?: ReactNode; /** Main title text */ title?: string; + /** Optional content rendered on the same row as the title (e.g. a switch link) */ + titleAction?: ReactNode; /** Description/message text */ message?: string; /** Additional content (buttons, etc.) */ @@ -21,6 +23,7 @@ interface Props { export const OverlayPanel = ({ icon, title, + titleAction, message, children, onDismiss, @@ -74,12 +77,16 @@ export const OverlayPanel = ({ aria-busy={role === "status" ? true : undefined} > {icon} - {title && - (variant === "modal" ? ( -

{title}

- ) : ( -
{title}
- ))} + {title && ( +
+ {variant === "modal" ? ( +

{title}

+ ) : ( +
{title}
+ )} + {titleAction} +
+ )} {message &&

{message}

} {children}
From bc43648758ef52e53f9f5a505b28250f167c1fe0 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 10:21:04 -0800 Subject: [PATCH 15/34] refactor(auth): improve error handling in Auth forms for better user experience - Updated ForgotPasswordForm, SignInForm, and SignUpForm to clear validation errors when the user types, enhancing real-time feedback. - Modified error display logic to only show validation errors on blur for fields with input, preventing unnecessary error messages for empty required fields. - Streamlined dependencies in useCallback hooks for better performance and clarity. --- .../AuthModal/forms/ForgotPasswordForm.tsx | 5 +++-- .../components/AuthModal/forms/SignInForm.tsx | 18 ++++++++++++------ .../components/AuthModal/forms/SignUpForm.tsx | 18 ++++++++++++------ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx index 76ddb9d7d..b372d97a3 100644 --- a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx +++ b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx @@ -51,11 +51,12 @@ export const ForgotPasswordForm: FC = ({ const value = e.target.value; setEmail(value); + // Clear error when user types - only show validation errors after blur if (touched) { - setError(validateEmail(value)); + setError(undefined); } }, - [touched, validateEmail], + [touched], ); const handleBlur = useCallback(() => { diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.tsx b/packages/web/src/components/AuthModal/forms/SignInForm.tsx index 98d025778..a83b34237 100644 --- a/packages/web/src/components/AuthModal/forms/SignInForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignInForm.tsx @@ -77,20 +77,26 @@ export const SignInForm: FC = ({ const value = e.target.value; setFormState((prev) => ({ ...prev, [field]: value })); - // Clear error on change if field was touched + // Clear error when user types - only show validation errors after blur if (touched[field]) { - const error = validateField(field, value); - setErrors((prev) => ({ ...prev, [field]: error })); + setErrors((prev) => ({ ...prev, [field]: undefined })); } }, - [touched, validateField], + [touched], ); const handleBlur = useCallback( (field: keyof FormState) => () => { setTouched((prev) => ({ ...prev, [field]: true })); - const error = validateField(field, formState[field]); - setErrors((prev) => ({ ...prev, [field]: error })); + const value = formState[field]; + // Only show errors on blur when user has entered something; empty required + // fields don't need an error - the disabled button already conveys that + if (value.trim() !== "") { + const error = validateField(field, value); + setErrors((prev) => ({ ...prev, [field]: error })); + } else { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } }, [formState, validateField], ); diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx index 98100cd1b..227ea8df7 100644 --- a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx @@ -86,20 +86,26 @@ export const SignUpForm: FC = ({ onNameChange?.(value); } - // Clear error on change if field was touched + // Clear error when user types - only show validation errors after blur if (touched[field]) { - const error = validateField(field, value); - setErrors((prev) => ({ ...prev, [field]: error })); + setErrors((prev) => ({ ...prev, [field]: undefined })); } }, - [touched, validateField, onNameChange], + [touched, onNameChange], ); const handleBlur = useCallback( (field: keyof FormState) => () => { setTouched((prev) => ({ ...prev, [field]: true })); - const error = validateField(field, formState[field]); - setErrors((prev) => ({ ...prev, [field]: error })); + const value = formState[field]; + // Only show errors on blur when user has entered something; empty required + // fields don't need an error - the disabled button already conveys that + if (value.trim() !== "") { + const error = validateField(field, value); + setErrors((prev) => ({ ...prev, [field]: error })); + } else { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } }, [formState, validateField], ); From 91e734c96ef480632739ac61ddec529e78467b77 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 10:28:13 -0800 Subject: [PATCH 16/34] feat(auth): implement useZodForm hook for streamlined form validation in Auth components - Introduced the useZodForm hook to manage form state and validation using Zod, enhancing the handling of form inputs in ForgotPasswordForm, SignInForm, and SignUpForm. - Updated forms to utilize the new hook, simplifying validation logic and improving user experience by showing errors only on blur and clearing them during input. - Added comprehensive tests for useZodForm to ensure correct validation behavior and form state management. --- .../forms/ForgotPasswordForm.test.tsx | 187 +++++++++++++++++ .../AuthModal/forms/ForgotPasswordForm.tsx | 88 ++------ .../AuthModal/forms/SignInForm.test.tsx | 163 +++++++++++++++ .../components/AuthModal/forms/SignInForm.tsx | 142 ++----------- .../AuthModal/forms/SignUpForm.test.tsx | 195 ++++++++++++++++++ .../components/AuthModal/forms/SignUpForm.tsx | 163 +++------------ .../AuthModal/hooks/useZodForm.test.tsx | 115 +++++++++++ .../components/AuthModal/hooks/useZodForm.ts | 133 ++++++++++++ 8 files changed, 869 insertions(+), 317 deletions(-) create mode 100644 packages/web/src/components/AuthModal/forms/ForgotPasswordForm.test.tsx create mode 100644 packages/web/src/components/AuthModal/forms/SignInForm.test.tsx create mode 100644 packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx create mode 100644 packages/web/src/components/AuthModal/hooks/useZodForm.test.tsx create mode 100644 packages/web/src/components/AuthModal/hooks/useZodForm.ts diff --git a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.test.tsx b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.test.tsx new file mode 100644 index 000000000..89cb981b6 --- /dev/null +++ b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.test.tsx @@ -0,0 +1,187 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ForgotPasswordForm } from "./ForgotPasswordForm"; + +const mockOnSubmit = jest.fn(); +const mockOnBackToSignIn = jest.fn(); + +const renderForgotPasswordForm = () => { + render( + , + ); +}; + +describe("ForgotPasswordForm", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("blur-only validation", () => { + it("does not show email error while user is typing", async () => { + const user = userEvent.setup(); + renderForgotPasswordForm(); + + const emailInput = screen.getByLabelText(/email/i); + await user.click(emailInput); + await user.type(emailInput, "invalid"); + + expect( + screen.queryByText(/please enter a valid email address/i), + ).not.toBeInTheDocument(); + }); + + it("shows email error only after blur", async () => { + const user = userEvent.setup(); + renderForgotPasswordForm(); + + await user.type(screen.getByLabelText(/email/i), "invalid-email"); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); + }); + }); + + it("clears email error when user types after blur", async () => { + const user = userEvent.setup(); + renderForgotPasswordForm(); + + const emailInput = screen.getByLabelText(/email/i); + await user.type(emailInput, "invalid"); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); + }); + + await user.click(emailInput); + await user.type(emailInput, "@example.com"); + + await waitFor(() => { + expect( + screen.queryByText(/please enter a valid email address/i), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("submit validation", () => { + it("shows email error when submitting empty form", async () => { + renderForgotPasswordForm(); + + const form = screen.getByLabelText(/email/i).closest("form"); + if (form) fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByText(/email is required/i)).toBeInTheDocument(); + }); + }); + + it("shows email format error when submitting invalid email", async () => { + const user = userEvent.setup(); + renderForgotPasswordForm(); + + await user.type(screen.getByLabelText(/email/i), "not-an-email"); + await user.click( + screen.getByRole("button", { name: /send reset link/i }), + ); + + await waitFor(() => { + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); + }); + }); + + it("does not call onSubmit when form is invalid", () => { + renderForgotPasswordForm(); + + const form = screen.getByLabelText(/email/i).closest("form"); + if (form) fireEvent.submit(form); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + it("calls onSubmit and shows success message when form is valid", async () => { + const user = userEvent.setup(); + renderForgotPasswordForm(); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.click( + screen.getByRole("button", { name: /send reset link/i }), + ); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith({ + email: "test@example.com", + }); + }); + + await waitFor(() => { + expect(screen.getByText(/check your email/i)).toBeInTheDocument(); + }); + }); + }); + + describe("submit button state", () => { + it("disables submit when email is empty", async () => { + renderForgotPasswordForm(); + + const submitButton = screen.getByRole("button", { + name: /send reset link/i, + }); + expect(submitButton).toBeDisabled(); + }); + + it("enables submit when email is valid", async () => { + const user = userEvent.setup(); + renderForgotPasswordForm(); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + + const submitButton = screen.getByRole("button", { + name: /send reset link/i, + }); + expect(submitButton).not.toBeDisabled(); + }); + }); + + describe("success state", () => { + it("shows submitted email in success message", async () => { + const user = userEvent.setup(); + renderForgotPasswordForm(); + + await user.type(screen.getByLabelText(/email/i), "user@example.com"); + await user.click( + screen.getByRole("button", { name: /send reset link/i }), + ); + + await waitFor(() => { + expect( + screen.getByText(/if an account exists for user@example.com/i), + ).toBeInTheDocument(); + }); + }); + }); + + describe("back to sign in", () => { + it("calls onBackToSignIn when back to sign in is clicked", async () => { + const user = userEvent.setup(); + renderForgotPasswordForm(); + + await user.click( + screen.getByRole("button", { name: /back to sign in/i }), + ); + + expect(mockOnBackToSignIn).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx index b372d97a3..8cffec555 100644 --- a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx +++ b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx @@ -1,17 +1,11 @@ -import { - ChangeEvent, - FC, - FormEvent, - useCallback, - useMemo, - useState, -} from "react"; +import { FC, useState } from "react"; import { ForgotPasswordFormData, forgotPasswordSchema, } from "@web/auth/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; +import { useZodForm } from "../hooks/useZodForm"; interface ForgotPasswordFormProps { /** Callback when form is submitted with valid data */ @@ -33,67 +27,25 @@ export const ForgotPasswordForm: FC = ({ onBackToSignIn, isSubmitting, }) => { - const [email, setEmail] = useState(""); - const [touched, setTouched] = useState(false); - const [error, setError] = useState(); const [isSubmitted, setIsSubmitted] = useState(false); - const validateEmail = useCallback((value: string): string | undefined => { - const result = forgotPasswordSchema.safeParse({ email: value }); - if (!result.success) { - return result.error.errors[0]?.message; - } - return undefined; - }, []); - - const handleChange = useCallback( - (e: ChangeEvent) => { - const value = e.target.value; - setEmail(value); - - // Clear error when user types - only show validation errors after blur - if (touched) { - setError(undefined); - } - }, - [touched], - ); - - const handleBlur = useCallback(() => { - setTouched(true); - setError(validateEmail(email)); - }, [email, validateEmail]); - - const isValid = useMemo(() => { - const result = forgotPasswordSchema.safeParse({ email }); - return result.success; - }, [email]); - - const handleSubmit = useCallback( - (e: FormEvent) => { - e.preventDefault(); - setTouched(true); - - const result = forgotPasswordSchema.safeParse({ email }); - if (result.success) { - onSubmit(result.data); - setIsSubmitted(true); - } else { - setError(result.error.errors[0]?.message); - } + const form = useZodForm({ + schema: forgotPasswordSchema, + initialValues: { email: "" }, + onSubmit: (data) => { + onSubmit(data); + setIsSubmitted(true); }, - [email, onSubmit], - ); + }); - // Show success state after submission if (isSubmitted) { return (

Check your email

- If an account exists for {email}, you will receive a password reset - link shortly. + If an account exists for {form.values.email}, you will receive a + password reset link shortly.

@@ -104,7 +56,7 @@ export const ForgotPasswordForm: FC = ({ } return ( - +

Enter your email address and we'll send you a link to reset your password. @@ -114,15 +66,19 @@ export const ForgotPasswordForm: FC = ({ type="email" placeholder="Email" ariaLabel="Email" - value={email} - onChange={handleChange} - onBlur={handleBlur} - error={error} - hasError={touched && !!error} + value={form.values.email} + onChange={form.handleChange("email")} + onBlur={form.handleBlur("email")} + error={form.errors.email} + hasError={!!form.touched.email && !!form.errors.email} autoComplete="email" /> - + Send reset link diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx b/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx new file mode 100644 index 000000000..f1692e329 --- /dev/null +++ b/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx @@ -0,0 +1,163 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SignInForm } from "./SignInForm"; + +const mockOnSubmit = jest.fn(); +const mockOnForgotPassword = jest.fn(); + +const renderSignInForm = () => { + render( + , + ); +}; + +describe("SignInForm", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("blur-only validation", () => { + it("does not show email error while user is typing", async () => { + const user = userEvent.setup(); + renderSignInForm(); + + const emailInput = screen.getByLabelText(/email/i); + await user.click(emailInput); + await user.type(emailInput, "invalid"); + + expect( + screen.queryByText(/please enter a valid email address/i), + ).not.toBeInTheDocument(); + }); + + it("shows email error only after blur", async () => { + const user = userEvent.setup(); + renderSignInForm(); + + await user.type(screen.getByLabelText(/email/i), "invalid-email"); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); + }); + }); + + it("clears email error when user types after blur", async () => { + const user = userEvent.setup(); + renderSignInForm(); + + const emailInput = screen.getByLabelText(/email/i); + await user.type(emailInput, "invalid"); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); + }); + + await user.click(emailInput); + await user.type(emailInput, "@example.com"); + + await waitFor(() => { + expect( + screen.queryByText(/please enter a valid email address/i), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("submit validation", () => { + it("shows all field errors when submitting invalid form", async () => { + renderSignInForm(); + + const form = screen.getByLabelText(/email/i).closest("form"); + if (form) fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByText(/email is required/i)).toBeInTheDocument(); + expect(screen.getByText(/password is required/i)).toBeInTheDocument(); + }); + }); + + it("does not call onSubmit when form is invalid", () => { + renderSignInForm(); + + const form = screen.getByLabelText(/email/i).closest("form"); + if (form) fireEvent.submit(form); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + it("calls onSubmit with valid data when form is valid", async () => { + const user = userEvent.setup(); + renderSignInForm(); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /^enter$/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + }); + }); + }); + + it("normalizes email to lowercase on submit", async () => { + const user = userEvent.setup(); + renderSignInForm(); + + await user.type(screen.getByLabelText(/email/i), "Test@Example.COM"); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /^enter$/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + }); + }); + }); + }); + + describe("submit button state", () => { + it("disables submit when form is invalid", async () => { + renderSignInForm(); + + const submitButton = screen.getByRole("button", { name: /^enter$/i }); + expect(submitButton).toBeDisabled(); + }); + + it("enables submit when all fields are valid", async () => { + const user = userEvent.setup(); + renderSignInForm(); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + + const submitButton = screen.getByRole("button", { name: /^enter$/i }); + expect(submitButton).not.toBeDisabled(); + }); + }); + + describe("forgot password link", () => { + it("calls onForgotPassword when forgot password link is clicked", async () => { + const user = userEvent.setup(); + renderSignInForm(); + + await user.click( + screen.getByRole("button", { name: /forgot password/i }), + ); + + expect(mockOnForgotPassword).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.tsx b/packages/web/src/components/AuthModal/forms/SignInForm.tsx index a83b34237..40351d70b 100644 --- a/packages/web/src/components/AuthModal/forms/SignInForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignInForm.tsx @@ -1,14 +1,8 @@ -import { - ChangeEvent, - FC, - FormEvent, - useCallback, - useMemo, - useState, -} from "react"; +import { FC } from "react"; import { SignInFormData, signInSchema } from "@web/auth/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; +import { useZodForm } from "../hooks/useZodForm"; interface SignInFormProps { /** Callback when form is submitted with valid data */ @@ -19,21 +13,6 @@ interface SignInFormProps { isSubmitting?: boolean; } -interface FormState { - email: string; - password: string; -} - -interface FormErrors { - email?: string; - password?: string; -} - -interface TouchedFields { - email: boolean; - password: boolean; -} - /** * Sign in form with email and password fields * @@ -44,104 +23,23 @@ export const SignInForm: FC = ({ onForgotPassword, isSubmitting, }) => { - const [formState, setFormState] = useState({ - email: "", - password: "", + const form = useZodForm({ + schema: signInSchema, + initialValues: { email: "", password: "" }, + onSubmit, }); - const [touched, setTouched] = useState({ - email: false, - password: false, - }); - - const [errors, setErrors] = useState({}); - - const validateField = useCallback( - (field: keyof FormState, value: string): string | undefined => { - const testData = { ...formState, [field]: value }; - const result = signInSchema.safeParse(testData); - - if (!result.success) { - const fieldError = result.error.errors.find( - (err) => err.path[0] === field, - ); - return fieldError?.message; - } - return undefined; - }, - [formState], - ); - - const handleChange = useCallback( - (field: keyof FormState) => (e: ChangeEvent) => { - const value = e.target.value; - setFormState((prev) => ({ ...prev, [field]: value })); - - // Clear error when user types - only show validation errors after blur - if (touched[field]) { - setErrors((prev) => ({ ...prev, [field]: undefined })); - } - }, - [touched], - ); - - const handleBlur = useCallback( - (field: keyof FormState) => () => { - setTouched((prev) => ({ ...prev, [field]: true })); - const value = formState[field]; - // Only show errors on blur when user has entered something; empty required - // fields don't need an error - the disabled button already conveys that - if (value.trim() !== "") { - const error = validateField(field, value); - setErrors((prev) => ({ ...prev, [field]: error })); - } else { - setErrors((prev) => ({ ...prev, [field]: undefined })); - } - }, - [formState, validateField], - ); - - const isValid = useMemo(() => { - const result = signInSchema.safeParse(formState); - return result.success; - }, [formState]); - - const handleSubmit = useCallback( - (e: FormEvent) => { - e.preventDefault(); - - // Mark all fields as touched - setTouched({ email: true, password: true }); - - const result = signInSchema.safeParse(formState); - if (result.success) { - onSubmit(result.data); - } else { - // Set all errors - const newErrors: FormErrors = {}; - result.error.errors.forEach((err) => { - const field = err.path[0] as keyof FormErrors; - if (!newErrors[field]) { - newErrors[field] = err.message; - } - }); - setErrors(newErrors); - } - }, - [formState, onSubmit], - ); - return ( - + @@ -149,11 +47,11 @@ export const SignInForm: FC = ({ type="password" placeholder="Password" ariaLabel="Password" - value={formState.password} - onChange={handleChange("password")} - onBlur={handleBlur("password")} - error={errors.password} - hasError={touched.password && !!errors.password} + value={form.values.password} + onChange={form.handleChange("password")} + onBlur={form.handleBlur("password")} + error={form.errors.password} + hasError={!!form.touched.password && !!form.errors.password} autoComplete="current-password" /> @@ -163,7 +61,11 @@ export const SignInForm: FC = ({

- + Enter diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx new file mode 100644 index 000000000..f005eadbd --- /dev/null +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx @@ -0,0 +1,195 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SignUpForm } from "./SignUpForm"; + +const mockOnSubmit = jest.fn(); +const mockOnNameChange = jest.fn(); + +const renderSignUpForm = (props?: { + onNameChange?: (name: string) => void; +}) => { + render( + , + ); +}; + +describe("SignUpForm", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("blur-only validation", () => { + it("does not show email error while user is typing", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + const emailInput = screen.getByLabelText(/email/i); + await user.click(emailInput); + await user.type(emailInput, "invalid"); + + expect( + screen.queryByText(/please enter a valid email address/i), + ).not.toBeInTheDocument(); + }); + + it("shows email error only after blur", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + await user.type(screen.getByLabelText(/email/i), "invalid-email"); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText(/please enter a valid email address/i), + ).toBeInTheDocument(); + }); + }); + + it("shows password error only after blur", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + await user.type(screen.getByLabelText(/password/i), "short"); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText(/password must be at least 8 characters/i), + ).toBeInTheDocument(); + }); + }); + + it("clears error when user types after blur", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + const passwordInput = screen.getByLabelText(/password/i); + await user.type(passwordInput, "short"); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText(/password must be at least 8 characters/i), + ).toBeInTheDocument(); + }); + + await user.click(passwordInput); + await user.type(passwordInput, "er123456"); + + await waitFor(() => { + expect( + screen.queryByText(/password must be at least 8 characters/i), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("submit validation", () => { + it("shows all field errors when submitting empty form", async () => { + renderSignUpForm(); + + const form = screen.getByLabelText(/name/i).closest("form"); + if (form) fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByText(/name is required/i)).toBeInTheDocument(); + expect(screen.getByText(/email is required/i)).toBeInTheDocument(); + expect(screen.getByText(/password is required/i)).toBeInTheDocument(); + }); + }); + + it("does not call onSubmit when form is invalid", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + await user.type(screen.getByLabelText(/name/i), "Alex"); + await user.type(screen.getByLabelText(/email/i), "invalid"); + await user.type(screen.getByLabelText(/password/i), "short"); + + const form = screen.getByLabelText(/name/i).closest("form"); + if (form) fireEvent.submit(form); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + it("calls onSubmit with valid data when form is valid", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + await user.type(screen.getByLabelText(/name/i), "Alex Smith"); + await user.type(screen.getByLabelText(/email/i), "alex@example.com"); + await user.type(screen.getByLabelText(/password/i), "securepass123"); + await user.click(screen.getByRole("button", { name: /create account/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith({ + name: "Alex Smith", + email: "alex@example.com", + password: "securepass123", + }); + }); + }); + + it("trims and lowercases values on submit", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + await user.type(screen.getByLabelText(/name/i), " Alex "); + await user.type(screen.getByLabelText(/email/i), " Alex@Example.COM "); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /create account/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith({ + name: "Alex", + email: "alex@example.com", + password: "password123", + }); + }); + }); + }); + + describe("submit button state", () => { + it("disables submit when form is invalid", async () => { + renderSignUpForm(); + + const submitButton = screen.getByRole("button", { + name: /create account/i, + }); + expect(submitButton).toBeDisabled(); + }); + + it("enables submit when all fields are valid", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + await user.type(screen.getByLabelText(/name/i), "Alex"); + await user.type(screen.getByLabelText(/email/i), "alex@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + + const submitButton = screen.getByRole("button", { + name: /create account/i, + }); + expect(submitButton).not.toBeDisabled(); + }); + }); + + describe("onNameChange callback", () => { + it("calls onNameChange when name field changes", async () => { + const user = userEvent.setup(); + renderSignUpForm(); + + await user.type(screen.getByLabelText(/name/i), "Alex"); + + expect(mockOnNameChange).toHaveBeenCalledWith("A"); + expect(mockOnNameChange).toHaveBeenCalledWith("Al"); + expect(mockOnNameChange).toHaveBeenCalledWith("Ale"); + expect(mockOnNameChange).toHaveBeenCalledWith("Alex"); + }); + }); +}); diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx index 227ea8df7..778adeda0 100644 --- a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx @@ -1,14 +1,8 @@ -import { - ChangeEvent, - FC, - FormEvent, - useCallback, - useMemo, - useState, -} from "react"; +import { ChangeEvent, FC, useCallback } from "react"; import { SignUpFormData, signUpSchema } from "@web/auth/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; +import { useZodForm } from "../hooks/useZodForm"; interface SignUpFormProps { /** Callback when form is submitted with valid data */ @@ -19,24 +13,6 @@ interface SignUpFormProps { isSubmitting?: boolean; } -interface FormState { - name: string; - email: string; - password: string; -} - -interface FormErrors { - name?: string; - email?: string; - password?: string; -} - -interface TouchedFields { - name: boolean; - email: boolean; - password: boolean; -} - /** * Sign up form with name, email, and password fields * @@ -47,110 +23,31 @@ export const SignUpForm: FC = ({ onNameChange, isSubmitting, }) => { - const [formState, setFormState] = useState({ - name: "", - email: "", - password: "", + const form = useZodForm({ + schema: signUpSchema, + initialValues: { name: "", email: "", password: "" }, + onSubmit, }); - const [touched, setTouched] = useState({ - name: false, - email: false, - password: false, - }); - - const [errors, setErrors] = useState({}); - - const validateField = useCallback( - (field: keyof FormState, value: string): string | undefined => { - const testData = { ...formState, [field]: value }; - const result = signUpSchema.safeParse(testData); - - if (!result.success) { - const fieldError = result.error.errors.find( - (err) => err.path[0] === field, - ); - return fieldError?.message; - } - return undefined; - }, - [formState], - ); - - const handleChange = useCallback( - (field: keyof FormState) => (e: ChangeEvent) => { - const value = e.target.value; - setFormState((prev) => ({ ...prev, [field]: value })); - - if (field === "name") { - onNameChange?.(value); - } - - // Clear error when user types - only show validation errors after blur - if (touched[field]) { - setErrors((prev) => ({ ...prev, [field]: undefined })); - } - }, - [touched, onNameChange], - ); - - const handleBlur = useCallback( - (field: keyof FormState) => () => { - setTouched((prev) => ({ ...prev, [field]: true })); - const value = formState[field]; - // Only show errors on blur when user has entered something; empty required - // fields don't need an error - the disabled button already conveys that - if (value.trim() !== "") { - const error = validateField(field, value); - setErrors((prev) => ({ ...prev, [field]: error })); - } else { - setErrors((prev) => ({ ...prev, [field]: undefined })); - } - }, - [formState, validateField], - ); - - const isValid = useMemo(() => { - const result = signUpSchema.safeParse(formState); - return result.success; - }, [formState]); - - const handleSubmit = useCallback( - (e: FormEvent) => { - e.preventDefault(); - - // Mark all fields as touched - setTouched({ name: true, email: true, password: true }); - - const result = signUpSchema.safeParse(formState); - if (result.success) { - onSubmit(result.data); - } else { - // Set all errors - const newErrors: FormErrors = {}; - result.error.errors.forEach((err) => { - const field = err.path[0] as keyof FormErrors; - if (!newErrors[field]) { - newErrors[field] = err.message; - } - }); - setErrors(newErrors); - } + const handleNameChange = useCallback( + (e: ChangeEvent) => { + form.handleChange("name")(e); + onNameChange?.(e.target.value); }, - [formState, onSubmit], + [form.handleChange, onNameChange], ); return ( -
+ @@ -158,11 +55,11 @@ export const SignUpForm: FC = ({ type="email" placeholder="Email" ariaLabel="Email" - value={formState.email} - onChange={handleChange("email")} - onBlur={handleBlur("email")} - error={errors.email} - hasError={touched.email && !!errors.email} + value={form.values.email} + onChange={form.handleChange("email")} + onBlur={form.handleBlur("email")} + error={form.errors.email} + hasError={!!form.touched.email && !!form.errors.email} autoComplete="email" /> @@ -170,15 +67,19 @@ export const SignUpForm: FC = ({ type="password" placeholder="Password" ariaLabel="Password" - value={formState.password} - onChange={handleChange("password")} - onBlur={handleBlur("password")} - error={errors.password} - hasError={touched.password && !!errors.password} + value={form.values.password} + onChange={form.handleChange("password")} + onBlur={form.handleBlur("password")} + error={form.errors.password} + hasError={!!form.touched.password && !!form.errors.password} autoComplete="new-password" /> - + Create account diff --git a/packages/web/src/components/AuthModal/hooks/useZodForm.test.tsx b/packages/web/src/components/AuthModal/hooks/useZodForm.test.tsx new file mode 100644 index 000000000..ca9d01745 --- /dev/null +++ b/packages/web/src/components/AuthModal/hooks/useZodForm.test.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { z } from "zod"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useZodForm } from "./useZodForm"; + +const testSchema = z.object({ + email: z.string().min(1, "Email is required").email("Invalid email"), + name: z.string().min(1, "Name is required"), +}); + +type TestFormValues = z.infer; + +function TestForm() { + const form = useZodForm({ + schema: testSchema, + initialValues: { email: "", name: "" }, + onSubmit: () => {}, + }); + + return ( +
+ + {form.touched.email && form.errors.email && ( + {form.errors.email} + )} + + {form.touched.name && form.errors.name && ( + {form.errors.name} + )} + +
+ ); +} + +describe("useZodForm", () => { + it("validates on blur only - no error while typing", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText(/email/i), "invalid"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("shows error after blur when field has invalid value", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText(/email/i), "invalid"); + await user.tab(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent(/invalid email/i); + }); + }); + + it("clears error when user types after blur", async () => { + const user = userEvent.setup(); + render(); + + const emailInput = screen.getByLabelText(/email/i); + await user.type(emailInput, "invalid"); + await user.tab(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent(/invalid email/i); + }); + + await user.click(emailInput); + await user.type(emailInput, "@example.com"); + + await waitFor(() => { + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); + + it("shows all errors on submit when form is invalid", async () => { + render(); + + const form = screen.getByLabelText(/email/i).closest("form"); + if (form) fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByText(/email is required/i)).toBeInTheDocument(); + expect(screen.getByText(/name is required/i)).toBeInTheDocument(); + }); + }); + + it("disables submit when form is invalid", () => { + render(); + expect(screen.getByRole("button", { name: /submit/i })).toBeDisabled(); + }); + + it("enables submit when form is valid", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText(/email/i), "test@example.com"); + await user.type(screen.getByLabelText(/name/i), "Alex"); + + expect(screen.getByRole("button", { name: /submit/i })).not.toBeDisabled(); + }); +}); diff --git a/packages/web/src/components/AuthModal/hooks/useZodForm.ts b/packages/web/src/components/AuthModal/hooks/useZodForm.ts new file mode 100644 index 000000000..373ef886a --- /dev/null +++ b/packages/web/src/components/AuthModal/hooks/useZodForm.ts @@ -0,0 +1,133 @@ +import { ChangeEvent, FormEvent, useCallback, useMemo, useState } from "react"; +import { z } from "zod"; + +/** + * Extracts field errors from a ZodError as Record + */ +function getFieldErrors(error: z.ZodError): Record { + const flat = error.flatten(); + const result: Record = {}; + for (const [field, messages] of Object.entries(flat.fieldErrors)) { + const msg = Array.isArray(messages) ? messages[0] : undefined; + if (msg) result[field] = msg; + } + return result; +} + +export interface UseZodFormConfig> { + /** Zod schema - output type must match TValues */ + schema: z.ZodType; + initialValues: TValues; + onSubmit: (data: TValues) => void; +} + +export interface UseZodFormReturn> { + values: TValues; + errors: Partial>; + touched: Partial>; + handleChange: ( + field: keyof TValues & string, + ) => (e: ChangeEvent) => void; + handleBlur: (field: keyof TValues & string) => () => void; + handleSubmit: (e: FormEvent) => void; + isValid: boolean; +} + +/** + * Form hook that uses zod for validation. + * + * - Validates on blur only (errors shown after user leaves field) + * - Clears field error when user types + * - Full validation on submit + */ +export function useZodForm>({ + schema, + initialValues, + onSubmit, +}: UseZodFormConfig): UseZodFormReturn { + const [values, setValues] = useState(initialValues); + const [touched, setTouched] = useState< + Partial> + >({}); + const [errors, setErrors] = useState< + Partial> + >({}); + + const validateField = useCallback( + (field: keyof TValues & string, value: string): string | undefined => { + const testData = { ...values, [field]: value }; + const result = schema.safeParse(testData); + if (!result.success) { + const fieldErrors = getFieldErrors(result.error); + return fieldErrors[field]; + } + return undefined; + }, + [schema, values], + ); + + const handleChange = useCallback( + (field: keyof TValues & string) => (e: ChangeEvent) => { + const value = e.target.value; + setValues((prev) => ({ ...prev, [field]: value })); + + if (touched[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }, + [touched], + ); + + const handleBlur = useCallback( + (field: keyof TValues & string) => () => { + setTouched((prev) => ({ ...prev, [field]: true })); + const value = values[field]; + if (value.trim() !== "") { + const error = validateField(field, value); + setErrors((prev) => ({ ...prev, [field]: error })); + } else { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }, + [values, validateField], + ); + + const isValid = useMemo( + () => schema.safeParse(values).success, + [schema, values], + ); + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + + const allTouched = Object.keys(initialValues).reduce( + (acc, key) => ({ ...acc, [key]: true }), + {} as Partial>, + ); + setTouched(allTouched); + + const result = schema.safeParse(values); + if (result.success) { + onSubmit(result.data); + } else { + setErrors( + getFieldErrors(result.error) as Partial< + Record + >, + ); + } + }, + [schema, values, onSubmit, initialValues], + ); + + return { + values, + errors, + touched, + handleChange, + handleBlur, + handleSubmit, + isValid, + }; +} From 29b7234f5d60edb028b22aaf28422a8a6173d7ba Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 10:36:22 -0800 Subject: [PATCH 17/34] refactor(auth): update button labels and enhance AuthModal structure - Changed button labels in SignInForm and SignUpForm from "Enter" and "Create account" to "Log in" and "Sign up" respectively for better clarity. - Updated Google sign-in button label in AuthModal from "Sign in with Google" to "Continue with Google" to align with user expectations. - Introduced AuthButton component to standardize button styles and improve code organization within AuthModal. - Adjusted tests in AuthModal.test.tsx, SignInForm.test.tsx, and SignUpForm.test.tsx to reflect the updated button labels, ensuring accurate test coverage. --- .../components/AuthModal/AuthModal.test.tsx | 22 +++++------ .../src/components/AuthModal/AuthModal.tsx | 39 ++++++++++--------- .../AuthModal/components/AuthButton.tsx | 11 +++++- .../AuthModal/forms/SignInForm.test.tsx | 8 ++-- .../components/AuthModal/forms/SignInForm.tsx | 2 +- .../AuthModal/forms/SignUpForm.test.tsx | 8 ++-- .../components/AuthModal/forms/SignUpForm.tsx | 2 +- 7 files changed, 51 insertions(+), 41 deletions(-) diff --git a/packages/web/src/components/AuthModal/AuthModal.test.tsx b/packages/web/src/components/AuthModal/AuthModal.test.tsx index 98d55d00a..976f19bb9 100644 --- a/packages/web/src/components/AuthModal/AuthModal.test.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.test.tsx @@ -256,8 +256,8 @@ describe("AuthModal", () => { await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { - // Look for the submit button by type - CTA is "Enter" per spec - const submitButton = screen.getByRole("button", { name: /^enter$/i }); + // Look for the submit button by type - CTA is "Log in" + const submitButton = screen.getByRole("button", { name: /^log in$/i }); expect(submitButton).toBeInTheDocument(); expect(submitButton).toHaveAttribute("type", "submit"); }); @@ -448,10 +448,10 @@ describe("AuthModal", () => { await waitFor(() => { const googleButton = screen.getByRole("button", { - name: /sign in with google/i, + name: /continue with google/i, }); expect(googleButton).toBeInTheDocument(); - expect(googleButton).toHaveTextContent(/sign in with google/i); + expect(googleButton).toHaveTextContent(/continue with google/i); }); }); @@ -463,12 +463,12 @@ describe("AuthModal", () => { await waitFor(() => { expect( - screen.getByRole("button", { name: /sign in with google/i }), + screen.getByRole("button", { name: /continue with google/i }), ).toBeInTheDocument(); }); await user.click( - screen.getByRole("button", { name: /sign in with google/i }), + screen.getByRole("button", { name: /continue with google/i }), ); expect(mockGoogleLogin).toHaveBeenCalled(); @@ -482,17 +482,17 @@ describe("AuthModal", () => { await waitFor(() => { expect( - screen.getByRole("button", { name: /sign in with google/i }), - ).toHaveTextContent(/sign in with google/i); + screen.getByRole("button", { name: /continue with google/i }), + ).toHaveTextContent(/continue with google/i); }); await user.click(screen.getByRole("button", { name: /^sign up$/i })); - // Google button label stays consistent as "Sign in with Google" + // Google button label stays consistent as "Continue with Google" await waitFor(() => { expect( - screen.getByRole("button", { name: /sign in with google/i }), - ).toHaveTextContent(/sign in with google/i); + screen.getByRole("button", { name: /continue with google/i }), + ).toHaveTextContent(/continue with google/i); }); }); }); diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index 398fd6997..939a5d71c 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -4,6 +4,7 @@ import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; import { SignInFormData, SignUpFormData } from "@web/auth/schemas/auth.schemas"; import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; import { GoogleButton } from "@web/components/oauth/google/GoogleButton"; +import { AuthButton } from "./components/AuthButton"; import { ForgotPasswordForm } from "./forms/ForgotPasswordForm"; import { SignInForm } from "./forms/SignInForm"; import { SignUpForm } from "./forms/SignUpForm"; @@ -80,23 +81,8 @@ export const AuthModal: FC = () => { : "Nice to meet you" : "Hey, welcome back"; - const titleAction = showAuthSwitch ? ( - - ) : undefined; - return ( - +
{/* Form based on current view */} {currentView === "signUp" && ( @@ -114,7 +100,24 @@ export const AuthModal: FC = () => { onBackToSignIn={handleBackToSignIn} /> )} - {/* Google Sign In */} + {/* Auth switch (Sign In / Sign Up) - only for signIn and signUp views */} + {showAuthSwitch && ( + <> +
+
+ or +
+
+ + {currentView === "signIn" ? "Sign Up" : "Sign In"} + + + )} + {/* Google Sign In - at bottom */} <>
@@ -123,7 +126,7 @@ export const AuthModal: FC = () => {
diff --git a/packages/web/src/components/AuthModal/components/AuthButton.tsx b/packages/web/src/components/AuthModal/components/AuthButton.tsx index 764350853..a17996b9a 100644 --- a/packages/web/src/components/AuthModal/components/AuthButton.tsx +++ b/packages/web/src/components/AuthModal/components/AuthButton.tsx @@ -3,7 +3,7 @@ import { ButtonHTMLAttributes, FC } from "react"; interface AuthButtonProps extends ButtonHTMLAttributes { /** Visual variant of the button */ - variant?: "primary" | "secondary" | "link"; + variant?: "primary" | "secondary" | "outline" | "link"; /** Whether the button is in a loading state */ isLoading?: boolean; } @@ -14,6 +14,7 @@ interface AuthButtonProps extends ButtonHTMLAttributes { * Variants: * - primary: Solid accent color background (for CTAs) * - secondary: Subtle background (for secondary actions) + * - outline: White background with dark border (matches Google button) * - link: Text-only style (for inline links like "Forgot password") */ export const AuthButton: FC = ({ @@ -32,18 +33,24 @@ export const AuthButton: FC = ({ className={clsx( "rounded-3xl text-sm font-medium transition-colors", "focus:ring-2 focus:ring-offset-2 focus:ring-offset-transparent focus:outline-none", + isDisabled ? "cursor-not-allowed" : "cursor-pointer", { // Primary variant "bg-accent-primary focus:ring-accent-primary h-10 w-full px-4 text-white": variant === "primary", "hover:brightness-110": variant === "primary" && !isDisabled, - "cursor-not-allowed opacity-50": variant === "primary" && isDisabled, + "opacity-50": variant === "primary" && isDisabled, // Secondary variant "bg-bg-secondary text-text-lighter h-10 w-full px-4": variant === "secondary", "hover:bg-bg-tertiary": variant === "secondary" && !isDisabled, + // Outline variant (white/black, matches Google button) + "h-10 w-full border border-[#1f1f1f] bg-white px-4 text-[#1f1f1f]": + variant === "outline", + "hover:bg-[#f8f8f8]": variant === "outline" && !isDisabled, + // Link variant "text-text-light hover:text-text-lighter px-0 py-0": variant === "link", diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx b/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx index f1692e329..559dbd53c 100644 --- a/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx +++ b/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx @@ -101,7 +101,7 @@ describe("SignInForm", () => { await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "password123"); - await user.click(screen.getByRole("button", { name: /^enter$/i })); + await user.click(screen.getByRole("button", { name: /^log in$/i })); await waitFor(() => { expect(mockOnSubmit).toHaveBeenCalledWith({ @@ -117,7 +117,7 @@ describe("SignInForm", () => { await user.type(screen.getByLabelText(/email/i), "Test@Example.COM"); await user.type(screen.getByLabelText(/password/i), "password123"); - await user.click(screen.getByRole("button", { name: /^enter$/i })); + await user.click(screen.getByRole("button", { name: /^log in$/i })); await waitFor(() => { expect(mockOnSubmit).toHaveBeenCalledWith({ @@ -132,7 +132,7 @@ describe("SignInForm", () => { it("disables submit when form is invalid", async () => { renderSignInForm(); - const submitButton = screen.getByRole("button", { name: /^enter$/i }); + const submitButton = screen.getByRole("button", { name: /^log in$/i }); expect(submitButton).toBeDisabled(); }); @@ -143,7 +143,7 @@ describe("SignInForm", () => { await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "password123"); - const submitButton = screen.getByRole("button", { name: /^enter$/i }); + const submitButton = screen.getByRole("button", { name: /^log in$/i }); expect(submitButton).not.toBeDisabled(); }); }); diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.tsx b/packages/web/src/components/AuthModal/forms/SignInForm.tsx index 40351d70b..b6bc69b34 100644 --- a/packages/web/src/components/AuthModal/forms/SignInForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignInForm.tsx @@ -66,7 +66,7 @@ export const SignInForm: FC = ({ disabled={!form.isValid} isLoading={isSubmitting} > - Enter + Log in ); diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx index f005eadbd..f07c5c823 100644 --- a/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.test.tsx @@ -124,7 +124,7 @@ describe("SignUpForm", () => { await user.type(screen.getByLabelText(/name/i), "Alex Smith"); await user.type(screen.getByLabelText(/email/i), "alex@example.com"); await user.type(screen.getByLabelText(/password/i), "securepass123"); - await user.click(screen.getByRole("button", { name: /create account/i })); + await user.click(screen.getByRole("button", { name: /sign up/i })); await waitFor(() => { expect(mockOnSubmit).toHaveBeenCalledWith({ @@ -142,7 +142,7 @@ describe("SignUpForm", () => { await user.type(screen.getByLabelText(/name/i), " Alex "); await user.type(screen.getByLabelText(/email/i), " Alex@Example.COM "); await user.type(screen.getByLabelText(/password/i), "password123"); - await user.click(screen.getByRole("button", { name: /create account/i })); + await user.click(screen.getByRole("button", { name: /sign up/i })); await waitFor(() => { expect(mockOnSubmit).toHaveBeenCalledWith({ @@ -159,7 +159,7 @@ describe("SignUpForm", () => { renderSignUpForm(); const submitButton = screen.getByRole("button", { - name: /create account/i, + name: /sign up/i, }); expect(submitButton).toBeDisabled(); }); @@ -173,7 +173,7 @@ describe("SignUpForm", () => { await user.type(screen.getByLabelText(/password/i), "password123"); const submitButton = screen.getByRole("button", { - name: /create account/i, + name: /sign up/i, }); expect(submitButton).not.toBeDisabled(); }); diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx index 778adeda0..4cbf2ae82 100644 --- a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx @@ -80,7 +80,7 @@ export const SignUpForm: FC = ({ disabled={!form.isValid} isLoading={isSubmitting} > - Create account + Sign up ); From a4f6fa0d000e73e0e050eea7536cc8765c3ff39f Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 10:41:39 -0800 Subject: [PATCH 18/34] refactor(auth): enhance AuthButton styles for improved user interaction - Updated button transition effects in AuthButton to use 'transition-all duration-150' for smoother animations. - Modified hover styles for the outline variant to improve visual feedback, changing from 'hover:bg-[#f8f8f8]' to 'hover:bg-[#f0f0f0] hover:border-[#151515]'. --- .../web/src/components/AuthModal/components/AuthButton.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/AuthModal/components/AuthButton.tsx b/packages/web/src/components/AuthModal/components/AuthButton.tsx index a17996b9a..5e0cb29d5 100644 --- a/packages/web/src/components/AuthModal/components/AuthButton.tsx +++ b/packages/web/src/components/AuthModal/components/AuthButton.tsx @@ -31,7 +31,7 @@ export const AuthButton: FC = ({ ; + return ; }; const renderWithProviders = ( @@ -179,7 +179,7 @@ describe("AuthModal", () => { }); describe("Auth view switching", () => { - it("shows Sign Up switch when on sign in form", async () => { + it("shows sign up when on sign in form", async () => { const user = userEvent.setup(); renderWithProviders(); @@ -210,7 +210,7 @@ describe("AuthModal", () => { screen.getByRole("heading", { name: /nice to meet you/i }), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: /^sign in$/i }), + screen.getByRole("button", { name: /^log in$/i }), ).toBeInTheDocument(); }); }); @@ -221,13 +221,13 @@ describe("AuthModal", () => { await user.click(screen.getByRole("button", { name: /open modal/i })); - // Sign In form - no Name field + // Login form - no Name field await waitFor(() => { expect(screen.queryByLabelText(/name/i)).not.toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); - // Switch to Sign Up + // Switch to sign up await user.click(screen.getByRole("button", { name: /^sign up$/i })); await waitFor(() => { @@ -236,7 +236,7 @@ describe("AuthModal", () => { }); }); - describe("Sign In Form", () => { + describe("Login Form", () => { it("renders email and password fields", async () => { const user = userEvent.setup(); renderWithProviders(); @@ -256,7 +256,7 @@ describe("AuthModal", () => { await user.click(screen.getByRole("button", { name: /open modal/i })); await waitFor(() => { - // Look for the submit button by type - CTA is "Log in" + // Look for the submit button by type - CTA is "login" const submitButton = screen.getByRole("button", { name: /^log in$/i }); expect(submitButton).toBeInTheDocument(); expect(submitButton).toHaveAttribute("type", "submit"); @@ -537,6 +537,137 @@ describe("AuthModal", () => { }); }); +// Helper to mock window.location for URL param tests +const mockWindowLocation = (url: string) => { + const urlObj = new URL(url, "http://localhost"); + Object.defineProperty(window, "location", { + value: { + pathname: urlObj.pathname, + search: urlObj.search, + hash: urlObj.hash, + href: urlObj.href, + }, + writable: true, + configurable: true, + }); +}; + +// Mock history.replaceState for URL param tests +const mockReplaceState = jest.fn(); +const originalHistory = window.history; + +describe("URL Parameter Support", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSession.mockReturnValue({ + authenticated: false, + setAuthenticated: jest.fn(), + }); + // Mock history.replaceState + Object.defineProperty(window, "history", { + value: { ...originalHistory, replaceState: mockReplaceState }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + // Reset window.location to default + mockWindowLocation("/day"); + // Restore original history + Object.defineProperty(window, "history", { + value: originalHistory, + writable: true, + configurable: true, + }); + }); + + it("opens sign in modal when ?auth=login is present", async () => { + mockWindowLocation("/?auth=login"); + renderWithProviders(
, "/?auth=login"); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /hey, welcome back/i }), + ).toBeInTheDocument(); + }); + }); + + it("opens sign up modal when ?auth=signup is present", async () => { + mockWindowLocation("/?auth=signup"); + renderWithProviders(
, "/?auth=signup"); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /nice to meet you/i }), + ).toBeInTheDocument(); + }); + }); + + it("opens forgot password modal when ?auth=forgot is present", async () => { + mockWindowLocation("/?auth=forgot"); + renderWithProviders(
, "/?auth=forgot"); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /reset password/i }), + ).toBeInTheDocument(); + }); + }); + + it("handles case-insensitive param values", async () => { + mockWindowLocation("/?auth=LOGIN"); + renderWithProviders(
, "/?auth=LOGIN"); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /hey, welcome back/i }), + ).toBeInTheDocument(); + }); + }); + + it("does not open modal for invalid param value", async () => { + mockWindowLocation("/?auth=invalid"); + renderWithProviders(
, "/?auth=invalid"); + + // Give it time to potentially open (it shouldn't) + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect( + screen.queryByRole("heading", { name: /hey, welcome back/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("heading", { name: /nice to meet you/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("heading", { name: /reset password/i }), + ).not.toBeInTheDocument(); + }); + + it("implicitly enables auth feature when ?auth param is present", async () => { + mockWindowLocation("/?auth=signup"); + renderWithProviders(, "/?auth=signup"); + + await waitFor(() => { + // The auth modal should open + expect( + screen.getByRole("heading", { name: /nice to meet you/i }), + ).toBeInTheDocument(); + }); + }); + + it("works on different routes", async () => { + mockWindowLocation("/week?auth=signup"); + renderWithProviders(
, "/week?auth=signup"); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /nice to meet you/i }), + ).toBeInTheDocument(); + }); + }); +}); + describe("AccountIcon", () => { beforeEach(() => { jest.clearAllMocks(); @@ -548,7 +679,7 @@ describe("AccountIcon", () => { setAuthenticated: jest.fn(), }); - renderWithProviders(, "/day?enableAuth=true"); + renderWithProviders(, "/day?auth=signup"); await waitFor(() => { expect(screen.getByLabelText(/log in/i)).toBeInTheDocument(); @@ -561,7 +692,7 @@ describe("AccountIcon", () => { setAuthenticated: jest.fn(), }); - renderWithProviders(, "/day?enableAuth=true"); + renderWithProviders(, "/day?auth=signup"); expect(screen.getByText("Log in")).toBeInTheDocument(); }); @@ -584,7 +715,7 @@ describe("AccountIcon", () => { setAuthenticated: jest.fn(), }); - renderWithProviders(, "/day?enableAuth=true"); + renderWithProviders(, "/day?auth=signup"); await waitFor(() => { expect(screen.getByLabelText(/log in/i)).toBeInTheDocument(); diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index 939a5d71c..99ec2eddd 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -1,14 +1,15 @@ import { FC, useCallback, useEffect, useRef, useState } from "react"; import { DotIcon } from "@phosphor-icons/react"; import { useGoogleAuth } from "@web/auth/hooks/oauth/useGoogleAuth"; -import { SignInFormData, SignUpFormData } from "@web/auth/schemas/auth.schemas"; +import { LogInFormData, SignUpFormData } from "@web/auth/schemas/auth.schemas"; import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; import { GoogleButton } from "@web/components/oauth/google/GoogleButton"; import { AuthButton } from "./components/AuthButton"; import { ForgotPasswordForm } from "./forms/ForgotPasswordForm"; -import { SignInForm } from "./forms/SignInForm"; +import { LogInForm } from "./forms/LogInForm"; import { SignUpForm } from "./forms/SignUpForm"; import { useAuthModal } from "./hooks/useAuthModal"; +import { useAuthUrlParam } from "./hooks/useAuthUrlParam"; /** * Authentication modal with Sign In, Sign Up, and Forgot Password views @@ -21,8 +22,12 @@ import { useAuthModal } from "./hooks/useAuthModal"; * - Accessible modal with proper ARIA attributes */ export const AuthModal: FC = () => { - const { isOpen, currentView, closeModal, setView } = useAuthModal(); + const { isOpen, currentView, openModal, closeModal, setView } = + useAuthModal(); const googleAuth = useGoogleAuth(); + + // Handle URL-based auth modal triggers (e.g., ?auth=signup) + useAuthUrlParam(openModal); const [signUpName, setSignUpName] = useState(""); const prevViewRef = useRef(currentView); @@ -34,7 +39,7 @@ export const AuthModal: FC = () => { }, [currentView]); const handleSwitchAuth = useCallback( - () => setView(currentView === "signIn" ? "signUp" : "signIn"), + () => setView(currentView === "login" ? "signUp" : "login"), [currentView, setView], ); @@ -43,14 +48,16 @@ export const AuthModal: FC = () => { closeModal(); }, [googleAuth, closeModal]); - const handleSignUp = useCallback((_data: SignUpFormData) => { + const handleSignUp = useCallback((data: SignUpFormData) => { // TODO: Implement email/password sign up API call in Phase 2 // For now, this is UI-only - backend integration will be added later + console.log(data); }, []); - const handleSignIn = useCallback((_data: SignInFormData) => { + const handleLogin = useCallback((data: LogInFormData) => { // TODO: Implement email/password sign in API call in Phase 2 // For now, this is UI-only - backend integration will be added later + console.log(data); }, []); const handleForgotPassword = useCallback(() => { @@ -58,7 +65,7 @@ export const AuthModal: FC = () => { }, [setView]); const handleBackToSignIn = useCallback(() => { - setView("signIn"); + setView("login"); }, [setView]); const handleForgotPasswordSubmit = useCallback((_data: { email: string }) => { @@ -88,9 +95,9 @@ export const AuthModal: FC = () => { {currentView === "signUp" && ( )} - {currentView === "signIn" && ( - )} @@ -113,7 +120,7 @@ export const AuthModal: FC = () => { variant="outline" onClick={handleSwitchAuth} > - {currentView === "signIn" ? "Sign Up" : "Sign In"} + {currentView === "login" ? "Sign up" : "Log in"} )} diff --git a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx index 8cffec555..27093a557 100644 --- a/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx +++ b/packages/web/src/components/AuthModal/forms/ForgotPasswordForm.tsx @@ -1,7 +1,7 @@ import { FC, useState } from "react"; import { ForgotPasswordFormData, - forgotPasswordSchema, + ForgotPasswordSchema, } from "@web/auth/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; @@ -30,7 +30,7 @@ export const ForgotPasswordForm: FC = ({ const [isSubmitted, setIsSubmitted] = useState(false); const form = useZodForm({ - schema: forgotPasswordSchema, + schema: ForgotPasswordSchema, initialValues: { email: "" }, onSubmit: (data) => { onSubmit(data); diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.tsx b/packages/web/src/components/AuthModal/forms/LogInForm.tsx similarity index 90% rename from packages/web/src/components/AuthModal/forms/SignInForm.tsx rename to packages/web/src/components/AuthModal/forms/LogInForm.tsx index b6bc69b34..552dd3722 100644 --- a/packages/web/src/components/AuthModal/forms/SignInForm.tsx +++ b/packages/web/src/components/AuthModal/forms/LogInForm.tsx @@ -1,12 +1,12 @@ import { FC } from "react"; -import { SignInFormData, signInSchema } from "@web/auth/schemas/auth.schemas"; +import { LogInFormData, LogInSchema } from "@web/auth/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; import { useZodForm } from "../hooks/useZodForm"; interface SignInFormProps { /** Callback when form is submitted with valid data */ - onSubmit: (data: SignInFormData) => void; + onSubmit: (data: LogInFormData) => void; /** Callback when "Forgot password" is clicked */ onForgotPassword: () => void; /** Whether form submission is in progress */ @@ -18,13 +18,13 @@ interface SignInFormProps { * * Includes "Forgot password" link and validates on blur */ -export const SignInForm: FC = ({ +export const LogInForm: FC = ({ onSubmit, onForgotPassword, isSubmitting, }) => { const form = useZodForm({ - schema: signInSchema, + schema: LogInSchema, initialValues: { email: "", password: "" }, onSubmit, }); diff --git a/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx b/packages/web/src/components/AuthModal/forms/LoginForm.test.tsx similarity index 98% rename from packages/web/src/components/AuthModal/forms/SignInForm.test.tsx rename to packages/web/src/components/AuthModal/forms/LoginForm.test.tsx index 559dbd53c..d523bbed2 100644 --- a/packages/web/src/components/AuthModal/forms/SignInForm.test.tsx +++ b/packages/web/src/components/AuthModal/forms/LoginForm.test.tsx @@ -1,21 +1,21 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { SignInForm } from "./SignInForm"; +import { LogInForm } from "./LogInForm"; const mockOnSubmit = jest.fn(); const mockOnForgotPassword = jest.fn(); const renderSignInForm = () => { render( - , ); }; -describe("SignInForm", () => { +describe("LogInForm", () => { beforeEach(() => { jest.clearAllMocks(); }); diff --git a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx index 4cbf2ae82..e5eac0f5c 100644 --- a/packages/web/src/components/AuthModal/forms/SignUpForm.tsx +++ b/packages/web/src/components/AuthModal/forms/SignUpForm.tsx @@ -1,5 +1,5 @@ import { ChangeEvent, FC, useCallback } from "react"; -import { SignUpFormData, signUpSchema } from "@web/auth/schemas/auth.schemas"; +import { SignUpFormData, SignUpSchema } from "@web/auth/schemas/auth.schemas"; import { AuthButton } from "../components/AuthButton"; import { AuthInput } from "../components/AuthInput"; import { useZodForm } from "../hooks/useZodForm"; @@ -24,7 +24,7 @@ export const SignUpForm: FC = ({ isSubmitting, }) => { const form = useZodForm({ - schema: signUpSchema, + schema: SignUpSchema, initialValues: { name: "", email: "", password: "" }, onSubmit, }); diff --git a/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts b/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts index 28f3f2af0..f375a7896 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts +++ b/packages/web/src/components/AuthModal/hooks/useAuthFeatureFlag.ts @@ -3,16 +3,16 @@ import { useSearchParams } from "react-router-dom"; /** * Feature flag hook for email/password authentication UI * - * Checks for the URL parameter `?enableAuth=true` to conditionally + * Checks for the URL parameter to conditionally * show the auth modal and related UI elements. * * @returns boolean - true if auth feature is enabled via URL param * * @example - * // Navigate to /day?enableAuth=true to enable + * // Use /day?auth=signup to implicitly enable and open modal * const isAuthEnabled = useAuthFeatureFlag(); */ export function useAuthFeatureFlag(): boolean { const [searchParams] = useSearchParams(); - return searchParams.get("enableAuth") === "true"; + return searchParams.has("auth"); } diff --git a/packages/web/src/components/AuthModal/hooks/useAuthModal.ts b/packages/web/src/components/AuthModal/hooks/useAuthModal.ts index a2813c9af..567c8a967 100644 --- a/packages/web/src/components/AuthModal/hooks/useAuthModal.ts +++ b/packages/web/src/components/AuthModal/hooks/useAuthModal.ts @@ -6,7 +6,7 @@ import { useState, } from "react"; -export type AuthView = "signIn" | "signUp" | "forgotPassword"; +export type AuthView = "login" | "signUp" | "forgotPassword"; interface AuthModalContextValue { isOpen: boolean; @@ -18,7 +18,7 @@ interface AuthModalContextValue { const defaultContextValue: AuthModalContextValue = { isOpen: false, - currentView: "signIn", + currentView: "login", openModal: () => {}, closeModal: () => {}, setView: () => {}, @@ -43,9 +43,9 @@ export function useAuthModal(): AuthModalContextValue { */ export function useAuthModalState() { const [isOpen, setIsOpen] = useState(false); - const [currentView, setCurrentView] = useState("signIn"); + const [currentView, setCurrentView] = useState("login"); - const openModal = useCallback((view: AuthView = "signIn") => { + const openModal = useCallback((view: AuthView = "login") => { setCurrentView(view); setIsOpen(true); }, []); @@ -53,7 +53,7 @@ export function useAuthModalState() { const closeModal = useCallback(() => { setIsOpen(false); // Reset to signIn view after closing - setCurrentView("signIn"); + setCurrentView("login"); }, []); const setView = useCallback((view: AuthView) => { diff --git a/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.test.tsx b/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.test.tsx new file mode 100644 index 000000000..b96a3e05e --- /dev/null +++ b/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.test.tsx @@ -0,0 +1,172 @@ +import { renderHook } from "@testing-library/react"; +import { useAuthUrlParam } from "./useAuthUrlParam"; + +// Helper to set up window.location for tests +const setWindowLocation = (url: string) => { + const urlObj = new URL(url, "http://localhost"); + Object.defineProperty(window, "location", { + value: { + pathname: urlObj.pathname, + search: urlObj.search, + hash: urlObj.hash, + }, + writable: true, + }); +}; + +// Mock history.replaceState +const mockReplaceState = jest.fn(); +Object.defineProperty(window, "history", { + value: { + replaceState: mockReplaceState, + }, + writable: true, +}); + +describe("useAuthUrlParam", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset to default location + setWindowLocation("/"); + }); + + describe("opens modal for valid param values", () => { + it("opens login view for ?auth=login", () => { + setWindowLocation("/?auth=login"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).toHaveBeenCalledWith("login"); + expect(openModal).toHaveBeenCalledTimes(1); + }); + + it("opens signUp view for ?auth=signup", () => { + setWindowLocation("/?auth=signup"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).toHaveBeenCalledWith("signUp"); + expect(openModal).toHaveBeenCalledTimes(1); + }); + + it("opens forgotPassword view for ?auth=forgot", () => { + setWindowLocation("/?auth=forgot"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).toHaveBeenCalledWith("forgotPassword"); + expect(openModal).toHaveBeenCalledTimes(1); + }); + }); + + describe("case-insensitive handling", () => { + it.each([ + ["LOGIN", "login"], + ["Login", "login"], + ["SIGNUP", "signUp"], + ["SignUp", "signUp"], + ["FORGOT", "forgotPassword"], + ["Forgot", "forgotPassword"], + ] as const)("handles %s as %s", (param, expectedView) => { + setWindowLocation(`/?auth=${param}`); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).toHaveBeenCalledWith(expectedView); + }); + }); + + describe("ignores invalid values", () => { + it("does not open modal for invalid param value", () => { + setWindowLocation("/?auth=invalid"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).not.toHaveBeenCalled(); + }); + + it("does not open modal for empty param value", () => { + setWindowLocation("/?auth="); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).not.toHaveBeenCalled(); + }); + + it("does not open modal when no auth param present", () => { + setWindowLocation("/"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).not.toHaveBeenCalled(); + }); + }); + + describe("clears param after opening", () => { + it("removes auth param from URL", () => { + setWindowLocation("/?auth=login"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(mockReplaceState).toHaveBeenCalledWith(null, "", "/"); + }); + + it("preserves other query params", () => { + setWindowLocation("/?auth=login&other=value&another=param"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(mockReplaceState).toHaveBeenCalledWith( + null, + "", + "/?other=value&another=param", + ); + }); + + it("preserves hash", () => { + setWindowLocation("/?auth=login#section"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(mockReplaceState).toHaveBeenCalledWith(null, "", "/#section"); + }); + }); + + describe("double-trigger prevention", () => { + it("only opens modal once even when rerendered", () => { + setWindowLocation("/?auth=login"); + const openModal = jest.fn(); + const { rerender } = renderHook(() => useAuthUrlParam(openModal)); + + // Simulate StrictMode by rerendering + rerender(); + rerender(); + + expect(openModal).toHaveBeenCalledTimes(1); + }); + }); + + describe("works with different routes", () => { + it("works on /week route", () => { + setWindowLocation("/week?auth=signup"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).toHaveBeenCalledWith("signUp"); + expect(mockReplaceState).toHaveBeenCalledWith(null, "", "/week"); + }); + + it("works on /day route with date", () => { + setWindowLocation("/day/2026-02-26?auth=forgot"); + const openModal = jest.fn(); + renderHook(() => useAuthUrlParam(openModal)); + + expect(openModal).toHaveBeenCalledWith("forgotPassword"); + expect(mockReplaceState).toHaveBeenCalledWith( + null, + "", + "/day/2026-02-26", + ); + }); + }); +}); diff --git a/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.ts b/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.ts new file mode 100644 index 000000000..47edef852 --- /dev/null +++ b/packages/web/src/components/AuthModal/hooks/useAuthUrlParam.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef } from "react"; +import { AuthView } from "./useAuthModal"; + +/** + * Maps URL parameter values to AuthView types + * Supports common URL-friendly names for each auth view + */ +const VIEW_MAP: Record = { + login: "login", + signup: "signUp", + forgot: "forgotPassword", +}; + +/** + * Hook that opens the auth modal based on URL parameters + * + * Reads the `?auth=` parameter and opens the modal to the corresponding view. + * After opening, the parameter is removed from the URL to prevent re-triggering + * on page refresh. + * + * @param openModal - Function to open the modal with a specific view + * + * @example + * // URL: /?auth=signup → Opens signup modal, URL becomes / + * // URL: /week?auth=login → Opens login modal, URL becomes /week + * // URL: /?auth=forgot → Opens forgot password modal + * + * Supported parameter values: + * - "login" → signIn view + * - "signup" → signUp view + * - "forgot" → forgotPassword view + */ +export function useAuthUrlParam(openModal: (view?: AuthView) => void): void { + const hasProcessedRef = useRef(false); + + useEffect(() => { + // Prevent double-trigger in React StrictMode + if (hasProcessedRef.current) { + return; + } + + const searchParams = new URLSearchParams(window.location.search); + const authParam = searchParams.get("auth"); + + if (!authParam) { + return; + } + + // Case-insensitive lookup + const normalizedParam = authParam.toLowerCase(); + const view = VIEW_MAP[normalizedParam]; + + if (view) { + hasProcessedRef.current = true; + openModal(view); + + // Remove the auth param from URL without adding history entry + searchParams.delete("auth"); + const newSearch = searchParams.toString(); + const newUrl = + window.location.pathname + + (newSearch ? `?${newSearch}` : "") + + window.location.hash; + window.history.replaceState(null, "", newUrl); + } + }, [openModal]); +} From 46516b2ad2707bf47f1cd873d64b848303b13892 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 11:41:23 -0800 Subject: [PATCH 21/34] chore: remove console log from auth modal --- packages/web/src/components/AuthModal/AuthModal.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index 99ec2eddd..e1657edb7 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -51,13 +51,11 @@ export const AuthModal: FC = () => { const handleSignUp = useCallback((data: SignUpFormData) => { // TODO: Implement email/password sign up API call in Phase 2 // For now, this is UI-only - backend integration will be added later - console.log(data); }, []); const handleLogin = useCallback((data: LogInFormData) => { // TODO: Implement email/password sign in API call in Phase 2 // For now, this is UI-only - backend integration will be added later - console.log(data); }, []); const handleForgotPassword = useCallback(() => { From 8cb91e75569376a1c4ca4c4dff70004de041bfaf Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 13:29:38 -0800 Subject: [PATCH 22/34] feat(tailwind): enhance zIndex configuration for improved layering of UI components - Added detailed zIndex layers in tailwind.config.ts to manage stacking order for calendar events, overlays, and interactive elements. - Updated OverlayPanel, BaseContextMenu, and StorageInfoModal components to utilize the new zIndex values for consistent layering across the application. - Removed direct references to ZIndex constants in components to streamline styling and improve maintainability. --- .../components/OverlayPanel/OverlayPanel.tsx | 2 +- .../AgendaEventPreview/AgendaEventPreview.tsx | 3 +-- .../ContextMenu/BaseContextMenu.tsx | 8 ++----- packages/web/tailwind.config.ts | 21 +++++++++++++++++++ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/web/src/components/OverlayPanel/OverlayPanel.tsx b/packages/web/src/components/OverlayPanel/OverlayPanel.tsx index c19d029a5..5faf2b0f1 100644 --- a/packages/web/src/components/OverlayPanel/OverlayPanel.tsx +++ b/packages/web/src/components/OverlayPanel/OverlayPanel.tsx @@ -31,7 +31,7 @@ export const OverlayPanel = ({ variant = "modal", }: Props) => { const backdropClasses = clsx( - "fixed inset-0 z-[1000] flex items-center justify-center bg-bg-primary/85 backdrop-blur-sm", + "fixed inset-0 z-layer-20 flex items-center justify-center bg-bg-primary/85 backdrop-blur-sm", ); const panelClasses = clsx("flex flex-col items-center", { diff --git a/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx b/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx index 81fea7403..51e14d6c8 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx @@ -8,7 +8,6 @@ import { import { useObservable } from "@ngneat/use-observable"; import { Priorities } from "@core/constants/core.constants"; import { darken, isDark } from "@core/util/color.utils"; -import { ZIndex } from "@web/common/constants/web.constants"; import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; import { CursorItem, @@ -62,7 +61,7 @@ export function AgendaEventPreview({ role="dialog" aria-labelledby="event-title" aria-describedby={event?.description ? "event-description" : undefined} - className={`z-${ZIndex.LAYER_5} max-w-80 min-w-64 rounded-lg p-4 shadow-lg`} + className="max-w-80 min-w-64 rounded-lg p-4 shadow-lg" style={{ ...floating.context.floatingStyles, backgroundColor: darkPriorityColor, diff --git a/packages/web/src/views/Day/components/ContextMenu/BaseContextMenu.tsx b/packages/web/src/views/Day/components/ContextMenu/BaseContextMenu.tsx index 7b44fc708..60f0f5d01 100644 --- a/packages/web/src/views/Day/components/ContextMenu/BaseContextMenu.tsx +++ b/packages/web/src/views/Day/components/ContextMenu/BaseContextMenu.tsx @@ -12,7 +12,6 @@ import { useInteractions, useRole, } from "@floating-ui/react"; -import { ZIndex } from "@web/common/constants/web.constants"; interface BaseContextMenuProps { onOutsideClick: () => void; @@ -44,13 +43,10 @@ export const BaseContextMenu = forwardRef( return createElement("ul", { ...getFloatingProps(props), className: classNames( - "bg-bg-secondary absolute min-w-[160px] list-none rounded", + "bg-bg-secondary absolute z-layer-30 min-w-[160px] list-none rounded", "border border-gray-600 shadow-md", ), - style: { - ...props.style, - zIndex: ZIndex.LAYER_5, - }, + style: props.style, ref, }); }, diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts index 712aabd32..a8119d43c 100644 --- a/packages/web/tailwind.config.ts +++ b/packages/web/tailwind.config.ts @@ -18,6 +18,27 @@ const config: Config = { borderRadius: { DEFAULT: "4px", }, + zIndex: { + // Layer 1-5: Grid-level stacking for calendar events + "layer-1": "1", // Base grid events + "layer-2": "2", // Now line, time indicators + "layer-3": "3", // Event text, icons + "layer-4": "4", // Resize handles, scalers + "layer-5": "5", // Sticky headers, edge indicators + + // Layer 10: Floating UI elements + "layer-10": "50", // Dropdowns, popovers, datepickers + + // Layer 20: Overlay backgrounds and content + "layer-20": "100", // Modal/overlay backdrops + "layer-21": "101", // Modal/overlay content (above backdrop) + + // Layer 30: Top-level interactive elements + "layer-30": "200", // Tooltips, context menus + + // Max: Absolute top priority + max: "9999", // Keyboard shortcuts overlay, emergency top + }, }, }, plugins: [], From c053e50334476a6381cb44cb08a86ccd725e9072 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 13:30:02 -0800 Subject: [PATCH 23/34] chore: remove StorageInfoModal component - Deleted the StorageInfoModal component from the project to streamline the codebase and improve maintainability. - This change follows recent updates to zIndex configurations and component layering, ensuring a cleaner architecture. --- .../StorageInfoModal/StorageInfoModal.tsx | 163 ------------------ 1 file changed, 163 deletions(-) delete mode 100644 packages/web/src/views/Day/components/StorageInfoModal/StorageInfoModal.tsx diff --git a/packages/web/src/views/Day/components/StorageInfoModal/StorageInfoModal.tsx b/packages/web/src/views/Day/components/StorageInfoModal/StorageInfoModal.tsx deleted file mode 100644 index 92c7fc0b1..000000000 --- a/packages/web/src/views/Day/components/StorageInfoModal/StorageInfoModal.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useEffect, useRef } from "react"; -import { ZIndex } from "@web/common/constants/web.constants"; -import { theme } from "@web/common/styles/theme"; -import { InfoIcon } from "@web/components/Icons/Info"; -import { StyledXIcon } from "@web/components/Icons/X"; -import { markStorageInfoAsSeen } from "../../../../common/utils/storage/storage.util"; - -interface StorageInfoModalProps { - isOpen: boolean; - onClose: () => void; -} - -export const StorageInfoModal = ({ - isOpen, - onClose, -}: StorageInfoModalProps) => { - const modalRef = useRef(null); - const previousActiveElementRef = useRef(null); - - useEffect(() => { - if (isOpen) { - // Mark as seen when modal is opened - markStorageInfoAsSeen(); - - // Store the currently focused element before opening modal - previousActiveElementRef.current = - document.activeElement as HTMLElement | null; - - // Focus the modal container - if (modalRef.current) { - const firstFocusableElement = modalRef.current.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ) as HTMLElement | null; - firstFocusableElement?.focus(); - } - } else { - // Restore focus to the previously focused element when modal closes - if (previousActiveElementRef.current) { - previousActiveElementRef.current.focus(); - previousActiveElementRef.current = null; - } - } - }, [isOpen]); - - useEffect(() => { - if (!isOpen) { - return; - } - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - return; - } - - // Trap focus within modal using Tab key - if (e.key === "Tab" && modalRef.current) { - const focusableElements = modalRef.current.querySelectorAll( - 'button:not([tabindex="-1"]), [href]:not([tabindex="-1"]), input:not([tabindex="-1"]), select:not([tabindex="-1"]), textarea:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])', - ); - - const firstFocusableElement = focusableElements[0] as HTMLElement; - const lastFocusableElement = focusableElements[ - focusableElements.length - 1 - ] as HTMLElement; - - if (e.shiftKey) { - // Shift + Tab - if (document.activeElement === firstFocusableElement) { - e.preventDefault(); - lastFocusableElement?.focus(); - } - } else { - // Tab - if (document.activeElement === lastFocusableElement) { - e.preventDefault(); - firstFocusableElement?.focus(); - } - } - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [isOpen, onClose]); - - if (!isOpen) { - return null; - } - - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - - return ( -
- - -
- -

- Browser Storage -

-
- -
-

- Day tasks are saved in your browser's local storage. Clearing - your browser data will remove them. -

-

Think of day tasks as simple ways to stay focused on today.

-

- Your calendar events are safely backed up and not - stored in your browser. -

-
- -
- -
-
-
- ); -}; From 20dd15529306221a3d63ace208b742a0c2d91808 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 13:43:20 -0800 Subject: [PATCH 24/34] refactor(auth): simplify AuthButton styles by removing redundant font weight - Updated the AuthButton component to remove the font weight class for a cleaner style definition. - This change enhances the button's appearance while maintaining its functionality and responsiveness. --- packages/web/src/components/AuthModal/components/AuthButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/AuthModal/components/AuthButton.tsx b/packages/web/src/components/AuthModal/components/AuthButton.tsx index 5e0cb29d5..c6d26f91d 100644 --- a/packages/web/src/components/AuthModal/components/AuthButton.tsx +++ b/packages/web/src/components/AuthModal/components/AuthButton.tsx @@ -31,7 +31,7 @@ export const AuthButton: FC = ({ - -
- -

- Browser Storage -

-
- -
-

- Day tasks are saved in your browser's local storage. Clearing - your browser data will remove them. -

-

Think of day tasks as simple ways to stay focused on today.

-

- Your calendar events are safely backed up and not - stored in your browser. -

-
- -
- -
-
-
- ); -}; From 52d47036555164d9ab2601e143b77226fdc2c339 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 14:51:31 -0800 Subject: [PATCH 30/34] feat(auth): add useAuthCmdItems hook for authentication command palette - Introduced the useAuthCmdItems hook to provide command palette items for authentication actions when the user is not authenticated. - Integrated useAuthCmdItems into CmdPalette, DayCmdPalette, and NowCmdPalette components to enhance user experience with sign-up and log-in options. --- .../web/src/common/hooks/useAuthCmdItems.ts | 31 +++++++++++++++++++ .../web/src/views/CmdPalette/CmdPalette.tsx | 3 ++ .../views/Day/components/DayCmdPalette.tsx | 3 ++ .../views/Now/components/NowCmdPalette.tsx | 3 ++ 4 files changed, 40 insertions(+) create mode 100644 packages/web/src/common/hooks/useAuthCmdItems.ts diff --git a/packages/web/src/common/hooks/useAuthCmdItems.ts b/packages/web/src/common/hooks/useAuthCmdItems.ts new file mode 100644 index 000000000..cda539e76 --- /dev/null +++ b/packages/web/src/common/hooks/useAuthCmdItems.ts @@ -0,0 +1,31 @@ +import { JsonStructureItem } from "react-cmdk"; +import { useSession } from "@web/auth/hooks/session/useSession"; +import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; + +/** + * Returns command palette items for authentication actions. + * Items are only returned when the user is not authenticated. + */ +export const useAuthCmdItems = (): JsonStructureItem[] => { + const { authenticated } = useSession(); + const { openModal } = useAuthModal(); + + if (authenticated) { + return []; + } + + return [ + { + id: "sign-up", + children: "Sign Up", + icon: "UserPlusIcon", + onClick: () => openModal("signUp"), + }, + { + id: "log-in", + children: "Log In", + icon: "ArrowLeftOnRectangleIcon", + onClick: () => openModal("login"), + }, + ]; +}; diff --git a/packages/web/src/views/CmdPalette/CmdPalette.tsx b/packages/web/src/views/CmdPalette/CmdPalette.tsx index fcc8e97e6..a994d3ac4 100644 --- a/packages/web/src/views/CmdPalette/CmdPalette.tsx +++ b/packages/web/src/views/CmdPalette/CmdPalette.tsx @@ -8,6 +8,7 @@ import { import { Categories_Event } from "@core/types/event.types"; import { useConnectGoogle } from "@web/auth/hooks/oauth/useConnectGoogle"; import { moreCommandPaletteItems } from "@web/common/constants/more.cmd.constants"; +import { useAuthCmdItems } from "@web/common/hooks/useAuthCmdItems"; import { pressKey } from "@web/common/utils/dom/event-emitter.util"; import { onEventTargetVisibility } from "@web/common/utils/dom/event-target-visibility.util"; import { @@ -42,6 +43,7 @@ const CmdPalette = ({ const [search, setSearch] = useState(""); const { isGoogleCalendarConnected, onConnectGoogleCalendar } = useConnectGoogle(); + const authCmdItems = useAuthCmdItems(); const handleCreateSomedayDraft = async ( category: Categories_Event.SOMEDAY_WEEK | Categories_Event.SOMEDAY_MONTH, @@ -146,6 +148,7 @@ const CmdPalette = ({ ? undefined : onConnectGoogleCalendar, }, + ...authCmdItems, { id: "log-out", children: "Log Out [z]", diff --git a/packages/web/src/views/Day/components/DayCmdPalette.tsx b/packages/web/src/views/Day/components/DayCmdPalette.tsx index 1b35dc2b1..ea31d754e 100644 --- a/packages/web/src/views/Day/components/DayCmdPalette.tsx +++ b/packages/web/src/views/Day/components/DayCmdPalette.tsx @@ -5,6 +5,7 @@ import dayjs from "@core/util/date/dayjs"; import { useConnectGoogle } from "@web/auth/hooks/oauth/useConnectGoogle"; import { moreCommandPaletteItems } from "@web/common/constants/more.cmd.constants"; import { VIEW_SHORTCUTS } from "@web/common/constants/shortcuts.constants"; +import { useAuthCmdItems } from "@web/common/hooks/useAuthCmdItems"; import { pressKey } from "@web/common/utils/dom/event-emitter.util"; import { openEventFormCreateEvent, @@ -26,6 +27,7 @@ export const DayCmdPalette = ({ onGoToToday }: DayCmdPaletteProps) => { const today = dayjs(); const { isGoogleCalendarConnected, onConnectGoogleCalendar } = useConnectGoogle(); + const authCmdItems = useAuthCmdItems(); const filteredItems = filterItems( [ @@ -83,6 +85,7 @@ export const DayCmdPalette = ({ onGoToToday }: DayCmdPaletteProps) => { ? undefined : onConnectGoogleCalendar, }, + ...authCmdItems, { id: "log-out", children: "Log Out [z]", diff --git a/packages/web/src/views/Now/components/NowCmdPalette.tsx b/packages/web/src/views/Now/components/NowCmdPalette.tsx index 28cae9df0..06b4e6238 100644 --- a/packages/web/src/views/Now/components/NowCmdPalette.tsx +++ b/packages/web/src/views/Now/components/NowCmdPalette.tsx @@ -4,6 +4,7 @@ import "react-cmdk/dist/cmdk.css"; import { useConnectGoogle } from "@web/auth/hooks/oauth/useConnectGoogle"; import { moreCommandPaletteItems } from "@web/common/constants/more.cmd.constants"; import { VIEW_SHORTCUTS } from "@web/common/constants/shortcuts.constants"; +import { useAuthCmdItems } from "@web/common/hooks/useAuthCmdItems"; import { pressKey } from "@web/common/utils/dom/event-emitter.util"; import { onEventTargetVisibility } from "@web/common/utils/dom/event-target-visibility.util"; import { selectIsCmdPaletteOpen } from "@web/ducks/settings/selectors/settings.selectors"; @@ -17,6 +18,7 @@ export const NowCmdPalette = () => { const [search, setSearch] = useState(""); const { isGoogleCalendarConnected, onConnectGoogleCalendar } = useConnectGoogle(); + const authCmdItems = useAuthCmdItems(); const filteredItems = filterItems( [ @@ -60,6 +62,7 @@ export const NowCmdPalette = () => { ? undefined : onConnectGoogleCalendar, }, + ...authCmdItems, { id: "log-out", children: "Log Out [z]", From 073149d39d1874798bcad6fddb5f3f2c9d36a6c5 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 15:06:08 -0800 Subject: [PATCH 31/34] feat(simplify-code): add skill and heuristics for code simplification - Introduced a new skill for simplifying code, focusing on minimizing complexity, eliminating duplication, and enhancing legibility. - Added heuristics and examples to guide developers in applying simplification principles effectively. - Updated .gitignore to remove unnecessary entries and streamline the project structure. --- .claude/skills/simplify-code/SKILL.md | 299 +++++++++++++++++++++ .codex/skills/simplify-code/SKILL.md | 79 ++++++ .codex/skills/simplify-code/examples.md | 131 +++++++++ .codex/skills/simplify-code/heuristics.md | 94 +++++++ .cursor/rules/naming-convention.mdc | 3 + .cursor/skills/simplify-code/SKILL.md | 79 ++++++ .cursor/skills/simplify-code/examples.md | 131 +++++++++ .cursor/skills/simplify-code/heuristics.md | 94 +++++++ .gitignore | 1 - 9 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/simplify-code/SKILL.md create mode 100644 .codex/skills/simplify-code/SKILL.md create mode 100644 .codex/skills/simplify-code/examples.md create mode 100644 .codex/skills/simplify-code/heuristics.md create mode 100644 .cursor/rules/naming-convention.mdc create mode 100644 .cursor/skills/simplify-code/SKILL.md create mode 100644 .cursor/skills/simplify-code/examples.md create mode 100644 .cursor/skills/simplify-code/heuristics.md diff --git a/.claude/skills/simplify-code/SKILL.md b/.claude/skills/simplify-code/SKILL.md new file mode 100644 index 000000000..a5a4c16c6 --- /dev/null +++ b/.claude/skills/simplify-code/SKILL.md @@ -0,0 +1,299 @@ +--- +name: simplify-code +description: Simplifies code by minimizing complexity, eliminating duplication, and prioritizing legibility for contributors. Use when implementing features, fixing bugs, refactoring, or when the user asks to simplify, clean up, make DRY, reduce complexity, or improve maintainability. +--- + +# Simplify Code + +Favor minimal, legible implementations. Fewer lines and clearer structure make code easier to understand and maintain. + +## Before Making Changes + +1. **Necessity**: Does this change only address what's required? +2. **Existing abstractions**: Are there shared utilities, hooks, or helpers in `common/`, `util/`, etc.? +3. **Duplication**: Where else does similar logic exist? +4. **Root cause**: What is actually driving the complexity? + +## Core Principles + +### Minimal Surface Area + +- Prefer the smallest change that achieves the goal +- Add abstractions only when reuse is real and obvious +- Avoid "future-proofing" that adds complexity now + +### DRY + +- **Literal duplication**: Same logic in 2+ places → extract shared function +- **Similar structure**: Parameterize or compose instead of copying +- **Config-driven branches**: Use maps/objects instead of long switch/if chains +- **Do not over-DRY**: Don't unify logic that merely _looks_ similar; shared abstractions must cleanly handle all real cases + +### Legibility + +- One clear responsibility per function/component +- Guard clauses and early returns over deep nesting +- Functions under ~20 lines when feasible; flag functions over ~30 lines +- Each line readable without jumping around + +### Durability + +- Prefer built-ins and stable, minimal dependencies +- Abstractions with narrow, stable contracts +- Avoid hidden state and surprising side effects + +## When to Extract vs. Inline + +**Extract when:** + +- Logic is used in 2+ places with shared intent +- A block has a clear, reusable name +- The abstraction has a narrow, stable API + +**Do not extract when:** + +- Used once and clear inline +- The abstraction would need many params or special cases +- The abstraction name would be vague (e.g. `doStuff`) + +## Complexity Thresholds + +| Metric | Prefer | Flag | Action | +| -------------------- | ---------- | ---------- | ---------------------------- | +| Function length | < 20 lines | > 30 lines | Split or extract | +| Nesting depth | ≤ 2 levels | > 3 levels | Guard clauses, early returns | +| Parameters | ≤ 3 | > 4 | Options object or context | +| Conditional branches | ≤ 3 | > 4 | Map/object or polymorphism | +| Similar blocks | 0 | 2+ | Extract and parameterize | + +## DRY Detection Rules + +### Extract when you see + +- Same expression or block in 2+ places +- Copy-paste with variable name changes only +- Parallel structures (e.g. handlers for A and B that mirror each other) +- Repeated validation, formatting, or mapping logic + +### Parameterize when + +- Logic is identical except for a value or small behavior +- A good abstraction name exists (e.g. `formatDate`, `validateEmail`) +- The parameter surface is small (< 4 params typically) + +### Do not extract when + +- Used once and inline logic is clear +- Abstraction would need many optional params or flags +- Name would be generic (`handleThing`, `processData`) +- Only superficial similarity — real behavior diverges +- Combining would obscure intent or create hidden coupling + +## Nesting Reduction + +**Preferred**: Guard clauses and early returns + +``` +if (!user?.isActive) return; +if (!user.hasPermission) return; +doThing(); +``` + +**Avoid**: Deep nesting + +``` +if (user) { + if (user.isActive) { + if (user.hasPermission) { + doThing(); + } + } +} +``` + +## Config Over Conditionals + +**When**: Multiple similar branches based on a key or type + +**Instead of**: Long switch or if-else chain + +``` +switch (type) { + case 'a': return handleA(); + case 'b': return handleB(); + case 'c': return handleC(); +} +``` + +**Prefer**: Map or object lookup (when handlers are simple) + +``` +const handlers = { a: handleA, b: handleB, c: handleC }; +return handlers[type]?.(); +``` + +## Project-Aware Simplification + +Before adding new abstractions: + +1. Search for existing utilities in `common/`, `util/`, `hooks/` +2. Check sibling components or similar views for shared patterns +3. Follow established conventions (e.g. naming, file layout) +4. Prefer composition over new inheritance + +## Durability Checklist + +- [ ] Abstraction has a clear, narrow contract +- [ ] No hidden globals or mutable shared state +- [ ] Types/interfaces document inputs and outputs +- [ ] Dependencies are minimal and stable +- [ ] Change is incremental; no big-bang refactors + +## Anti-Patterns to Avoid + +- Abstracting "for the future" without current reuse +- Clever one-liners that obscure intent +- Premature micro-optimization over clarity +- Collapsing unrelated responsibilities into one abstraction +- Overly deep inheritance or generic hierarchies + +## Output Format + +When proposing simplifications: + +1. One-sentence summary of the change +2. Minimal diff or before/after +3. Principle(s) applied (DRY, legibility, etc.) +4. Any tradeoffs noted + +## Examples + +### Guard Clauses vs. Nesting + +**Before:** + +```ts +function processUser(user: User | null) { + if (user) { + if (user.isActive) { + if (user.hasPermission) { + return doThing(user); + } + } + } + return null; +} +``` + +**After:** + +```ts +function processUser(user: User | null) { + if (!user?.isActive || !user.hasPermission) return null; + return doThing(user); +} +``` + +### Single Pass vs. Repeated Iteration + +**Before:** + +```ts +const names = items.map((i) => i.name); +const ids = items.map((i) => i.id); +const active = items.filter((i) => i.active); +``` + +**After** (when two+ iterations over same array): + +```ts +const { names, ids, active } = items.reduce( + (acc, i) => ({ + names: [...acc.names, i.name], + ids: [...acc.ids, i.id], + active: i.active ? [...acc.active, i] : acc.active, + }), + { names: [] as string[], ids: [] as string[], active: [] as Item[] }, +); +``` + +_Note:_ Keep separate passes if they are clearer; avoid reduce when simple map/filter is more readable. + +### Config-Driven Handlers + +**Before:** + +```ts +function getLabel(type: string) { + if (type === "email") return "Email"; + if (type === "phone") return "Phone"; + if (type === "address") return "Address"; + return "Unknown"; +} +``` + +**After:** + +```ts +const LABELS: Record = { + email: "Email", + phone: "Phone", + address: "Address", +}; +const getLabel = (type: string) => LABELS[type] ?? "Unknown"; +``` + +### Duplicated Logic → Shared Helper + +**Before:** + +```ts +// In ComponentA +const formatted = `${user.firstName} ${user.lastName}`.trim(); + +// In ComponentB +const displayName = `${user.firstName} ${user.lastName}`.trim(); +``` + +**After:** + +```ts +// common/util/formatUser.ts +export const formatFullName = (user: { firstName: string; lastName: string }) => + `${user.firstName} ${user.lastName}`.trim(); +``` + +### Inline When Single Use + +**Before** (over-extraction): + +```ts +const getIsValid = (x: number) => x > 0 && x < 100; +if (getIsValid(value)) { ... } +``` + +**After:** + +```ts +if (value > 0 && value < 100) { ... } +``` + +### Composing Hooks Instead of Duplication + +**Before** (similar logic in two components): + +```ts +// DayCmdPalette.tsx +const authItems = isLoggedIn ? [logoutItem] : [loginItem, signupItem]; + +// NowCmdPalette.tsx +const authItems = isLoggedIn ? [logoutItem] : [loginItem, signupItem]; +``` + +**After:** + +```ts +// useAuthCmdItems.ts +export const useAuthCmdItems = (isLoggedIn: boolean) => + isLoggedIn ? [logoutItem] : [loginItem, signupItem]; +``` diff --git a/.codex/skills/simplify-code/SKILL.md b/.codex/skills/simplify-code/SKILL.md new file mode 100644 index 000000000..1d58dcdf0 --- /dev/null +++ b/.codex/skills/simplify-code/SKILL.md @@ -0,0 +1,79 @@ +--- +name: codex-simplify-code +description: Simplifies code by minimizing complexity, eliminating duplication, and prioritizing legibility for contributors. Use when implementing features, fixing bugs, refactoring, or when the user asks to simplify, clean up, make DRY, reduce complexity, or improve maintainability. +--- + +# Simplify Code + +Favor minimal, legible implementations. Fewer lines and clearer structure make code easier to understand and maintain. + +## Before Making Changes + +1. **Necessity**: Does this change only address what's required? +2. **Existing abstractions**: Are there shared utilities, hooks, or helpers in `common/`, `util/`, etc.? +3. **Duplication**: Where else does similar logic exist? +4. **Root cause**: What is actually driving the complexity? + +## Core Principles + +### Minimal Surface Area + +- Prefer the smallest change that achieves the goal +- Add abstractions only when reuse is real and obvious +- Avoid "future-proofing" that adds complexity now + +### DRY + +- **Literal duplication**: Same logic in 2+ places → extract shared function +- **Similar structure**: Parameterize or compose instead of copying +- **Config-driven branches**: Use maps/objects instead of long switch/if chains +- **Do not over-DRY**: Don't unify logic that merely _looks_ similar; shared abstractions must cleanly handle all real cases + +### Legibility + +- One clear responsibility per function/component +- Guard clauses and early returns over deep nesting +- Functions under ~20 lines when feasible; flag functions over ~30 lines +- Each line readable without jumping around + +### Durability + +- Prefer built-ins and stable, minimal dependencies +- Abstractions with narrow, stable contracts +- Avoid hidden state and surprising side effects + +## When to Extract vs. Inline + +**Extract when:** + +- Logic is used in 2+ places with shared intent +- A block has a clear, reusable name +- The abstraction has a narrow, stable API + +**Do not extract when:** + +- Used once and clear inline +- The abstraction would need many params or special cases +- The abstraction name would be vague (e.g. `doStuff`) + +## Anti-Patterns to Avoid + +- Abstracting "for the future" without current reuse +- Clever one-liners that obscure intent +- Premature micro-optimization over clarity +- Collapsing unrelated responsibilities into one abstraction +- Overly deep inheritance or generic hierarchies + +## Output Format + +When proposing simplifications: + +1. One-sentence summary of the change +2. Minimal diff or before/after +3. Principle(s) applied (DRY, legibility, etc.) +4. Any tradeoffs noted + +## Additional Resources + +- For detailed heuristics and decision rules, see [heuristics.md](heuristics.md) +- For before/after patterns, see [examples.md](examples.md) diff --git a/.codex/skills/simplify-code/examples.md b/.codex/skills/simplify-code/examples.md new file mode 100644 index 000000000..fb4a02e32 --- /dev/null +++ b/.codex/skills/simplify-code/examples.md @@ -0,0 +1,131 @@ +# Simplify Code — Examples + +## Guard Clauses vs. Nesting + +**Before:** + +```ts +function processUser(user: User | null) { + if (user) { + if (user.isActive) { + if (user.hasPermission) { + return doThing(user); + } + } + } + return null; +} +``` + +**After:** + +```ts +function processUser(user: User | null) { + if (!user?.isActive || !user.hasPermission) return null; + return doThing(user); +} +``` + +## Single Pass vs. Repeated Iteration + +**Before:** + +```ts +const names = items.map((i) => i.name); +const ids = items.map((i) => i.id); +const active = items.filter((i) => i.active); +``` + +**After** (when two+ iterations over same array): + +```ts +const { names, ids, active } = items.reduce( + (acc, i) => ({ + names: [...acc.names, i.name], + ids: [...acc.ids, i.id], + active: i.active ? [...acc.active, i] : acc.active, + }), + { names: [] as string[], ids: [] as string[], active: [] as Item[] }, +); +``` + +_Note:_ Keep separate passes if they are clearer; avoid reduce when simple map/filter is more readable. + +## Config-Driven Handlers + +**Before:** + +```ts +function getLabel(type: string) { + if (type === "email") return "Email"; + if (type === "phone") return "Phone"; + if (type === "address") return "Address"; + return "Unknown"; +} +``` + +**After:** + +```ts +const LABELS: Record = { + email: "Email", + phone: "Phone", + address: "Address", +}; +const getLabel = (type: string) => LABELS[type] ?? "Unknown"; +``` + +## Duplicated Logic → Shared Helper + +**Before:** + +```ts +// In ComponentA +const formatted = `${user.firstName} ${user.lastName}`.trim(); + +// In ComponentB +const displayName = `${user.firstName} ${user.lastName}`.trim(); +``` + +**After:** + +```ts +// common/util/formatUser.ts +export const formatFullName = (user: { firstName: string; lastName: string }) => + `${user.firstName} ${user.lastName}`.trim(); +``` + +## Inline When Single Use + +**Before** (over-extraction): + +```ts +const getIsValid = (x: number) => x > 0 && x < 100; +if (getIsValid(value)) { ... } +``` + +**After:** + +```ts +if (value > 0 && value < 100) { ... } +``` + +## Composing Hooks Instead of Duplication + +**Before** (similar logic in two components): + +```ts +// DayCmdPalette.tsx +const authItems = isLoggedIn ? [logoutItem] : [loginItem, signupItem]; + +// NowCmdPalette.tsx +const authItems = isLoggedIn ? [logoutItem] : [loginItem, signupItem]; +``` + +**After:** + +```ts +// useAuthCmdItems.ts +export const useAuthCmdItems = (isLoggedIn: boolean) => + isLoggedIn ? [logoutItem] : [loginItem, signupItem]; +``` diff --git a/.codex/skills/simplify-code/heuristics.md b/.codex/skills/simplify-code/heuristics.md new file mode 100644 index 000000000..50d68ed50 --- /dev/null +++ b/.codex/skills/simplify-code/heuristics.md @@ -0,0 +1,94 @@ +# Simplify Code — Heuristics + +## Complexity Thresholds + +| Metric | Prefer | Flag | Action | +| -------------------- | ---------- | ---------- | ---------------------------- | +| Function length | < 20 lines | > 30 lines | Split or extract | +| Nesting depth | ≤ 2 levels | > 3 levels | Guard clauses, early returns | +| Parameters | ≤ 3 | > 4 | Options object or context | +| Conditional branches | ≤ 3 | > 4 | Map/object or polymorphism | +| Similar blocks | 0 | 2+ | Extract and parameterize | + +## DRY Detection Rules + +### Extract when you see + +- Same expression or block in 2+ places +- Copy-paste with variable name changes only +- Parallel structures (e.g. handlers for A and B that mirror each other) +- Repeated validation, formatting, or mapping logic + +### Parameterize when + +- Logic is identical except for a value or small behavior +- A good abstraction name exists (e.g. `formatDate`, `validateEmail`) +- The parameter surface is small (< 4 params typically) + +### Do not extract when + +- Used once and inline logic is clear +- Abstraction would need many optional params or flags +- Name would be generic (`handleThing`, `processData`) +- Only superficial similarity — real behavior diverges +- Combining would obscure intent or create hidden coupling + +## Nesting Reduction + +**Preferred**: Guard clauses and early returns + +``` +if (!user?.isActive) return; +if (!user.hasPermission) return; +doThing(); +``` + +**Avoid**: Deep nesting + +``` +if (user) { + if (user.isActive) { + if (user.hasPermission) { + doThing(); + } + } +} +``` + +## Config Over Conditionals + +**When**: Multiple similar branches based on a key or type + +**Instead of**: Long switch or if-else chain + +``` +switch (type) { + case 'a': return handleA(); + case 'b': return handleB(); + case 'c': return handleC(); +} +``` + +**Prefer**: Map or object lookup (when handlers are simple) + +``` +const handlers = { a: handleA, b: handleB, c: handleC }; +return handlers[type]?.(); +``` + +## Project-Aware Simplification + +Before adding new abstractions: + +1. Search for existing utilities in `common/`, `util/`, `hooks/` +2. Check sibling components or similar views for shared patterns +3. Follow established conventions (e.g. naming, file layout) +4. Prefer composition over new inheritance + +## Durability Checklist + +- [ ] Abstraction has a clear, narrow contract +- [ ] No hidden globals or mutable shared state +- [ ] Types/interfaces document inputs and outputs +- [ ] Dependencies are minimal and stable +- [ ] Change is incremental; no big-bang refactors diff --git a/.cursor/rules/naming-convention.mdc b/.cursor/rules/naming-convention.mdc new file mode 100644 index 000000000..3dca9096c --- /dev/null +++ b/.cursor/rules/naming-convention.mdc @@ -0,0 +1,3 @@ +--- +alwaysApply: true +--- diff --git a/.cursor/skills/simplify-code/SKILL.md b/.cursor/skills/simplify-code/SKILL.md new file mode 100644 index 000000000..563036126 --- /dev/null +++ b/.cursor/skills/simplify-code/SKILL.md @@ -0,0 +1,79 @@ +--- +name: simplify-code +description: Simplifies code by minimizing complexity, eliminating duplication, and prioritizing legibility for contributors. Use when implementing features, fixing bugs, refactoring, or when the user asks to simplify, clean up, make DRY, reduce complexity, or improve maintainability. +--- + +# Simplify Code + +Favor minimal, legible implementations. Fewer lines and clearer structure make code easier to understand and maintain. + +## Before Making Changes + +1. **Necessity**: Does this change only address what's required? +2. **Existing abstractions**: Are there shared utilities, hooks, or helpers in `common/`, `util/`, etc.? +3. **Duplication**: Where else does similar logic exist? +4. **Root cause**: What is actually driving the complexity? + +## Core Principles + +### Minimal Surface Area + +- Prefer the smallest change that achieves the goal +- Add abstractions only when reuse is real and obvious +- Avoid "future-proofing" that adds complexity now + +### DRY + +- **Literal duplication**: Same logic in 2+ places → extract shared function +- **Similar structure**: Parameterize or compose instead of copying +- **Config-driven branches**: Use maps/objects instead of long switch/if chains +- **Do not over-DRY**: Don't unify logic that merely _looks_ similar; shared abstractions must cleanly handle all real cases + +### Legibility + +- One clear responsibility per function/component +- Guard clauses and early returns over deep nesting +- Functions under ~20 lines when feasible; flag functions over ~30 lines +- Each line readable without jumping around + +### Durability + +- Prefer built-ins and stable, minimal dependencies +- Abstractions with narrow, stable contracts +- Avoid hidden state and surprising side effects + +## When to Extract vs. Inline + +**Extract when:** + +- Logic is used in 2+ places with shared intent +- A block has a clear, reusable name +- The abstraction has a narrow, stable API + +**Do not extract when:** + +- Used once and clear inline +- The abstraction would need many params or special cases +- The abstraction name would be vague (e.g. `doStuff`) + +## Anti-Patterns to Avoid + +- Abstracting "for the future" without current reuse +- Clever one-liners that obscure intent +- Premature micro-optimization over clarity +- Collapsing unrelated responsibilities into one abstraction +- Overly deep inheritance or generic hierarchies + +## Output Format + +When proposing simplifications: + +1. One-sentence summary of the change +2. Minimal diff or before/after +3. Principle(s) applied (DRY, legibility, etc.) +4. Any tradeoffs noted + +## Additional Resources + +- For detailed heuristics and decision rules, see [heuristics.md](heuristics.md) +- For before/after patterns, see [examples.md](examples.md) diff --git a/.cursor/skills/simplify-code/examples.md b/.cursor/skills/simplify-code/examples.md new file mode 100644 index 000000000..fb4a02e32 --- /dev/null +++ b/.cursor/skills/simplify-code/examples.md @@ -0,0 +1,131 @@ +# Simplify Code — Examples + +## Guard Clauses vs. Nesting + +**Before:** + +```ts +function processUser(user: User | null) { + if (user) { + if (user.isActive) { + if (user.hasPermission) { + return doThing(user); + } + } + } + return null; +} +``` + +**After:** + +```ts +function processUser(user: User | null) { + if (!user?.isActive || !user.hasPermission) return null; + return doThing(user); +} +``` + +## Single Pass vs. Repeated Iteration + +**Before:** + +```ts +const names = items.map((i) => i.name); +const ids = items.map((i) => i.id); +const active = items.filter((i) => i.active); +``` + +**After** (when two+ iterations over same array): + +```ts +const { names, ids, active } = items.reduce( + (acc, i) => ({ + names: [...acc.names, i.name], + ids: [...acc.ids, i.id], + active: i.active ? [...acc.active, i] : acc.active, + }), + { names: [] as string[], ids: [] as string[], active: [] as Item[] }, +); +``` + +_Note:_ Keep separate passes if they are clearer; avoid reduce when simple map/filter is more readable. + +## Config-Driven Handlers + +**Before:** + +```ts +function getLabel(type: string) { + if (type === "email") return "Email"; + if (type === "phone") return "Phone"; + if (type === "address") return "Address"; + return "Unknown"; +} +``` + +**After:** + +```ts +const LABELS: Record = { + email: "Email", + phone: "Phone", + address: "Address", +}; +const getLabel = (type: string) => LABELS[type] ?? "Unknown"; +``` + +## Duplicated Logic → Shared Helper + +**Before:** + +```ts +// In ComponentA +const formatted = `${user.firstName} ${user.lastName}`.trim(); + +// In ComponentB +const displayName = `${user.firstName} ${user.lastName}`.trim(); +``` + +**After:** + +```ts +// common/util/formatUser.ts +export const formatFullName = (user: { firstName: string; lastName: string }) => + `${user.firstName} ${user.lastName}`.trim(); +``` + +## Inline When Single Use + +**Before** (over-extraction): + +```ts +const getIsValid = (x: number) => x > 0 && x < 100; +if (getIsValid(value)) { ... } +``` + +**After:** + +```ts +if (value > 0 && value < 100) { ... } +``` + +## Composing Hooks Instead of Duplication + +**Before** (similar logic in two components): + +```ts +// DayCmdPalette.tsx +const authItems = isLoggedIn ? [logoutItem] : [loginItem, signupItem]; + +// NowCmdPalette.tsx +const authItems = isLoggedIn ? [logoutItem] : [loginItem, signupItem]; +``` + +**After:** + +```ts +// useAuthCmdItems.ts +export const useAuthCmdItems = (isLoggedIn: boolean) => + isLoggedIn ? [logoutItem] : [loginItem, signupItem]; +``` diff --git a/.cursor/skills/simplify-code/heuristics.md b/.cursor/skills/simplify-code/heuristics.md new file mode 100644 index 000000000..50d68ed50 --- /dev/null +++ b/.cursor/skills/simplify-code/heuristics.md @@ -0,0 +1,94 @@ +# Simplify Code — Heuristics + +## Complexity Thresholds + +| Metric | Prefer | Flag | Action | +| -------------------- | ---------- | ---------- | ---------------------------- | +| Function length | < 20 lines | > 30 lines | Split or extract | +| Nesting depth | ≤ 2 levels | > 3 levels | Guard clauses, early returns | +| Parameters | ≤ 3 | > 4 | Options object or context | +| Conditional branches | ≤ 3 | > 4 | Map/object or polymorphism | +| Similar blocks | 0 | 2+ | Extract and parameterize | + +## DRY Detection Rules + +### Extract when you see + +- Same expression or block in 2+ places +- Copy-paste with variable name changes only +- Parallel structures (e.g. handlers for A and B that mirror each other) +- Repeated validation, formatting, or mapping logic + +### Parameterize when + +- Logic is identical except for a value or small behavior +- A good abstraction name exists (e.g. `formatDate`, `validateEmail`) +- The parameter surface is small (< 4 params typically) + +### Do not extract when + +- Used once and inline logic is clear +- Abstraction would need many optional params or flags +- Name would be generic (`handleThing`, `processData`) +- Only superficial similarity — real behavior diverges +- Combining would obscure intent or create hidden coupling + +## Nesting Reduction + +**Preferred**: Guard clauses and early returns + +``` +if (!user?.isActive) return; +if (!user.hasPermission) return; +doThing(); +``` + +**Avoid**: Deep nesting + +``` +if (user) { + if (user.isActive) { + if (user.hasPermission) { + doThing(); + } + } +} +``` + +## Config Over Conditionals + +**When**: Multiple similar branches based on a key or type + +**Instead of**: Long switch or if-else chain + +``` +switch (type) { + case 'a': return handleA(); + case 'b': return handleB(); + case 'c': return handleC(); +} +``` + +**Prefer**: Map or object lookup (when handlers are simple) + +``` +const handlers = { a: handleA, b: handleB, c: handleC }; +return handlers[type]?.(); +``` + +## Project-Aware Simplification + +Before adding new abstractions: + +1. Search for existing utilities in `common/`, `util/`, `hooks/` +2. Check sibling components or similar views for shared patterns +3. Follow established conventions (e.g. naming, file layout) +4. Prefer composition over new inheritance + +## Durability Checklist + +- [ ] Abstraction has a clear, narrow contract +- [ ] No hidden globals or mutable shared state +- [ ] Types/interfaces document inputs and outputs +- [ ] Dependencies are minimal and stable +- [ ] Change is incremental; no big-bang refactors diff --git a/.gitignore b/.gitignore index 666567fd6..6b369e476 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ packages/**/yarn.lock ######## # root .claude/settings.local.json -.cursor/ .idea/ .mcp.json .vscode/ From 98a8d7724aebf87930ada8b0fd4b0213fa366465 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 15:35:39 -0800 Subject: [PATCH 32/34] feat(event-form): enhance fillTitleAndSaveWithKeyboard function to wait for form closure - Added a wait condition to ensure the title input is hidden after saving, confirming the form closure and completion of the save action. - This improvement enhances the reliability of the event form submission process in end-to-end tests. --- e2e/utils/event-test-utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/utils/event-test-utils.ts b/e2e/utils/event-test-utils.ts index ad79b938a..467489180 100644 --- a/e2e/utils/event-test-utils.ts +++ b/e2e/utils/event-test-utils.ts @@ -213,6 +213,8 @@ export const fillTitleAndSaveWithKeyboard = async ( await expect(titleInput).toBeVisible({ timeout: FORM_TIMEOUT }); await titleInput.fill(title); await page.keyboard.press("Enter"); + // Wait for form to close, confirming the save completed + await titleInput.waitFor({ state: "hidden", timeout: FORM_TIMEOUT }); }; export const openTimedEventFormWithMouse = async (page: Page) => { From 7ea23c6a462ff3fb79d650664c0af75f27885160 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 16:28:31 -0800 Subject: [PATCH 33/34] feat(NowLine): enhance z-index management and add test for z-index behavior - Integrated useGridMaxZIndex hook to dynamically set the z-index of the NowLine component, ensuring it appears above other elements. - Updated the NowLine test suite to include a new test case verifying that the z-index is set correctly. - Refactored the NowLine component to remove hardcoded z-index values, improving maintainability and consistency with recent z-index updates. --- packages/web/src/common/hooks/useAuthCmdItems.ts | 5 ++++- .../views/Day/components/Agenda/NowLine/NowLine.test.tsx | 7 +++++++ .../src/views/Day/components/Agenda/NowLine/NowLine.tsx | 5 ++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/web/src/common/hooks/useAuthCmdItems.ts b/packages/web/src/common/hooks/useAuthCmdItems.ts index cda539e76..451cb2ca1 100644 --- a/packages/web/src/common/hooks/useAuthCmdItems.ts +++ b/packages/web/src/common/hooks/useAuthCmdItems.ts @@ -9,8 +9,11 @@ import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; export const useAuthCmdItems = (): JsonStructureItem[] => { const { authenticated } = useSession(); const { openModal } = useAuthModal(); + const isAuthFeatureEnabled = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).has("auth"); - if (authenticated) { + if (authenticated || !isAuthFeatureEnabled) { return []; } diff --git a/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.test.tsx b/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.test.tsx index 9deddb748..eac7021cd 100644 --- a/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.test.tsx +++ b/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.test.tsx @@ -44,6 +44,13 @@ describe("NowLine", () => { expect(nowLineElement).toHaveStyle({ top: "100px" }); }); + it("sets z-index above the max grid z-index", () => { + const { container } = render(); + + const nowLineElement = container.querySelector('[data-now-marker="true"]'); + expect(nowLineElement).toHaveStyle({ zIndex: "11" }); + }); + it("updates time on minute sync", () => { let syncCallback: () => void; (setupMinuteSync as jest.Mock).mockImplementation((cb) => { diff --git a/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.tsx b/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.tsx index f53f77b20..f090bff55 100644 --- a/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.tsx +++ b/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; import { fromEvent, share } from "rxjs"; +import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; import { CompassDOMEvents, compassEventEmitter, @@ -16,6 +17,7 @@ const scroll$ = fromEvent( ).pipe(share()); export const NowLine = memo(function NowLine() { + const maxZIndex = useGridMaxZIndex(); const ref = useRef(null); const [currentTime, setCurrentTime] = useState(new Date()); @@ -45,9 +47,10 @@ export const NowLine = memo(function NowLine() {
From 403c98d90c19d02d6bd1cf17e9f50c9ee69283d7 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 26 Feb 2026 16:29:38 -0800 Subject: [PATCH 34/34] feat(auth): add unit tests for useAuthCmdItems hook - Introduced a new test suite for the useAuthCmdItems hook to validate its behavior under various authentication states. - Added tests to ensure correct command palette items are returned based on authentication status and feature flag settings. - Verified that clicking on authentication items opens the appropriate modal views, enhancing the reliability of the authentication command palette functionality. --- .../src/common/hooks/useAuthCmdItems.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 packages/web/src/common/hooks/useAuthCmdItems.test.ts diff --git a/packages/web/src/common/hooks/useAuthCmdItems.test.ts b/packages/web/src/common/hooks/useAuthCmdItems.test.ts new file mode 100644 index 000000000..5d755c8a9 --- /dev/null +++ b/packages/web/src/common/hooks/useAuthCmdItems.test.ts @@ -0,0 +1,85 @@ +import type { MouseEvent } from "react"; +import { act } from "react"; +import { renderHook } from "@testing-library/react"; +import { useSession } from "@web/auth/hooks/session/useSession"; +import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal"; +import { useAuthCmdItems } from "./useAuthCmdItems"; + +jest.mock("@web/auth/hooks/session/useSession", () => ({ + useSession: jest.fn(), +})); + +jest.mock("@web/components/AuthModal/hooks/useAuthModal", () => ({ + useAuthModal: jest.fn(), +})); + +describe("useAuthCmdItems", () => { + const mockOpenModal = jest.fn(); + const mockUseSession = useSession as jest.MockedFunction; + const mockUseAuthModal = useAuthModal as jest.MockedFunction< + typeof useAuthModal + >; + + beforeEach(() => { + jest.clearAllMocks(); + window.history.pushState({}, "", "/day"); + + mockUseSession.mockReturnValue({ + authenticated: false, + setAuthenticated: jest.fn(), + }); + mockUseAuthModal.mockReturnValue({ + isOpen: false, + currentView: "login", + openModal: mockOpenModal, + closeModal: jest.fn(), + setView: jest.fn(), + }); + }); + + it("returns no items when authenticated", () => { + window.history.pushState({}, "", "/day?auth=true"); + mockUseSession.mockReturnValue({ + authenticated: true, + setAuthenticated: jest.fn(), + }); + + const { result } = renderHook(() => useAuthCmdItems()); + + expect(result.current).toEqual([]); + }); + + it("returns no items when auth feature flag is disabled", () => { + const { result } = renderHook(() => useAuthCmdItems()); + + expect(result.current).toEqual([]); + }); + + it("returns auth items when unauthenticated and auth feature flag is enabled", () => { + window.history.pushState({}, "", "/day?auth=true"); + + const { result } = renderHook(() => useAuthCmdItems()); + + expect(result.current.map((item) => item.id)).toEqual([ + "sign-up", + "log-in", + ]); + }); + + it("opens matching auth modal view when item actions are clicked", () => { + window.history.pushState({}, "", "/day?auth=true"); + + const { result } = renderHook(() => useAuthCmdItems()); + const signUpItem = result.current.find((item) => item.id === "sign-up"); + const logInItem = result.current.find((item) => item.id === "log-in"); + + const mockEvent = {} as MouseEvent; + act(() => { + signUpItem?.onClick?.(mockEvent); + logInItem?.onClick?.(mockEvent); + }); + + expect(mockOpenModal).toHaveBeenNthCalledWith(1, "signUp"); + expect(mockOpenModal).toHaveBeenNthCalledWith(2, "login"); + }); +});