From 84fad211a5d3c89431d853ad88bf2d784da94a6f Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 15:30:06 -0400 Subject: [PATCH 1/7] Clarify shared bill forks and fix title editing --- components/BillSourceIndicator.tsx | 40 +++-- components/ProBillSplitter.tsx | 108 +++++++------- components/ShareBill.tsx | 13 +- .../ProBillSplitterTitleInput.test.tsx | 64 ++++++++ components/__tests__/SharedBillUi.test.tsx | 140 ++++++++++++++++++ 5 files changed, 300 insertions(+), 65 deletions(-) create mode 100644 components/__tests__/ProBillSplitterTitleInput.test.tsx create mode 100644 components/__tests__/SharedBillUi.test.tsx diff --git a/components/BillSourceIndicator.tsx b/components/BillSourceIndicator.tsx index cd3c8fe..b5fc997 100644 --- a/components/BillSourceIndicator.tsx +++ b/components/BillSourceIndicator.tsx @@ -1,7 +1,10 @@ "use client" -import { Copy, Link2 } from "lucide-react" +import { Copy, Link2, RotateCcw } from "lucide-react" +import { useRouter } from "next/navigation" import { useBill } from "@/contexts/BillContext" +import { Button } from "@/components/ui/button" +import { buildSharedBillPath } from "@/lib/sharing" import { cn } from "@/lib/utils" const billSourceConfig = { @@ -12,13 +15,14 @@ const billSourceConfig = { }, shared_copy: { icon: Copy, - label: "Editing local copy", + label: "Forked from shared bill", className: "text-amber-700 bg-amber-50", }, } as const export function BillSourceIndicator({ className }: { className?: string }) { const { state } = useBill() + const router = useRouter() if (state.billSource === "draft") { return null @@ -26,17 +30,33 @@ export function BillSourceIndicator({ className }: { className?: string }) { const config = billSourceConfig[state.billSource] const Icon = config.icon + const originalSharedBillId = state.billSource === "shared_copy" ? state.sharedOriginBillId : null + const canReloadOriginal = Boolean(originalSharedBillId) return ( - + + + {config.label} + + + {canReloadOriginal && originalSharedBillId && ( + )} - > - - {config.label} ) } diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index 73c51f8..308e390 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -865,62 +865,65 @@ function DesktopBillSplitter() { return } + // Ignore spreadsheet hotkeys while focus is in other form controls like the bill title. + if (isInInput) { + return + } + // Global shortcuts (only when not typing in other inputs) - if (!isInInput) { - if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { - e.preventDefault() - hotkeyActions.dispatchUndo() - hotkeyActions.toastUndo() - return - } + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault() + hotkeyActions.dispatchUndo() + hotkeyActions.toastUndo() + return + } - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') { - e.preventDefault() - hotkeyActions.dispatchRedo() - hotkeyActions.toastRedo() - return - } + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') { + e.preventDefault() + hotkeyActions.dispatchRedo() + hotkeyActions.toastRedo() + return + } - // Cmd+N: New bill - if ((e.metaKey || e.ctrlKey) && e.key === 'n') { - e.preventDefault() - newBillSourceRef.current = "shortcut" - setIsNewBillDialogOpen(true) - return - } + // Cmd+N: New bill + if ((e.metaKey || e.ctrlKey) && e.key === 'n') { + e.preventDefault() + newBillSourceRef.current = "shortcut" + setIsNewBillDialogOpen(true) + return + } - // Cmd+Shift+N: Add new item - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'N') { - e.preventDefault() - hotkeyActions.addItem() - analytics.trackFeatureUsed("keyboard_shortcut_add_item") - return - } + // Cmd+Shift+N: Add new item + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'N') { + e.preventDefault() + hotkeyActions.addItem() + analytics.trackFeatureUsed("keyboard_shortcut_add_item") + return + } - // Cmd+Shift+P: Add person - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'P') { - e.preventDefault() - hotkeyActions.addPerson() - analytics.trackFeatureUsed("keyboard_shortcut_add_person") - return - } + // Cmd+Shift+P: Add person + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'P') { + e.preventDefault() + hotkeyActions.addPerson() + analytics.trackFeatureUsed("keyboard_shortcut_add_person") + return + } - // Cmd+Shift+C: Copy summary - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'C') { - e.preventDefault() - hotkeyActions.copyBreakdown() - analytics.trackFeatureUsed("keyboard_shortcut_copy") - return - } + // Cmd+Shift+C: Copy summary + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'C') { + e.preventDefault() + hotkeyActions.copyBreakdown() + analytics.trackFeatureUsed("keyboard_shortcut_copy") + return + } - // Cmd+S: Share - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 's') { - e.preventDefault() - const shareButton = document.querySelector('[data-share-trigger]') as HTMLButtonElement - if (shareButton) shareButton.click() - analytics.trackFeatureUsed("keyboard_shortcut_share") - return - } + // Cmd+S: Share + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 's') { + e.preventDefault() + const shareButton = document.querySelector('[data-share-trigger]') as HTMLButtonElement + if (shareButton) shareButton.click() + analytics.trackFeatureUsed("keyboard_shortcut_share") + return } // Grid navigation - Excel-like behavior @@ -1054,11 +1057,11 @@ function DesktopBillSplitter() { previousItemsLengthRef.current = items.length if (activeView !== 'ledger') return - if (prevLen === 0 && items.length === 1) { + if (prevLen === 0 && items.length === 1 && !hasMeaningfulItems) { setSelectedCell({ row: 0, col: 'name' }) setEditing(true) } - }, [activeView, items.length]) + }, [activeView, hasMeaningfulItems, items.length]) useEffect(() => { if (editing && editInputRef.current) { @@ -1087,6 +1090,9 @@ function DesktopBillSplitter() { dispatch({ type: 'SET_BILL_TITLE', payload: e.target.value }) analytics.trackTitleChanged(e.target.value) }} + onKeyDown={(e) => { + e.stopPropagation() + }} style={{ width: `${Math.min(Math.max((title || '').length || 7, 7), 26)}ch`, }} diff --git a/components/ShareBill.tsx b/components/ShareBill.tsx index 6f360cf..9cd65b6 100644 --- a/components/ShareBill.tsx +++ b/components/ShareBill.tsx @@ -30,6 +30,8 @@ export function ShareBill({ variant = "outline", size = "sm", showText = true, i const [isStoring, setIsStoring] = useState(false) const [shareUrl, setShareUrl] = useState("") const [storeError, setStoreError] = useState(null) + const isSharedCopy = state.billSource === "shared_copy" + const shareTriggerLabel = isSharedCopy ? "Share updated copy" : "Share" // Generate share URL when dialog opens const handleOpenDialog = async (open: boolean) => { @@ -158,20 +160,23 @@ export function ShareBill({ variant = "outline", size = "sm", showText = true, i id={id ?? "share-bill-trigger"} // Allow external triggering via document.getElementById() variant={variant} size={size} + aria-label={shareTriggerLabel} className={showText ? "flex items-center gap-1.5 btn-smooth" : "dock-item p-0 h-auto bg-transparent border-0 hover:bg-primary/10"} > - {showText && Share} + {showText && {shareTriggerLabel}} - Share & Export "{state.currentBill.title}" + {isSharedCopy ? `Share updated copy "${state.currentBill.title}"` : `Share & Export "${state.currentBill.title}"`} - Share a link with anyone or export your bill data. Bills are stored securely and expire after 6 months. + {isSharedCopy + ? "This creates a new link for your edited copy. The original shared bill stays unchanged." + : "Share a link with anyone or export your bill data. Bills are stored securely and expire after 1 year."} @@ -245,7 +250,7 @@ export function ShareBill({ variant = "outline", size = "sm", showText = true, i - ✅ Universal sharing: This link works for anyone and auto-deletes after 6 months. + ✅ Universal sharing: This link works for anyone and auto-deletes after 1 year. Bills are stored securely in the cloud. diff --git a/components/__tests__/ProBillSplitterTitleInput.test.tsx b/components/__tests__/ProBillSplitterTitleInput.test.tsx new file mode 100644 index 0000000..1c77b79 --- /dev/null +++ b/components/__tests__/ProBillSplitterTitleInput.test.tsx @@ -0,0 +1,64 @@ +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '@/tests/utils/test-utils' +import { ProBillSplitter } from '@/components/ProBillSplitter' +import { createMockBill, createMockItem } from '@/tests/utils/test-utils' +import { useBill } from '@/contexts/BillContext' + +function BillStateProbe() { + const { state } = useBill() + + return ( +
+ {state.currentBill.title} + {state.currentBill.items[0]?.name ?? ''} +
+ ) +} + +describe('ProBillSplitter bill title input', () => { + it('does not start editing the selected ledger cell while typing in the bill title', async () => { + const user = userEvent.setup() + + const bill = createMockBill({ + id: 'bill-title-bug', + title: 'Dinner', + items: [ + createMockItem({ + id: 'item-1', + name: 'Pad Thai', + }), + ], + }) + + window.localStorage.getItem = jest.fn((key: string) => { + if (key === 'splitSimple_currentBill') { + return JSON.stringify(bill) + } + + return null + }) + + render( + <> + + + + ) + + const titleInput = await screen.findByRole('textbox', { name: /bill title/i }) + + await waitFor(() => { + expect(titleInput).toHaveValue('Dinner') + expect(screen.getByTestId('probe-title')).toHaveTextContent('Dinner') + expect(screen.getByTestId('probe-first-item-name')).toHaveTextContent('Pad Thai') + }) + + await user.click(titleInput) + await user.type(titleInput, 'X') + + expect(titleInput).toHaveValue('DinnerX') + expect(screen.getByTestId('probe-title')).toHaveTextContent('DinnerX') + expect(screen.getByTestId('probe-first-item-name')).toHaveTextContent('Pad Thai') + expect(screen.queryByLabelText(/edit name/i)).not.toBeInTheDocument() + }) +}) diff --git a/components/__tests__/SharedBillUi.test.tsx b/components/__tests__/SharedBillUi.test.tsx new file mode 100644 index 0000000..1ac31e2 --- /dev/null +++ b/components/__tests__/SharedBillUi.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BillSourceIndicator } from '@/components/BillSourceIndicator' +import { ShareBill } from '@/components/ShareBill' +import { createMockBill } from '@/tests/utils/test-utils' +import { useBill } from '@/contexts/BillContext' +import { useRouter } from 'next/navigation' +import { storeBillInCloud, generateCloudShareUrl } from '@/lib/sharing' + +jest.mock('@/contexts/BillContext', () => ({ + useBill: jest.fn(), +})) + +const mockPush = jest.fn() + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})) + +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: jest.fn(), + }), +})) + +jest.mock('@/hooks/use-analytics', () => ({ + useBillAnalytics: () => ({ + trackShareBillClicked: jest.fn(), + trackFeatureUsed: jest.fn(), + trackError: jest.fn(), + }), +})) + +jest.mock('@/lib/sharing', () => { + const actual = jest.requireActual('@/lib/sharing') + + return { + ...actual, + storeBillInCloud: jest.fn(), + generateCloudShareUrl: jest.fn(), + } +}) + +const mockedUseBill = jest.mocked(useBill) +const mockedUseRouter = jest.mocked(useRouter) +const mockedStoreBillInCloud = jest.mocked(storeBillInCloud) +const mockedGenerateCloudShareUrl = jest.mocked(generateCloudShareUrl) + +function createMockRouter() { + return { + push: mockPush, + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + } +} + +function mockBillState(overrides: Partial> = {}, billSource: 'draft' | 'shared' | 'shared_copy' = 'draft', sharedOriginBillId: string | null = null) { + mockedUseBill.mockReturnValue({ + state: { + currentBill: createMockBill(overrides), + billSource, + sharedOriginBillId, + syncStatus: 'never_synced', + lastSyncTime: null, + history: [], + historyIndex: -1, + maxHistorySize: 50, + }, + dispatch: jest.fn(), + canUndo: false, + canRedo: false, + syncToCloud: jest.fn(), + }) +} + +describe('BillSourceIndicator', () => { + beforeEach(() => { + mockPush.mockReset() + mockedUseRouter.mockReturnValue(createMockRouter() as unknown as ReturnType) + }) + + it('shows a forked shared-bill label and reload action for local copies', () => { + mockBillState({ id: 'forked-bill-id' }, 'shared_copy', 'shared-bill-id') + + render() + + expect(screen.getByText('Forked from shared bill')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /reload original/i })).toBeInTheDocument() + }) + + it('reopens the original shared bill from a forked copy', async () => { + const user = userEvent.setup() + mockBillState({ id: 'forked-bill-id' }, 'shared_copy', 'shared-bill-id') + + render() + + await user.click(screen.getByRole('button', { name: /reload original/i })) + + expect(mockPush).toHaveBeenCalledWith('/b/shared-bill-id', { scroll: false }) + }) + + it('keeps the shared bill indicator simple when viewing the original', () => { + mockBillState({ id: 'shared-bill-id' }, 'shared', 'shared-bill-id') + + render() + + expect(screen.getByText('Viewing shared bill')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /reload original/i })).not.toBeInTheDocument() + }) +}) + +describe('ShareBill', () => { + beforeEach(() => { + mockedUseRouter.mockReturnValue(createMockRouter() as unknown as ReturnType) + mockedStoreBillInCloud.mockResolvedValue({ success: true }) + mockedGenerateCloudShareUrl.mockImplementation((billId: string) => `https://splitsimple.app/b/${billId}`) + }) + + it('updates the trigger label for shared copies', () => { + mockBillState({ id: 'forked-bill-id', title: 'Edited Bill' }, 'shared_copy', 'shared-bill-id') + + render() + + expect(screen.getByRole('button', { name: /share updated copy/i })).toBeInTheDocument() + }) + + it('explains that sharing a fork keeps the original untouched', async () => { + const user = userEvent.setup() + mockBillState({ id: 'forked-bill-id', title: 'Edited Bill' }, 'shared_copy', 'shared-bill-id') + + render() + + await user.click(screen.getByRole('button', { name: /share updated copy/i })) + + expect(await screen.findByText(/original shared bill stays unchanged/i)).toBeInTheDocument() + }) +}) From ca229a59030725ca5642496328aeabbcb6f25343 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 15:30:09 -0400 Subject: [PATCH 2/7] Extend shared bill expiry to one year --- lib/__tests__/constants.test.ts | 7 +++++++ lib/constants.ts | 2 +- lib/sharing.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/constants.test.ts diff --git a/lib/__tests__/constants.test.ts b/lib/__tests__/constants.test.ts new file mode 100644 index 0000000..d9e91c2 --- /dev/null +++ b/lib/__tests__/constants.test.ts @@ -0,0 +1,7 @@ +import { STORAGE } from '@/lib/constants' + +describe('shared bill storage constants', () => { + it('keeps shared bills available for one year by default', () => { + expect(STORAGE.BILL_TTL_SECONDS).toBe(365 * 24 * 60 * 60) + }) +}) diff --git a/lib/constants.ts b/lib/constants.ts index f8dc4f9..6580bd8 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -23,5 +23,5 @@ export const TIMING = { // Shared bill storage constants export const STORAGE = { - BILL_TTL_SECONDS: 15552000, // ~6 months (6 * 30 days) + BILL_TTL_SECONDS: 31536000, // 1 year (365 days) } as const diff --git a/lib/sharing.ts b/lib/sharing.ts index 6e64a58..2c87cf2 100644 --- a/lib/sharing.ts +++ b/lib/sharing.ts @@ -147,7 +147,7 @@ export async function getBillFromCloud(billId: string): Promise if (!response.ok) { if (response.status === 404) { - return { error: "We couldn't find that bill. Bills expire after 6 months — ask the owner for a fresh link, or double-check the ID." } + return { error: "We couldn't find that bill. Bills expire after 1 year — ask the owner for a fresh link, or double-check the ID." } } let errorMessage = 'Something went wrong loading this bill. Please try again.' From 1aa1dd3c7e34da5514f1152f1654a4cd792a2a66 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 15:30:11 -0400 Subject: [PATCH 3/7] Harden admin dashboard state handling --- app/admin/__tests__/page.test.tsx | 176 +++++++++++++++++++++ app/admin/page.tsx | 248 ++++++++++++++++++++++-------- 2 files changed, 360 insertions(+), 64 deletions(-) create mode 100644 app/admin/__tests__/page.test.tsx diff --git a/app/admin/__tests__/page.test.tsx b/app/admin/__tests__/page.test.tsx new file mode 100644 index 0000000..532eb8d --- /dev/null +++ b/app/admin/__tests__/page.test.tsx @@ -0,0 +1,176 @@ +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '@/tests/utils/test-utils' +import AdminPage from '@/app/admin/page' +import type { AdminBillMetadata, AdminStats, Bill } from '@/lib/bill-types' + +const mockToast = jest.fn() +const mockPush = jest.fn() + +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: mockToast, + }), +})) + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: jest.fn(), + prefetch: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + }), +})) + +function createBill(overrides: Partial = {}): Bill { + return { + id: 'bill-1', + title: 'Weekly Dinner', + status: 'active', + tax: '0', + tip: '0', + discount: '0', + taxTipAllocation: 'proportional', + notes: '', + people: [], + items: [], + ...overrides, + } +} + +function createAdminStats(overrides: Partial = {}): AdminStats { + return { + totalBills: 1, + activeBills: 1, + draftBills: 0, + closedBills: 0, + totalItems: 2, + totalPeople: 3, + totalStorageSize: 1024, + averageBillSize: 1024, + totalMoneyProcessed: 24, + averageBillValue: 24, + medianBillValue: 24, + largestBill: 24, + smallestBill: 24, + subtotalRevenue: 24, + taxRevenue: 0, + tipRevenue: 0, + totalTaxCollected: 0, + totalTipsProcessed: 0, + totalDiscountsApplied: 0, + billsWithTax: 0, + billsWithTips: 0, + billsWithDiscounts: 0, + billsCreatedToday: 1, + billsCreatedThisWeek: 1, + billsCreatedThisMonth: 1, + billsCreatedLastWeek: 0, + billsCreatedLastMonth: 0, + weeklyGrowth: 100, + monthlyGrowth: 100, + completionRate: 100, + shareRate: 100, + averageAccessCount: 1, + sharedBills: 1, + completedBills: 1, + averageItemsPerBill: 2, + averagePeoplePerBill: 3, + complexBills: 0, + largeBills: 0, + popularSplitMethods: [{ method: 'even', count: 1, percentage: 100 }], + ...overrides, + } +} + +function createAdminBill(overrides: Partial = {}): AdminBillMetadata { + return { + id: 'bill-1', + bill: createBill(), + createdAt: '2026-05-29T00:00:00.000Z', + lastModified: '2026-05-29T00:00:00.000Z', + expiresAt: '2027-05-29T00:00:00.000Z', + accessCount: 1, + size: 512, + shareUrl: 'https://splitsimple.example/b/bill-1', + totalAmount: 24, + lastAccessed: '2026-05-29T00:00:00.000Z', + ...overrides, + } +} + +function createBillsResponse(overrides: Partial<{ bills: AdminBillMetadata[]; stats: AdminStats; pagination: { totalPages: number } }> = {}) { + return { + bills: [createAdminBill()], + stats: createAdminStats(), + pagination: { totalPages: 1 }, + ...overrides, + } +} + +function createFetchResponse(status: number, body?: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + blob: jest.fn().mockResolvedValue(new Blob()), + } as unknown as Response +} + +describe('AdminPage', () => { + beforeEach(() => { + mockToast.mockReset() + mockPush.mockReset() + global.fetch = jest.fn() + }) + + it('returns to the login screen when the admin session expires during bill fetch', async () => { + jest.mocked(global.fetch) + .mockResolvedValueOnce(createFetchResponse(200)) + .mockResolvedValueOnce(createFetchResponse(401, { error: 'Unauthorized' })) + + render() + + expect(await screen.findByText('Admin Access')).toBeInTheDocument() + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Session expired', + }) + ) + }) + + it('shows an empty state instead of a blank table when no bills are returned', async () => { + jest.mocked(global.fetch) + .mockResolvedValueOnce(createFetchResponse(200)) + .mockResolvedValueOnce( + createFetchResponse(200, createBillsResponse({ bills: [], stats: createAdminStats({ totalBills: 0, activeBills: 0, sharedBills: 0, totalItems: 0, totalPeople: 0, totalMoneyProcessed: 0, averageBillValue: 0, medianBillValue: 0, largestBill: 0, smallestBill: 0, subtotalRevenue: 0, averageBillSize: 0, averageAccessCount: 0, completionRate: 0, shareRate: 0, averageItemsPerBill: 0, averagePeoplePerBill: 0, completedBills: 0 }), pagination: { totalPages: 0 } })) + ) + + render() + + expect(await screen.findByText(/no bills found/i)).toBeInTheDocument() + expect(screen.getByText(/try changing your search or filters/i)).toBeInTheDocument() + expect(screen.getByText('Page 1 of 1')).toBeInTheDocument() + }) + + it('requests a supported backend sort key when sorting by total amount', async () => { + const user = userEvent.setup() + + jest.mocked(global.fetch) + .mockResolvedValueOnce(createFetchResponse(200)) + .mockResolvedValueOnce(createFetchResponse(200, createBillsResponse())) + .mockResolvedValueOnce(createFetchResponse(200, createBillsResponse())) + + render() + + await screen.findByText('Bills Command Center') + + await user.selectOptions(screen.getByLabelText(/sort by/i), 'total') + + await waitFor(() => { + const lastCallUrl = jest.mocked(global.fetch).mock.calls.at(-1)?.[0] + expect(lastCallUrl).toEqual(expect.stringContaining('sortBy=total')) + }) + }) +}) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c534125..deb8658 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -87,11 +87,16 @@ interface AdminBillsResponse { } } +interface ApiErrorResponse { + error?: string +} + export default function AdminPage() { const router = useRouter() const { toast } = useToast() const [isAuthenticated, setIsAuthenticated] = useState(false) const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) const [password, setPassword] = useState('') const [bills, setBills] = useState([]) const [stats, setStats] = useState(null) @@ -108,6 +113,37 @@ export default function AdminPage() { const fetchDebounceRef = useRef(null) const fetchAbortRef = useRef(null) + const readErrorMessage = async (response: Response, fallback: string) => { + try { + const data = await response.json() as ApiErrorResponse + return data.error || fallback + } catch { + return fallback + } + } + + const handleUnauthorized = () => { + if (!isAuthenticated) return + + if (fetchAbortRef.current) { + fetchAbortRef.current.abort() + fetchAbortRef.current = null + } + + setIsAuthenticated(false) + setBills([]) + setStats(null) + setSelectedBill(null) + setShowBillDialog(false) + setShowDeleteDialog(false) + setBillToDelete(null) + toast({ + title: 'Session expired', + description: 'Please log in again to continue managing bills.', + variant: 'destructive' + }) + } + useEffect(() => { checkAuth() }, []) @@ -131,6 +167,17 @@ export default function AdminPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated, currentPage, searchQuery, statusFilter, sortBy, sortOrder]) + useEffect(() => { + return () => { + if (fetchAbortRef.current) { + fetchAbortRef.current.abort() + } + if (fetchDebounceRef.current) { + window.clearTimeout(fetchDebounceRef.current) + } + } + }, []) + const checkAuth = async () => { try { const response = await fetch('/api/admin/bills?limit=1') @@ -190,6 +237,7 @@ export default function AdminPage() { const fetchBills = async () => { try { + setIsFetching(true) if (fetchAbortRef.current) { fetchAbortRef.current.abort() } @@ -209,12 +257,27 @@ export default function AdminPage() { signal: controller.signal }) - if (response.ok) { - const data = await response.json() as AdminBillsResponse - setBills(data.bills) - setStats(data.stats) - setTotalPages(data.pagination.totalPages) + if (response.status === 401) { + handleUnauthorized() + return + } + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Failed to fetch bills')) + } + + const data = await response.json() as AdminBillsResponse + const safeTotalPages = Math.max(1, data.pagination.totalPages || 1) + + if (currentPage > safeTotalPages) { + setTotalPages(safeTotalPages) + setCurrentPage(safeTotalPages) + return } + + setBills(data.bills) + setStats(data.stats) + setTotalPages(safeTotalPages) } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { return @@ -222,9 +285,12 @@ export default function AdminPage() { console.error('Error fetching bills:', error) toast({ title: 'Error', - description: 'Failed to fetch bills', + description: error instanceof Error ? error.message : 'Failed to fetch bills', variant: 'destructive' }) + } finally { + setIsFetching(false) + fetchAbortRef.current = null } } @@ -234,25 +300,30 @@ export default function AdminPage() { method: 'DELETE' }) - if (response.ok) { - toast({ - title: 'Success', - description: 'Bill deleted successfully' - }) - fetchBills() - } else { - throw new Error('Failed to delete bill') + if (response.status === 401) { + handleUnauthorized() + return } + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Failed to delete bill')) + } + + toast({ + title: 'Success', + description: 'Bill deleted successfully' + }) + await fetchBills() } catch (error) { toast({ title: 'Error', - description: 'Failed to delete bill', + description: error instanceof Error ? error.message : 'Failed to delete bill', variant: 'destructive' }) + } finally { + setShowDeleteDialog(false) + setBillToDelete(null) } - - setShowDeleteDialog(false) - setBillToDelete(null) } const handleExtendBill = async (billId: string, days: number = 30) => { @@ -263,19 +334,24 @@ export default function AdminPage() { body: JSON.stringify({ days }) }) - if (response.ok) { - toast({ - title: 'Success', - description: `Bill expiration extended by ${days} days` - }) - fetchBills() - } else { - throw new Error('Failed to extend bill') + if (response.status === 401) { + handleUnauthorized() + return } + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Failed to extend bill expiration')) + } + + toast({ + title: 'Success', + description: `Bill expiration extended by ${days} days` + }) + await fetchBills() } catch (error) { toast({ title: 'Error', - description: 'Failed to extend bill expiration', + description: error instanceof Error ? error.message : 'Failed to extend bill expiration', variant: 'destructive' }) } @@ -285,39 +361,52 @@ export default function AdminPage() { try { const response = await fetch(`/api/admin/export?format=${format}`) - if (response.ok) { - const blob = await response.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `bills_export_${new Date().toISOString()}.${format}` - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) + if (response.status === 401) { + handleUnauthorized() + return + } - toast({ - title: 'Success', - description: 'Bills exported successfully' - }) - } else { - throw new Error('Failed to export bills') + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Failed to export bills')) } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `bills_export_${new Date().toISOString()}.${format}` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + + toast({ + title: 'Success', + description: 'Bills exported successfully' + }) } catch (error) { toast({ title: 'Error', - description: 'Failed to export bills', + description: error instanceof Error ? error.message : 'Failed to export bills', variant: 'destructive' }) } } - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text) - toast({ - title: 'Copied', - description: 'Share URL copied to clipboard' - }) + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast({ + title: 'Copied', + description: 'Share URL copied to clipboard' + }) + } catch { + toast({ + title: 'Clipboard blocked', + description: 'Could not copy the share URL. Please copy it manually.', + variant: 'destructive' + }) + } } const formatBytes = (bytes: number) => { @@ -327,7 +416,8 @@ export default function AdminPage() { } const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString() + const date = new Date(dateString) + return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString() } const getStatusBadge = (status: string) => { @@ -344,6 +434,10 @@ export default function AdminPage() { ) } + const safeBillsTotal = Math.max(stats?.totalBills || 0, 1) + const activeBillsShare = stats ? Math.min(100, (stats.activeBills / safeBillsTotal) * 100) : 0 + const sharedBillsShare = stats?.totalBills ? Math.round((stats.sharedBills / stats.totalBills) * 100) : 0 + if (isLoading) { return (
@@ -666,7 +760,7 @@ export default function AdminPage() {
of {stats.totalBills} total @@ -691,7 +785,7 @@ export default function AdminPage() {
{stats.sharedBills}

- {Math.round((stats.sharedBills / stats.totalBills) * 100)}% collaboration rate + {sharedBillsShare}% collaboration rate

@@ -726,10 +820,11 @@ export default function AdminPage() { onClick={() => fetchBills()} variant="outline" size="sm" + disabled={isFetching} className="gap-1 btn-smooth border-slate-200 hover:border-slate-300" > - - Sync + + {isFetching ? 'Syncing…' : 'Sync'}
@@ -764,7 +859,10 @@ export default function AdminPage() { value={searchQuery} name="search-bills" autoComplete="off" - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { + setSearchQuery(e.target.value) + setCurrentPage(1) + }} aria-label="Search bills" className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" /> @@ -773,7 +871,10 @@ export default function AdminPage() {
setSortBy(e.target.value)} + onChange={(e) => { + setSortBy(e.target.value) + setCurrentPage(1) + }} aria-label="Sort by" className="px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" > - - + + + + + + )} + /> + + + + + )} + /> + + +
+
+ + { + setManualOpen(open) + if (!open) { + resetManualFlow() + } + }} + > + + {manualMode === "menu" ? ( + <> + + Enter Manually + + Choose the fastest path for the information you have right now. + + + +
+ + +
+
+ +
+
+
+
Itemized bill
+

+ Start with blank rows if you want fairness by item from the beginning. +

+
+ +
+ + setManualTitle(event.target.value)} + placeholder="Manual Split" + /> +
+ + +
+
+
+ + ) : ( + <> + + Quick Split Total + + This creates one shared line item now, and you can itemize it later if the group wants more detail. + + + +
+
+
+ + setQuickTitle(event.target.value)} + placeholder="Quick Split" + /> +
+ +
+ + setQuickAmount(event.target.value)} + placeholder="86.42" + /> +

+ If this already includes tax and tip, leave the fields below blank. +

+
+
+ +
+
+ + setQuickTax(event.target.value)} + placeholder="0.00" + /> +
+
+ + setQuickTip(event.target.value)} + placeholder="0.00" + /> +
+
+ + setQuickDiscount(event.target.value)} + placeholder="0.00" + /> +
+
+ +
+ + +
+ +
+
+
+

People

+

+ Add everyone involved in the split. +

+
+ +
+ +
+ {participants.map((participant, index) => ( +
+
+
+ + updateParticipant(participant.id, "name", event.target.value)} + placeholder={`Person ${index + 1}`} + /> +
+ + {participants.length > 2 && ( + + )} +
+ + {quickSplitMode === "shares" && ( +
+ + updateParticipant(participant.id, "shares", event.target.value)} + placeholder="1" + /> +
+ )} + + {quickSplitMode === "exact" && ( +
+ + updateParticipant(participant.id, "exactAmount", event.target.value)} + placeholder="0.00" + /> +
+ )} +
+ ))} +
+
+ + {quickSplitError && ( +
+ {quickSplitError} +
+ )} + +
+ + +
+
+ + )} +
+
+ + ) +} + +function StartOptionButton({ + icon: Icon, + title, + description, + layout, +}: { + icon: typeof Camera + title: string + description: string + layout: "sidebar" | "grid" +}) { + const isSidebar = layout === "sidebar" + + return ( + + + + + + {isSidebar ? ( + + + {title} + {description} + + + ) : ( + <> + {title} + {description} + + )} + + ) +} diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index 308e390..b4dac50 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -38,6 +38,7 @@ import { useIsMobile } from '@/hooks/use-mobile' import { MobileSpreadsheetView } from '@/components/MobileSpreadsheetView' import { AnimatedNumber } from '@/components/AnimatedNumber' import { SplitSimpleIcon } from '@/components/SplitSimpleIcon' +import { BillStartOptions } from '@/components/BillStartOptions' import { ToastAction } from '@/components/ui/toast' import { getSplitMethodOption, splitMethodOptions } from '@/components/split-method-options' @@ -1719,62 +1720,42 @@ function DesktopBillSplitter() {
-
- {people.length === 0 ? ( - - ) : ( - - )} - {people.length === 0 ? ( - - ) : ( +
+ + +
+ {people.length === 0 ? ( + + ) : ( + + )} - )} - - Scan receipt to import items - - )} - /> +
)} diff --git a/components/ReceiptScanner.tsx b/components/ReceiptScanner.tsx index 80f7707..a3b06e3 100644 --- a/components/ReceiptScanner.tsx +++ b/components/ReceiptScanner.tsx @@ -36,9 +36,10 @@ interface ScanError { interface ReceiptScannerProps { onImport: (items: ReceiptLineItem[]) => void trigger?: React.ReactNode + initialTab?: "image" | "text" } -export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { +export function ReceiptScanner({ onImport, trigger, initialTab = "image" }: ReceiptScannerProps) { const [isOpen, setIsOpen] = useState(false) const [state, setState] = useState('idle') const [receiptImage, setReceiptImage] = useState(null) @@ -46,6 +47,7 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { const [zoom, setZoom] = useState(1) const [rotation, setRotate] = useState(0) const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState<"image" | "text">(initialTab) const { toast } = useToast() const handleReset = useCallback(() => { @@ -55,10 +57,14 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { setZoom(1) setRotate(0) setError(null) - }, []) + setActiveTab(initialTab) + }, [initialTab]) const handleOpenChange = (open: boolean) => { setIsOpen(open) + if (open) { + setActiveTab(initialTab) + } if (!open) { setTimeout(handleReset, 300) // Reset after animation } @@ -195,10 +201,12 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { )}> {state === 'idle' && ( setError(null)} + onTabChange={setActiveTab} /> )} @@ -227,15 +235,19 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { // --- Sub-Components --- function UploadView({ + activeTab, onUpload, onPaste, error, - onDismissError + onDismissError, + onTabChange, }: { + activeTab: "image" | "text" onUpload: (file: File) => void onPaste: (text: string) => void error: ScanError | null onDismissError: () => void + onTabChange: (value: "image" | "text") => void }) { const fileInputRef = useRef(null) const [dragActive, setDragActive] = useState(false) @@ -262,11 +274,11 @@ function UploadView({ return (
- - Add Receipt - + + Add Items + - + onTabChange(value as "image" | "text")} className="flex-1 flex flex-col">
Upload Image diff --git a/components/mobile/MobileCardView.tsx b/components/mobile/MobileCardView.tsx index eaca57f..52b1c3b 100644 --- a/components/mobile/MobileCardView.tsx +++ b/components/mobile/MobileCardView.tsx @@ -27,7 +27,7 @@ import { Edit2 } from "lucide-react" import { useBill } from "@/contexts/BillContext" -import type { Item, Person, ReceiptLineItem } from "@/lib/bill-types" +import type { Item, Person } from "@/lib/bill-types" import { calculateItemSplits, getBillSummary } from "@/lib/calculations" import { PersonSelector } from "@/components/PersonSelector" import { SplitMethodSelector } from "@/components/SplitMethodSelector" @@ -40,7 +40,7 @@ import { useBillAnalytics } from "@/hooks/use-analytics" import { SplitSimpleIcon } from "@/components/SplitSimpleIcon" import { BillLookup } from "@/components/BillLookup" import { ShareBill } from "@/components/ShareBill" -import { ReceiptScanner } from "@/components/ReceiptScanner" +import { BillStartOptions } from "@/components/BillStartOptions" import { cn } from "@/lib/utils" // Color palette for people @@ -114,19 +114,6 @@ export function MobileCardView() { } } - const handleScanImport = (scannedItems: ReceiptLineItem[]) => { - scannedItems.forEach((item) => { - const newItem: Omit = { - ...item, - splitWith: people.map((p) => p.id), - method: "even", - } - dispatch({ type: "ADD_ITEM", payload: newItem }) - }) - analytics.trackFeatureUsed("scan_receipt_import", { count: scannedItems.length }) - toast({ title: "Items added from scan" }) - } - const handleNewBill = () => { if (confirm("Start a new bill? Current bill will be lost if not shared.")) { dispatch({ type: "NEW_BILL" }) @@ -279,16 +266,13 @@ export function MobileCardView() { {items.length === 0 ? ( - +
📝

No items yet

-

Add your first item to start splitting

+

Start with a receipt, pasted text, or a manual split.

- +
) : ( diff --git a/lib/__tests__/bill-start.test.ts b/lib/__tests__/bill-start.test.ts new file mode 100644 index 0000000..bb8ce64 --- /dev/null +++ b/lib/__tests__/bill-start.test.ts @@ -0,0 +1,57 @@ +import { getBillSummary } from "@/lib/calculations" +import { buildManualItemizedBill, buildQuickSplitBill } from "@/lib/bill-start" + +describe("bill-start helpers", () => { + it("builds an even quick split bill that reconciles", () => { + const bill = buildQuickSplitBill({ + title: "Bar tab", + amount: 80, + tax: 6.4, + tip: 13.6, + participants: [ + { name: "Anurag" }, + { name: "Sunil" }, + { name: "Praks" }, + { name: "Shambhavi" }, + ], + }) + + const summary = getBillSummary(bill) + + expect(bill.items).toHaveLength(1) + expect(summary.subtotal).toBe(80) + expect(summary.tax).toBe(6.4) + expect(summary.tip).toBe(13.6) + expect(summary.total).toBe(100) + expect(summary.personTotals).toHaveLength(4) + expect(summary.personTotals.every((person) => person.total === 25)).toBe(true) + }) + + it("builds an exact quick split bill with person amounts", () => { + const bill = buildQuickSplitBill({ + title: "Taxi", + amount: 42, + splitMode: "exact", + participants: [ + { name: "Anurag", exactAmount: 12 }, + { name: "Sunil", exactAmount: 10 }, + { name: "Praks", exactAmount: 20 }, + ], + }) + + const summary = getBillSummary(bill) + + expect(summary.total).toBe(42) + expect(summary.personTotals.map((person) => person.total)).toEqual([12, 10, 20]) + }) + + it("builds a manual itemized draft with starter rows", () => { + const bill = buildManualItemizedBill("Groceries") + + expect(bill.title).toBe("Groceries") + expect(bill.people).toHaveLength(1) + expect(bill.items).toHaveLength(3) + expect(bill.items.every((item) => item.quantity === 1 && item.method === "even")).toBe(true) + expect(bill.items.every((item) => item.splitWith.length === 1)).toBe(true) + }) +}) diff --git a/lib/bill-start.ts b/lib/bill-start.ts new file mode 100644 index 0000000..f081982 --- /dev/null +++ b/lib/bill-start.ts @@ -0,0 +1,125 @@ +import type { Bill, Item, Person } from "@/lib/bill-types" + +const PERSON_COLORS = [ + "#6366f1", + "#d97706", + "#dc2626", + "#22c55e", + "#f59e0b", + "#8b5cf6", + "#06b6d4", + "#ef4444", + "#10b981", + "#f97316", +] + +function createId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +function sanitizeAmount(value: number) { + return Math.max(0, Math.round(value * 100) / 100) +} + +function formatAmount(value: number) { + return sanitizeAmount(value).toFixed(2) +} + +export type QuickSplitMode = "even" | "shares" | "exact" + +export interface QuickSplitParticipantInput { + name: string + shares?: number + exactAmount?: number +} + +export interface QuickSplitDraftInput { + title: string + amount: number + tax?: number + tip?: number + discount?: number + allocation?: "proportional" | "even" + splitMode?: QuickSplitMode + participants: QuickSplitParticipantInput[] +} + +export function buildQuickSplitBill(input: QuickSplitDraftInput): Bill { + const splitMode = input.splitMode ?? "even" + const amount = sanitizeAmount(input.amount) + const tax = sanitizeAmount(input.tax ?? 0) + const tip = sanitizeAmount(input.tip ?? 0) + const discount = sanitizeAmount(input.discount ?? 0) + + const people: Person[] = input.participants.map((participant, index) => ({ + id: createId(), + name: participant.name.trim(), + color: PERSON_COLORS[index % PERSON_COLORS.length], + colorIdx: index % 6, + })) + + const item: Item = { + id: createId(), + name: "Shared total", + price: formatAmount(amount), + quantity: 1, + splitWith: people.map((person) => person.id), + method: splitMode, + } + + if (splitMode === "shares") { + item.customSplits = Object.fromEntries( + people.map((person, index) => [person.id, input.participants[index]?.shares ?? 1]) + ) + } + + if (splitMode === "exact") { + item.customSplits = Object.fromEntries( + people.map((person, index) => [person.id, sanitizeAmount(input.participants[index]?.exactAmount ?? 0)]) + ) + } + + return { + id: createId(), + title: input.title.trim() || "Quick Split", + status: "active", + tax: tax > 0 ? formatAmount(tax) : "", + tip: tip > 0 ? formatAmount(tip) : "", + discount: discount > 0 ? formatAmount(discount) : "", + taxTipAllocation: input.allocation ?? "proportional", + notes: "", + people, + items: [item], + } +} + +export function buildManualItemizedBill(title = "Manual Split"): Bill { + const firstPerson: Person = { + id: createId(), + name: "Person 1", + color: PERSON_COLORS[0], + colorIdx: 0, + } + + const starterItems: Item[] = Array.from({ length: 3 }, () => ({ + id: createId(), + name: "", + price: "", + quantity: 1, + splitWith: [firstPerson.id], + method: "even" as const, + })) + + return { + id: createId(), + title: title.trim() || "Manual Split", + status: "active", + tax: "", + tip: "", + discount: "", + taxTipAllocation: "proportional", + notes: "", + people: [firstPerson], + items: starterItems, + } +} diff --git a/next.config.mjs b/next.config.mjs index 0a34819..8ed2ca0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + allowedDevOrigins: ["127.0.0.1"], images: { unoptimized: true, }, From 0303034606a1fb6a4df6f6b391b4af5dba87b7e3 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:24:48 -0400 Subject: [PATCH 5/7] fix: align quick-split palette and block grid hotkey leak under modals - bill-start: derive person color and colorIdx from the canonical 6-color palette with a single modulus, so a quick-split person's stored hex always matches the swatch the Pro view renders via COLORS[colorIdx]. - ProBillSplitter: bail out of the global spreadsheet keydown handler while a Radix dialog/alertdialog is open. Fixes keystrokes leaking into the selected ledger cell behind the Edit Member popup when focus sits on a non-input. Verified in-browser: dialog typing, Escape close, and grid editing all unaffected; tsc clean; 175 tests pass. --- components/ProBillSplitter.tsx | 8 ++++++++ lib/bill-start.ts | 34 ++++++++++++++++++---------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index b4dac50..c06b1c9 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -836,6 +836,14 @@ function DesktopBillSplitter() { } } + // A modal/dialog (Edit Member, New Bill, Share, Delete…) owns the keyboard while open. + // Its inputs live in a portal; when focus sits on the dialog container or a button + // inside it, isInInput is briefly false. Without this guard a printable key falls + // through to type-to-edit and silently edits the selected ledger cell behind the modal. + if (document.querySelector('[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]')) { + return + } + // If currently editing a cell input, let typing happen but keep spreadsheet commits if (hotkeyState.editing && isInInput) { if (e.key === 'Enter') { diff --git a/lib/bill-start.ts b/lib/bill-start.ts index f081982..06cf29e 100644 --- a/lib/bill-start.ts +++ b/lib/bill-start.ts @@ -1,16 +1,15 @@ import type { Bill, Item, Person } from "@/lib/bill-types" +// Mirrors the canonical `COLORS` palette in ProBillSplitter (hex values, same order). +// `color` and `colorIdx` are both derived from this single source so the stored hex +// always matches the swatch the Pro view renders via COLORS[colorIdx]. const PERSON_COLORS = [ - "#6366f1", - "#d97706", - "#dc2626", - "#22c55e", - "#f59e0b", - "#8b5cf6", - "#06b6d4", - "#ef4444", - "#10b981", - "#f97316", + "#4F46E5", // indigo + "#F97316", // orange + "#F43F5E", // rose + "#10B981", // emerald + "#3B82F6", // blue + "#F59E0B", // amber ] function createId() { @@ -51,12 +50,15 @@ export function buildQuickSplitBill(input: QuickSplitDraftInput): Bill { const tip = sanitizeAmount(input.tip ?? 0) const discount = sanitizeAmount(input.discount ?? 0) - const people: Person[] = input.participants.map((participant, index) => ({ - id: createId(), - name: participant.name.trim(), - color: PERSON_COLORS[index % PERSON_COLORS.length], - colorIdx: index % 6, - })) + const people: Person[] = input.participants.map((participant, index) => { + const colorIdx = index % PERSON_COLORS.length + return { + id: createId(), + name: participant.name.trim(), + color: PERSON_COLORS[colorIdx], + colorIdx, + } + }) const item: Item = { id: createId(), From 4ded33a9f3bbc3530bb17c71c1d36c05a1ddfedf Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:13:43 -0400 Subject: [PATCH 6/7] fix: add DialogDescription to Edit Member dialog for a11y Resolves the Radix "Missing Description or aria-describedby for DialogContent" warning by linking a description to the dialog. Verified in-browser: dialog now exposes aria-describedby and the warning no longer appears. --- components/ProBillSplitter.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index c06b1c9..b329c5f 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -69,6 +69,7 @@ import { import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' @@ -2180,6 +2181,7 @@ function DesktopBillSplitter() { Edit Member + Update this person's display name and color. {editingPerson && (
From f09b6366a3b71e34719d7c0cb47f447ae110778b Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:49:07 -0400 Subject: [PATCH 7/7] chore: gitignore local tooling artifacts (.playwright-cli) --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index d647a8c..69ed6ef 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,8 @@ public/mockServiceWorker.js .env.local +.claude/settings.local.json +.gstack/ + +# Local tooling artifacts +.playwright-cli/