From 5ce68702bac7f41d6a247eb969a555de0b657212 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:26:17 -0400 Subject: [PATCH 01/13] refactor: extract shared bill query helpers --- lib/__tests__/sharing.test.ts | 13 ++++++++++++- lib/sharing.ts | 23 ++++++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/sharing.test.ts b/lib/__tests__/sharing.test.ts index ed76cb1..e19bbbc 100644 --- a/lib/__tests__/sharing.test.ts +++ b/lib/__tests__/sharing.test.ts @@ -1,4 +1,4 @@ -import { extractBillIdFromInput } from '@/lib/sharing' +import { extractBillIdFromInput, getSharedBillIdFromSearch, stripSharedBillParams } from '@/lib/sharing' describe('extractBillIdFromInput', () => { it('returns a raw bill ID unchanged', () => { @@ -15,3 +15,14 @@ describe('extractBillIdFromInput', () => { expect(extractBillIdFromInput('?bill=1780007206455-yojajgt')).toBe('1780007206455-yojajgt') }) }) + +describe('shared bill search helpers', () => { + it('reads the shared bill id from search params', () => { + expect(getSharedBillIdFromSearch('?bill=1780007206455-yojajgt&view=breakdown')).toBe('1780007206455-yojajgt') + }) + + it('clears shared bill params while preserving unrelated params', () => { + expect(stripSharedBillParams('bill=1780007206455-yojajgt&view=breakdown')).toBe('?view=breakdown') + expect(stripSharedBillParams('?share=1780007206455-yojajgt')).toBe('') + }) +}) diff --git a/lib/sharing.ts b/lib/sharing.ts index b2915d0..49e8d68 100644 --- a/lib/sharing.ts +++ b/lib/sharing.ts @@ -3,6 +3,24 @@ import { isMigratableBill, isRecord, migrateBillSchema } from "@/lib/validation" const FULL_BILL_ID_PATTERN = /^\d{13}-[a-z0-9]+$/i +function normalizeSearchInput(search: string): string { + return search.startsWith("?") ? search.slice(1) : search +} + +export function getSharedBillIdFromSearch(search: string): string | null { + const params = new URLSearchParams(normalizeSearchInput(search)) + return params.get("bill") || params.get("share") +} + +export function stripSharedBillParams(search: string): string { + const params = new URLSearchParams(normalizeSearchInput(search)) + params.delete("bill") + params.delete("share") + + const nextSearch = params.toString() + return nextSearch ? `?${nextSearch}` : "" +} + export function extractBillIdFromInput(input: string): string { const trimmed = input.trim().replace(/^#/, "") if (!trimmed) return "" @@ -13,8 +31,7 @@ export function extractBillIdFromInput(input: string): string { const queryStart = trimmed.indexOf("?") if (queryStart >= 0) { - const params = new URLSearchParams(trimmed.slice(queryStart + 1)) - const sharedId = params.get("bill") || params.get("share") + const sharedId = getSharedBillIdFromSearch(trimmed.slice(queryStart)) if (sharedId) { return sharedId.trim() } @@ -22,7 +39,7 @@ export function extractBillIdFromInput(input: string): string { try { const url = new URL(trimmed) - const sharedId = url.searchParams.get("bill") || url.searchParams.get("share") + const sharedId = getSharedBillIdFromSearch(url.search) if (sharedId) { return sharedId.trim() } From b7b60fc2c94e639f07ccfb7cf4481b0427b0f504 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:26:20 -0400 Subject: [PATCH 02/13] fix: reload shared bills only on id changes --- contexts/BillContext.tsx | 22 +++++++++++++++++---- contexts/__tests__/BillContext.test.tsx | 26 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/contexts/BillContext.tsx b/contexts/BillContext.tsx index 0863140..39e7bca 100644 --- a/contexts/BillContext.tsx +++ b/contexts/BillContext.tsx @@ -1,8 +1,8 @@ "use client" import type React from "react" -import { createContext, useContext, useReducer, useEffect } from "react" -import { getBillFromCloud, storeBillInCloud } from "@/lib/sharing" +import { createContext, useContext, useReducer, useEffect, useRef } from "react" +import { getBillFromCloud, getSharedBillIdFromSearch, storeBillInCloud } from "@/lib/sharing" import { isMigratableBill, isRecord, migrateBillSchema, type MigratableBill } from "@/lib/validation" import type { Bill, BillStatus, Item, Person, SyncStatus, TaxTipAllocation } from "@/lib/bill-types" @@ -317,8 +317,7 @@ const loadBillFromLocalStorage = (billId: string): MigratableBill | null => { const getSharedBillIdFromLocation = (): string | null => { if (typeof window === "undefined") return null - const params = new URLSearchParams(window.location.search) - return params.get("bill") || params.get("share") + return getSharedBillIdFromSearch(window.location.search) } const generateShareUrl = (billId: string): string => { @@ -357,6 +356,8 @@ const BillContext = createContext<{ // Provider export function BillProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(billReducer, initialState) + const sharedBillIdRef = useRef(null) + const sharedBillLoadRequestRef = useRef(0) const canUndo = state.historyIndex >= 0 const canRedo = state.historyIndex < state.history.length - 1 @@ -384,6 +385,15 @@ export function BillProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const loadBillFromLocation = async (loadLocalFallback: boolean) => { const sharedBillId = getSharedBillIdFromLocation() + const previousSharedBillId = sharedBillIdRef.current + sharedBillIdRef.current = sharedBillId + + if (sharedBillId === previousSharedBillId && sharedBillId !== null) { + return + } + + const requestId = ++sharedBillLoadRequestRef.current + if (!sharedBillId) { if (!loadLocalFallback) return @@ -403,6 +413,8 @@ export function BillProvider({ children }: { children: React.ReactNode }) { try { const cloudResult = await getBillFromCloud(sharedBillId) + if (sharedBillLoadRequestRef.current !== requestId) return + if (cloudResult.bill) { const migratedBill = migrateBillSchema(cloudResult.bill) dispatch({ type: "LOAD_BILL", payload: migratedBill }) @@ -421,6 +433,8 @@ export function BillProvider({ children }: { children: React.ReactNode }) { } const localSharedBill = loadBillFromLocalStorage(sharedBillId) + if (sharedBillLoadRequestRef.current !== requestId) return + if (localSharedBill) { const migratedBill = migrateBillSchema(localSharedBill) dispatch({ type: "LOAD_BILL", payload: migratedBill }) diff --git a/contexts/__tests__/BillContext.test.tsx b/contexts/__tests__/BillContext.test.tsx index baab1cf..8f9fb82 100644 --- a/contexts/__tests__/BillContext.test.tsx +++ b/contexts/__tests__/BillContext.test.tsx @@ -508,5 +508,31 @@ describe('BillContext', () => { expect(result.current.state.currentBill.title).toBe('Loaded After Navigation') expect(getBillFromCloud).toHaveBeenCalledWith(sharedBill.id) }) + + it('should not refetch when unrelated query params change for the same shared bill', async () => { + const sharedBill = createMockBill({ + id: '1780007206455-yojajgt', + title: 'Stable Shared Bill', + }) + + jest.mocked(getBillFromCloud).mockResolvedValue({ bill: sharedBill }) + window.history.replaceState({}, '', `/?bill=${sharedBill.id}`) + + const { result } = renderHook(() => useBill(), { wrapper }) + + await waitFor(() => { + expect(result.current.state.currentBill.id).toBe(sharedBill.id) + }) + expect(getBillFromCloud).toHaveBeenCalledTimes(1) + + act(() => { + window.history.pushState({}, '', `/?bill=${sharedBill.id}&view=breakdown`) + }) + + await waitFor(() => { + expect(getBillFromCloud).toHaveBeenCalledTimes(1) + }) + expect(result.current.state.currentBill.title).toBe('Stable Shared Bill') + }) }) }) From ec42bbff8dd2f13d182c817f5ac78c2c80f882bb Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:26:24 -0400 Subject: [PATCH 03/13] fix: clear shared bill params on new drafts --- components/ProBillSplitter.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index d1fa7e2..ee99634 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -31,7 +31,7 @@ import { ShareBill } from '@/components/ShareBill' import { SyncStatusIndicator } from '@/components/SyncStatusIndicator' import { useBillAnalytics } from '@/hooks/use-analytics' import { TIMING } from '@/lib/constants' -import { extractBillIdFromInput, getBillFromCloud } from '@/lib/sharing' +import { extractBillIdFromInput, getBillFromCloud, stripSharedBillParams } from '@/lib/sharing' import { migrateBillSchema } from '@/lib/validation' import { useIsMobile } from '@/hooks/use-mobile' import { MobileSpreadsheetView } from '@/components/MobileSpreadsheetView' @@ -512,6 +512,8 @@ function DesktopBillSplitter() { }, [dispatch, toast]) const confirmNewBill = useCallback(() => { + const nextSearch = stripSharedBillParams(searchParams.toString()) + router.replace(nextSearch ? `${pathname}${nextSearch}` : pathname, { scroll: false }) dispatch({ type: 'NEW_BILL' }) toast({ title: "New bill created", variant: "success" }) analytics.trackBillCreated() @@ -520,7 +522,7 @@ function DesktopBillSplitter() { ) newBillSourceRef.current = "button" setIsNewBillDialogOpen(false) - }, [dispatch, toast, analytics]) + }, [analytics, dispatch, pathname, router, searchParams, toast]) const openDeleteDialog = useCallback((item: Item) => { setPendingDeleteItem(item) From 194dfe161f0a9e662f7437d30797fae0f7f872fb Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:26:27 -0400 Subject: [PATCH 04/13] fix: return readable backend proxy failures --- lib/__tests__/backend-proxy.test.ts | 13 +++++++++++++ lib/backend-proxy.ts | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/__tests__/backend-proxy.test.ts b/lib/__tests__/backend-proxy.test.ts index 5218ee4..f642f15 100644 --- a/lib/__tests__/backend-proxy.test.ts +++ b/lib/__tests__/backend-proxy.test.ts @@ -8,17 +8,20 @@ describe("proxyToBackend", () => { const originalFetch = global.fetch const originalBackendUrl = process.env.CLOUDFLARE_BACKEND_URL const originalBackendSecret = process.env.BACKEND_SHARED_SECRET + let consoleErrorSpy: jest.SpyInstance beforeEach(() => { process.env.CLOUDFLARE_BACKEND_URL = "https://splitsimple-backend.aarekaz.workers.dev" process.env.BACKEND_SHARED_SECRET = "test-secret" global.fetch = jest.fn() + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) }) afterEach(() => { global.fetch = originalFetch process.env.CLOUDFLARE_BACKEND_URL = originalBackendUrl process.env.BACKEND_SHARED_SECRET = originalBackendSecret + consoleErrorSpy.mockRestore() jest.clearAllMocks() }) @@ -58,4 +61,14 @@ describe("proxyToBackend", () => { expect(init.headers.get("x-splitsimple-backend-secret")).toBe("test-secret") expect(init.headers.get("x-splitsimple-public-url")).toBe("https://splitsimple.anuragd.me") }) + + it("returns a readable 502 when the backend fetch fails", async () => { + ;(global.fetch as jest.Mock).mockRejectedValue(new TypeError("fetch failed")) + + const request = new NextRequest("https://splitsimple.anuragd.me/api/bills/bill-123") + const response = await proxyToBackend(request, "/api/bills/bill-123") + + expect(response.status).toBe(502) + await expect(response.json()).resolves.toEqual({ error: "Backend service is unavailable" }) + }) }) diff --git a/lib/backend-proxy.ts b/lib/backend-proxy.ts index 0eb8cc2..ebf0473 100644 --- a/lib/backend-proxy.ts +++ b/lib/backend-proxy.ts @@ -55,8 +55,18 @@ export async function proxyToBackend( method: request.method, headers, body, + }).catch((error: unknown) => { + console.error("Backend proxy request failed:", error) + return null }) + if (!backendResponse) { + return NextResponse.json( + { error: "Backend service is unavailable" }, + { status: 502 } + ) + } + return new NextResponse(backendResponse.body, { status: backendResponse.status, headers: getProxyResponseHeaders(backendResponse.headers), From 148b9e1586ed1cbfe9ba86e043c5cb4ef3fc3e4c Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:43:37 -0400 Subject: [PATCH 05/13] feat: track bill source mode --- components/BillLookup.tsx | 9 +- components/ProBillSplitter.tsx | 9 +- contexts/BillContext.tsx | 178 ++++++++++++++++++------ contexts/__tests__/BillContext.test.tsx | 8 +- lib/bill-types.ts | 1 + 5 files changed, 158 insertions(+), 47 deletions(-) diff --git a/components/BillLookup.tsx b/components/BillLookup.tsx index de38ecb..d0443d6 100644 --- a/components/BillLookup.tsx +++ b/components/BillLookup.tsx @@ -81,7 +81,14 @@ export function BillLookup({ mode = "auto" }: BillLookupProps) { if (result.bill) { const migratedBill = migrateBillSchema(result.bill) - dispatch({ type: "LOAD_BILL", payload: migratedBill }) + dispatch({ + type: "LOAD_BILL", + payload: { + bill: migratedBill, + source: "shared", + sharedOriginBillId: trimmedId, + }, + }) setBillId("") setIsOpen(false) diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index ee99634..b44b9e0 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -674,7 +674,14 @@ function DesktopBillSplitter() { } const migratedBill = migrateBillSchema(result.bill) - dispatch({ type: 'LOAD_BILL', payload: migratedBill }) + dispatch({ + type: 'LOAD_BILL', + payload: { + bill: migratedBill, + source: 'shared', + sharedOriginBillId: trimmedId, + }, + }) toast({ title: "Bill loaded!", description: `Loaded "${migratedBill.title}"`, diff --git a/contexts/BillContext.tsx b/contexts/BillContext.tsx index 39e7bca..3647d22 100644 --- a/contexts/BillContext.tsx +++ b/contexts/BillContext.tsx @@ -4,7 +4,7 @@ import type React from "react" import { createContext, useContext, useReducer, useEffect, useRef } from "react" import { getBillFromCloud, getSharedBillIdFromSearch, storeBillInCloud } from "@/lib/sharing" import { isMigratableBill, isRecord, migrateBillSchema, type MigratableBill } from "@/lib/validation" -import type { Bill, BillStatus, Item, Person, SyncStatus, TaxTipAllocation } from "@/lib/bill-types" +import type { Bill, BillSource, BillStatus, Item, Person, SyncStatus, TaxTipAllocation } from "@/lib/bill-types" // State and Actions interface BillState { @@ -12,6 +12,8 @@ interface BillState { history: Bill[] historyIndex: number maxHistorySize: number + billSource: BillSource + sharedOriginBillId: string | null syncStatus: SyncStatus lastSyncTime: number | null } @@ -31,7 +33,14 @@ type BillAction = | { type: "UPDATE_ITEM"; payload: Item } | { type: "REMOVE_ITEM"; payload: string } | { type: "REORDER_ITEMS"; payload: { startIndex: number; endIndex: number } } - | { type: "LOAD_BILL"; payload: Bill } + | { + type: "LOAD_BILL" + payload: { + bill: Bill + source: "draft" | "shared" + sharedOriginBillId?: string | null + } + } | { type: "NEW_BILL" } | { type: "UNDO" } | { type: "REDO" } @@ -90,50 +99,100 @@ const initialState: BillState = { history: [], historyIndex: -1, maxHistorySize: 50, + billSource: "draft", + sharedOriginBillId: null, syncStatus: "never_synced", lastSyncTime: null, } +const EDITABLE_BILL_ACTIONS = new Set([ + "SET_BILL_TITLE", + "SET_BILL_STATUS", + "SET_NOTES", + "SET_TAX", + "SET_TIP", + "SET_DISCOUNT", + "SET_TAX_TIP_ALLOCATION", + "ADD_PERSON", + "UPDATE_PERSON", + "REMOVE_PERSON", + "ADD_ITEM", + "UPDATE_ITEM", + "REMOVE_ITEM", + "REORDER_ITEMS", +]) + +const createSharedBillCopy = (state: BillState): BillState => { + const sharedOriginBillId = state.sharedOriginBillId ?? state.currentBill.id + const currentBill = { + ...structuredClone(state.currentBill), + id: simpleUUID(), + } + + return { + ...state, + currentBill, + billSource: "shared_copy", + sharedOriginBillId, + syncStatus: "never_synced", + lastSyncTime: null, + } +} + +const getEditableState = (state: BillState, actionType: BillAction["type"]): BillState => { + if (state.billSource !== "shared") { + return state + } + + if (!EDITABLE_BILL_ACTIONS.has(actionType)) { + return state + } + + return createSharedBillCopy(state) +} + // Reducer function billReducer(state: BillState, action: BillAction): BillState { + const editableState = getEditableState(state, action.type) + switch (action.type) { case "SET_BILL_TITLE": { - const newBill = { ...state.currentBill, title: action.payload } - return addToHistory(state, newBill) + const newBill = { ...editableState.currentBill, title: action.payload } + return addToHistory(editableState, newBill) } case "SET_BILL_STATUS": { - const newBill = { ...state.currentBill, status: action.payload } - return addToHistory(state, newBill) + const newBill = { ...editableState.currentBill, status: action.payload } + return addToHistory(editableState, newBill) } case "SET_NOTES": { - const newBill = { ...state.currentBill, notes: action.payload } - return addToHistory(state, newBill) + const newBill = { ...editableState.currentBill, notes: action.payload } + return addToHistory(editableState, newBill) } case "SET_TAX": { - const newBill = { ...state.currentBill, tax: action.payload } - return addToHistory(state, newBill) + const newBill = { ...editableState.currentBill, tax: action.payload } + return addToHistory(editableState, newBill) } case "SET_TIP": { - const newBill = { ...state.currentBill, tip: action.payload } - return addToHistory(state, newBill) + const newBill = { ...editableState.currentBill, tip: action.payload } + return addToHistory(editableState, newBill) } case "SET_DISCOUNT": { - const newBill = { ...state.currentBill, discount: action.payload } - return addToHistory(state, newBill) + const newBill = { ...editableState.currentBill, discount: action.payload } + return addToHistory(editableState, newBill) } case "SET_TAX_TIP_ALLOCATION": { - const newBill = { ...state.currentBill, taxTipAllocation: action.payload } - return addToHistory(state, newBill) + const newBill = { ...editableState.currentBill, taxTipAllocation: action.payload } + return addToHistory(editableState, newBill) } case "ADD_PERSON": { - const usedColors = new Set(state.currentBill.people.map((p) => p.color)) + const usedColors = new Set(editableState.currentBill.people.map((p) => p.color)) let newColor = "" if (action.payload.color) { @@ -147,33 +206,33 @@ function billReducer(state: BillState, action: BillAction): BillState { id: simpleUUID(), name: action.payload.name, color: newColor, - colorIdx: state.currentBill.people.length % 6, // Assign color index for Pro design (0-5) + colorIdx: editableState.currentBill.people.length % 6, // Assign color index for Pro design (0-5) } const newBill = { - ...state.currentBill, - people: [...state.currentBill.people, newPerson], + ...editableState.currentBill, + people: [...editableState.currentBill.people, newPerson], } - return addToHistory(state, newBill) + return addToHistory(editableState, newBill) } case "UPDATE_PERSON": { const newBill = { - ...state.currentBill, - people: state.currentBill.people.map((p) => (p.id === action.payload.id ? action.payload : p)), + ...editableState.currentBill, + people: editableState.currentBill.people.map((p) => (p.id === action.payload.id ? action.payload : p)), } - return addToHistory(state, newBill) + return addToHistory(editableState, newBill) } case "REMOVE_PERSON": { const newBill = { - ...state.currentBill, - people: state.currentBill.people.filter((p) => p.id !== action.payload), - items: state.currentBill.items.map((item) => ({ + ...editableState.currentBill, + people: editableState.currentBill.people.filter((p) => p.id !== action.payload), + items: editableState.currentBill.items.map((item) => ({ ...item, splitWith: item.splitWith.filter((id) => id !== action.payload), })), } - return addToHistory(state, newBill) + return addToHistory(editableState, newBill) } case "ADD_ITEM": { @@ -182,46 +241,53 @@ function billReducer(state: BillState, action: BillAction): BillState { id: simpleUUID(), } const newBill = { - ...state.currentBill, - items: [...state.currentBill.items, newItem], + ...editableState.currentBill, + items: [...editableState.currentBill.items, newItem], } - return addToHistory(state, newBill) + return addToHistory(editableState, newBill) } case "UPDATE_ITEM": { const newBill = { - ...state.currentBill, - items: state.currentBill.items.map((item) => (item.id === action.payload.id ? action.payload : item)), + ...editableState.currentBill, + items: editableState.currentBill.items.map((item) => (item.id === action.payload.id ? action.payload : item)), } - return addToHistory(state, newBill) + return addToHistory(editableState, newBill) } case "REMOVE_ITEM": { const newBill = { - ...state.currentBill, - items: state.currentBill.items.filter((item) => item.id !== action.payload), + ...editableState.currentBill, + items: editableState.currentBill.items.filter((item) => item.id !== action.payload), } - return addToHistory(state, newBill) + return addToHistory(editableState, newBill) } case "REORDER_ITEMS": { const { startIndex, endIndex } = action.payload - const newItems = Array.from(state.currentBill.items) + const newItems = Array.from(editableState.currentBill.items) const [removed] = newItems.splice(startIndex, 1) newItems.splice(endIndex, 0, removed) const newBill = { - ...state.currentBill, + ...editableState.currentBill, items: newItems, } - return addToHistory(state, newBill) + return addToHistory(editableState, newBill) } case "LOAD_BILL": { return { ...initialState, - currentBill: action.payload, + currentBill: action.payload.bill, history: [], historyIndex: -1, + billSource: action.payload.source, + sharedOriginBillId: + action.payload.source === "shared" + ? action.payload.sharedOriginBillId ?? action.payload.bill.id + : action.payload.sharedOriginBillId ?? null, + syncStatus: action.payload.source === "shared" ? "synced" : "never_synced", + lastSyncTime: action.payload.source === "shared" ? Date.now() : null, } } @@ -232,6 +298,10 @@ function billReducer(state: BillState, action: BillAction): BillState { currentBill: newBill, history: [], historyIndex: -1, + billSource: "draft", + sharedOriginBillId: null, + syncStatus: "never_synced", + lastSyncTime: null, } } @@ -402,7 +472,13 @@ export function BillProvider({ children }: { children: React.ReactNode }) { if (saved) { const bill: unknown = JSON.parse(saved) if (isMigratableBill(bill)) { - dispatch({ type: "LOAD_BILL", payload: migrateBillSchema(bill) }) + dispatch({ + type: "LOAD_BILL", + payload: { + bill: migrateBillSchema(bill), + source: "draft", + }, + }) } } } catch (error) { @@ -417,7 +493,14 @@ export function BillProvider({ children }: { children: React.ReactNode }) { if (cloudResult.bill) { const migratedBill = migrateBillSchema(cloudResult.bill) - dispatch({ type: "LOAD_BILL", payload: migratedBill }) + dispatch({ + type: "LOAD_BILL", + payload: { + bill: migratedBill, + source: "shared", + sharedOriginBillId: sharedBillId, + }, + }) if (typeof window !== "undefined") { const event = new CustomEvent("bill-loaded-success", { @@ -437,7 +520,14 @@ export function BillProvider({ children }: { children: React.ReactNode }) { if (localSharedBill) { const migratedBill = migrateBillSchema(localSharedBill) - dispatch({ type: "LOAD_BILL", payload: migratedBill }) + dispatch({ + type: "LOAD_BILL", + payload: { + bill: migratedBill, + source: "shared", + sharedOriginBillId: sharedBillId, + }, + }) if (typeof window !== "undefined") { const event = new CustomEvent("bill-loaded-success", { diff --git a/contexts/__tests__/BillContext.test.tsx b/contexts/__tests__/BillContext.test.tsx index 8f9fb82..3415e7d 100644 --- a/contexts/__tests__/BillContext.test.tsx +++ b/contexts/__tests__/BillContext.test.tsx @@ -458,7 +458,13 @@ describe('BillContext', () => { }) act(() => { - result.current.dispatch({ type: 'LOAD_BILL', payload: testBill }) + result.current.dispatch({ + type: 'LOAD_BILL', + payload: { + bill: testBill, + source: 'draft', + }, + }) }) expect(result.current.state.currentBill.title).toBe('Loaded Bill') diff --git a/lib/bill-types.ts b/lib/bill-types.ts index 6224b8a..3042552 100644 --- a/lib/bill-types.ts +++ b/lib/bill-types.ts @@ -1,4 +1,5 @@ export type SyncStatus = "never_synced" | "syncing" | "synced" | "error" +export type BillSource = "draft" | "shared" | "shared_copy" export type SplitMethod = "even" | "shares" | "percent" | "exact" From 70e0153a0baed96ae272628630cce3f4b94c0bb8 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:44:15 -0400 Subject: [PATCH 06/13] fix: fork shared bills before editing --- contexts/BillContext.tsx | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/contexts/BillContext.tsx b/contexts/BillContext.tsx index 3647d22..101fc16 100644 --- a/contexts/BillContext.tsx +++ b/contexts/BillContext.tsx @@ -1,8 +1,8 @@ "use client" import type React from "react" -import { createContext, useContext, useReducer, useEffect, useRef } from "react" -import { getBillFromCloud, getSharedBillIdFromSearch, storeBillInCloud } from "@/lib/sharing" +import { createContext, useCallback, useContext, useReducer, useEffect, useRef } from "react" +import { getBillFromCloud, getSharedBillIdFromSearch, storeBillInCloud, stripSharedBillParams } from "@/lib/sharing" import { isMigratableBill, isRecord, migrateBillSchema, type MigratableBill } from "@/lib/validation" import type { Bill, BillSource, BillStatus, Item, Person, SyncStatus, TaxTipAllocation } from "@/lib/bill-types" @@ -390,6 +390,14 @@ const getSharedBillIdFromLocation = (): string | null => { return getSharedBillIdFromSearch(window.location.search) } +const clearSharedBillParamsFromLocation = () => { + if (typeof window === "undefined") return + + const nextSearch = stripSharedBillParams(window.location.search) + const nextUrl = `${window.location.pathname}${nextSearch}${window.location.hash}` + window.history.replaceState(window.history.state, "", nextUrl) +} + const generateShareUrl = (billId: string): string => { // Ensure we always use the root path for sharing const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' @@ -425,29 +433,41 @@ const BillContext = createContext<{ // Provider export function BillProvider({ children }: { children: React.ReactNode }) { - const [state, dispatch] = useReducer(billReducer, initialState) + const [state, rawDispatch] = useReducer(billReducer, initialState) const sharedBillIdRef = useRef(null) const sharedBillLoadRequestRef = useRef(0) const canUndo = state.historyIndex >= 0 const canRedo = state.historyIndex < state.history.length - 1 + const dispatch = useCallback((action: BillAction) => { + const shouldClearSharedUrl = + state.billSource === "shared" && (EDITABLE_BILL_ACTIONS.has(action.type) || action.type === "NEW_BILL") + + if (shouldClearSharedUrl) { + clearSharedBillParamsFromLocation() + } + + rawDispatch(action) + }, [state.billSource]) + // Auto-sync to cloud functionality const syncToCloud = async () => { + if (state.billSource === "shared") return if (state.syncStatus === "syncing") return // Avoid duplicate sync calls - dispatch({ type: "SYNC_TO_CLOUD" }) + rawDispatch({ type: "SYNC_TO_CLOUD" }) try { const result = await storeBillInCloud(state.currentBill) if (result.success) { - dispatch({ type: "SET_SYNC_STATUS", payload: "synced" }) + rawDispatch({ type: "SET_SYNC_STATUS", payload: "synced" }) } else { - dispatch({ type: "SET_SYNC_STATUS", payload: "error" }) + rawDispatch({ type: "SET_SYNC_STATUS", payload: "error" }) } } catch (error) { console.error("Sync to cloud failed:", error) - dispatch({ type: "SET_SYNC_STATUS", payload: "error" }) + rawDispatch({ type: "SET_SYNC_STATUS", payload: "error" }) } } @@ -472,7 +492,7 @@ export function BillProvider({ children }: { children: React.ReactNode }) { if (saved) { const bill: unknown = JSON.parse(saved) if (isMigratableBill(bill)) { - dispatch({ + rawDispatch({ type: "LOAD_BILL", payload: { bill: migrateBillSchema(bill), @@ -493,7 +513,7 @@ export function BillProvider({ children }: { children: React.ReactNode }) { if (cloudResult.bill) { const migratedBill = migrateBillSchema(cloudResult.bill) - dispatch({ + rawDispatch({ type: "LOAD_BILL", payload: { bill: migratedBill, @@ -520,7 +540,7 @@ export function BillProvider({ children }: { children: React.ReactNode }) { if (localSharedBill) { const migratedBill = migrateBillSchema(localSharedBill) - dispatch({ + rawDispatch({ type: "LOAD_BILL", payload: { bill: migratedBill, From 0c7ef25a272dba641150b5c5cc6f96e515060299 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:44:37 -0400 Subject: [PATCH 07/13] fix: keep shared views out of draft persistence --- contexts/BillContext.tsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/contexts/BillContext.tsx b/contexts/BillContext.tsx index 101fc16..afc3ab3 100644 --- a/contexts/BillContext.tsx +++ b/contexts/BillContext.tsx @@ -609,18 +609,24 @@ export function BillProvider({ children }: { children: React.ReactNode }) { } }, []) - // Debounced auto-save to localStorage whenever state changes (500ms delay) + // Debounced persistence whenever the active bill changes (500ms delay) useEffect(() => { const timeoutId = setTimeout(() => { try { - // Save current bill to main storage - localStorage.setItem("splitSimple_currentBill", JSON.stringify(state.currentBill)) - - // Also save to shared bills storage for sharing saveBillToLocalStorage(state.currentBill) + + if (state.billSource === "shared") { + return + } + + localStorage.setItem("splitSimple_currentBill", JSON.stringify(state.currentBill)) } catch (error) { console.error("Failed to save bill to localStorage:", error) + if (state.billSource === "shared") { + return + } + // Try to save with a smaller payload if the bill is too large try { const minimalBill = { @@ -644,13 +650,13 @@ export function BillProvider({ children }: { children: React.ReactNode }) { }, 500) return () => clearTimeout(timeoutId) - }, [state.currentBill]) + }, [state.billSource, state.currentBill]) // Debounced auto-sync to cloud when bill changes useEffect(() => { let timeoutId: NodeJS.Timeout | undefined - if (state.syncStatus === "never_synced") { + if (state.billSource !== "shared" && state.syncStatus === "never_synced") { timeoutId = setTimeout(() => { syncToCloud() }, 2000) // 2-second debounce @@ -661,7 +667,7 @@ export function BillProvider({ children }: { children: React.ReactNode }) { clearTimeout(timeoutId) } } - }, [state.currentBill, state.syncStatus]) + }, [state.billSource, state.currentBill, state.syncStatus]) return {children} } From edbf8b079fb2d7966a0ca49c1519b3c583b01eff Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:44:58 -0400 Subject: [PATCH 08/13] feat: show shared bill mode in the header --- components/BillSourceIndicator.tsx | 42 ++++++++++++++++++++++++++++++ components/ProBillSplitter.tsx | 6 ++++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 components/BillSourceIndicator.tsx diff --git a/components/BillSourceIndicator.tsx b/components/BillSourceIndicator.tsx new file mode 100644 index 0000000..cd3c8fe --- /dev/null +++ b/components/BillSourceIndicator.tsx @@ -0,0 +1,42 @@ +"use client" + +import { Copy, Link2 } from "lucide-react" +import { useBill } from "@/contexts/BillContext" +import { cn } from "@/lib/utils" + +const billSourceConfig = { + shared: { + icon: Link2, + label: "Viewing shared bill", + className: "text-sky-700 bg-sky-50", + }, + shared_copy: { + icon: Copy, + label: "Editing local copy", + className: "text-amber-700 bg-amber-50", + }, +} as const + +export function BillSourceIndicator({ className }: { className?: string }) { + const { state } = useBill() + + if (state.billSource === "draft") { + return null + } + + const config = billSourceConfig[state.billSource] + const Icon = config.icon + + return ( + + + {config.label} + + ) +} diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index b44b9e0..84d98eb 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -28,6 +28,7 @@ import { cn, formatCurrencyWithCents as formatCurrencySimple } from '@/lib/utils import { generateSummaryText, copyToClipboard } from '@/lib/export' import { useToast } from '@/hooks/use-toast' import { ShareBill } from '@/components/ShareBill' +import { BillSourceIndicator } from '@/components/BillSourceIndicator' import { SyncStatusIndicator } from '@/components/SyncStatusIndicator' import { useBillAnalytics } from '@/hooks/use-analytics' import { TIMING } from '@/lib/constants' @@ -1094,7 +1095,10 @@ function DesktopBillSplitter() { name="bill-title" autoComplete="off" /> - +
+ + +
From 62e29b63c20daf97634ab65be8c3b4c5afce8d83 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:47:30 -0400 Subject: [PATCH 09/13] fix: defer draft persistence during shared loads --- contexts/BillContext.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/contexts/BillContext.tsx b/contexts/BillContext.tsx index afc3ab3..b5cdc35 100644 --- a/contexts/BillContext.tsx +++ b/contexts/BillContext.tsx @@ -612,7 +612,13 @@ export function BillProvider({ children }: { children: React.ReactNode }) { // Debounced persistence whenever the active bill changes (500ms delay) useEffect(() => { const timeoutId = setTimeout(() => { + const hasSharedBillInLocation = getSharedBillIdFromLocation() !== null + try { + if (hasSharedBillInLocation && state.billSource === "draft") { + return + } + saveBillToLocalStorage(state.currentBill) if (state.billSource === "shared") { @@ -623,7 +629,7 @@ export function BillProvider({ children }: { children: React.ReactNode }) { } catch (error) { console.error("Failed to save bill to localStorage:", error) - if (state.billSource === "shared") { + if (state.billSource === "shared" || (hasSharedBillInLocation && state.billSource === "draft")) { return } @@ -655,8 +661,9 @@ export function BillProvider({ children }: { children: React.ReactNode }) { // Debounced auto-sync to cloud when bill changes useEffect(() => { let timeoutId: NodeJS.Timeout | undefined + const hasSharedBillInLocation = getSharedBillIdFromLocation() !== null - if (state.billSource !== "shared" && state.syncStatus === "never_synced") { + if (!hasSharedBillInLocation && state.billSource !== "shared" && state.syncStatus === "never_synced") { timeoutId = setTimeout(() => { syncToCloud() }, 2000) // 2-second debounce From b5cdc56836b38befadc4bea9bdc13f3e7b25e2cf Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:47:33 -0400 Subject: [PATCH 10/13] test: cover shared bill source transitions --- contexts/__tests__/BillContext.test.tsx | 79 +++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/contexts/__tests__/BillContext.test.tsx b/contexts/__tests__/BillContext.test.tsx index 3415e7d..f313862 100644 --- a/contexts/__tests__/BillContext.test.tsx +++ b/contexts/__tests__/BillContext.test.tsx @@ -17,7 +17,21 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( ) describe('BillContext', () => { + let storage: Record + beforeEach(() => { + storage = {} + jest.mocked(localStorage.getItem).mockImplementation((key: string) => storage[key] ?? null) + jest.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => { + storage[key] = value + }) + jest.mocked(localStorage.removeItem).mockImplementation((key: string) => { + delete storage[key] + }) + jest.mocked(localStorage.clear).mockImplementation(() => { + storage = {} + }) + // Clear localStorage before each test localStorage.clear() window.history.replaceState({}, '', '/') @@ -32,6 +46,8 @@ describe('BillContext', () => { expect(result.current.state.currentBill.people[0].name).toBe('Person 1') expect(result.current.state.currentBill.items).toEqual([]) expect(result.current.state.currentBill.status).toBe('active') + expect(result.current.state.billSource).toBe('draft') + expect(result.current.state.sharedOriginBillId).toBeNull() expect(result.current.state.syncStatus).toBe('never_synced') }) @@ -471,6 +487,8 @@ describe('BillContext', () => { expect(result.current.state.currentBill.status).toBe('active') expect(result.current.state.currentBill.people).toHaveLength(1) expect(result.current.state.currentBill.items).toHaveLength(1) + expect(result.current.state.billSource).toBe('draft') + expect(result.current.state.sharedOriginBillId).toBeNull() expect(result.current.state.history).toEqual([]) }) @@ -490,6 +508,9 @@ describe('BillContext', () => { expect(result.current.state.currentBill.id).toBe(sharedBill.id) }) expect(result.current.state.currentBill.title).toBe('Shared Bill') + expect(result.current.state.billSource).toBe('shared') + expect(result.current.state.sharedOriginBillId).toBe(sharedBill.id) + expect(result.current.state.syncStatus).toBe('synced') expect(getBillFromCloud).toHaveBeenCalledWith(sharedBill.id) }) @@ -512,6 +533,7 @@ describe('BillContext', () => { expect(result.current.state.currentBill.id).toBe(sharedBill.id) }) expect(result.current.state.currentBill.title).toBe('Loaded After Navigation') + expect(result.current.state.billSource).toBe('shared') expect(getBillFromCloud).toHaveBeenCalledWith(sharedBill.id) }) @@ -540,5 +562,62 @@ describe('BillContext', () => { }) expect(result.current.state.currentBill.title).toBe('Stable Shared Bill') }) + + it('should fork a shared bill into a local copy on first edit', async () => { + const sharedBill = createMockBill({ + id: '1780007206455-yojajgt', + title: 'Shared Bill', + }) + + jest.mocked(getBillFromCloud).mockResolvedValue({ bill: sharedBill }) + window.history.replaceState({}, '', `/?bill=${sharedBill.id}`) + + const { result } = renderHook(() => useBill(), { wrapper }) + + await waitFor(() => { + expect(result.current.state.currentBill.id).toBe(sharedBill.id) + }) + + act(() => { + result.current.dispatch({ type: 'SET_BILL_TITLE', payload: 'Edited Copy' }) + }) + + expect(result.current.state.currentBill.id).not.toBe(sharedBill.id) + expect(result.current.state.currentBill.title).toBe('Edited Copy') + expect(result.current.state.billSource).toBe('shared_copy') + expect(result.current.state.sharedOriginBillId).toBe(sharedBill.id) + expect(window.location.search).toBe('') + }) + + it('should preserve the current draft when a shared bill is loaded', async () => { + const localDraft = createMockBill({ + id: 'draft-bill-id', + title: 'Local Draft', + }) + const sharedBill = createMockBill({ + id: '1780007206455-yojajgt', + title: 'Shared Bill', + }) + + localStorage.setItem('splitSimple_currentBill', JSON.stringify(localDraft)) + jest.mocked(getBillFromCloud).mockResolvedValue({ bill: sharedBill }) + window.history.replaceState({}, '', `/?bill=${sharedBill.id}`) + + const { result } = renderHook(() => useBill(), { wrapper }) + + await waitFor(() => { + expect(result.current.state.currentBill.id).toBe(sharedBill.id) + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 600)) + }) + + const savedDraft = JSON.parse(localStorage.getItem('splitSimple_currentBill') || '{}') + const sharedCache = JSON.parse(localStorage.getItem('splitsimple_bills') || '{}') + + expect(savedDraft.title).toBe('Local Draft') + expect(sharedCache[sharedBill.id]?.title).toBe('Shared Bill') + }) }) }) From 3d519fe8e74d754b9b844d41a8c35a8cea2950b0 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:59:13 -0400 Subject: [PATCH 11/13] feat: normalize persisted bill payloads --- contexts/BillContext.tsx | 23 +++++++----------- lib/__tests__/persisted-bill.test.ts | 35 ++++++++++++++++++++++++++++ lib/persisted-bill.ts | 26 +++++++++++++++++++++ lib/sharing.ts | 3 ++- 4 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 lib/__tests__/persisted-bill.test.ts create mode 100644 lib/persisted-bill.ts diff --git a/contexts/BillContext.tsx b/contexts/BillContext.tsx index b5cdc35..27124c3 100644 --- a/contexts/BillContext.tsx +++ b/contexts/BillContext.tsx @@ -2,7 +2,8 @@ import type React from "react" import { createContext, useCallback, useContext, useReducer, useEffect, useRef } from "react" -import { getBillFromCloud, getSharedBillIdFromSearch, storeBillInCloud, stripSharedBillParams } from "@/lib/sharing" +import { getBillFromCloud, getSharedBillIdFromLocationParts, storeBillInCloud, stripSharedBillLocation } from "@/lib/sharing" +import { normalizeBillForPersistence } from "@/lib/persisted-bill" import { isMigratableBill, isRecord, migrateBillSchema, type MigratableBill } from "@/lib/validation" import type { Bill, BillSource, BillStatus, Item, Person, SyncStatus, TaxTipAllocation } from "@/lib/bill-types" @@ -354,6 +355,7 @@ function billReducer(state: BillState, action: BillAction): BillState { // Sharing functionality const saveBillToLocalStorage = (bill: Bill) => { try { + const normalizedBill = normalizeBillForPersistence(bill) const billsData = localStorage.getItem("splitsimple_bills") || "{}" const parsedBills: unknown = JSON.parse(billsData) const bills: Record = {} @@ -362,7 +364,7 @@ const saveBillToLocalStorage = (bill: Bill) => { bills[id] = storedBill } } - bills[bill.id] = bill + bills[normalizedBill.id] = normalizedBill localStorage.setItem("splitsimple_bills", JSON.stringify(bills)) } catch (error) { console.error("Failed to save bill to localStorage:", error) @@ -387,23 +389,16 @@ const loadBillFromLocalStorage = (billId: string): MigratableBill | null => { const getSharedBillIdFromLocation = (): string | null => { if (typeof window === "undefined") return null - return getSharedBillIdFromSearch(window.location.search) + return getSharedBillIdFromLocationParts(window.location.pathname, window.location.search) } const clearSharedBillParamsFromLocation = () => { if (typeof window === "undefined") return - const nextSearch = stripSharedBillParams(window.location.search) - const nextUrl = `${window.location.pathname}${nextSearch}${window.location.hash}` + const nextUrl = stripSharedBillLocation(window.location.pathname, window.location.search, window.location.hash) window.history.replaceState(window.history.state, "", nextUrl) } -const generateShareUrl = (billId: string): string => { - // Ensure we always use the root path for sharing - const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' - return `${baseUrl}/?bill=${billId}` -} - function addToHistory(state: BillState, newBill: Bill): BillState { const newHistory = state.history.slice(0, state.historyIndex + 1) newHistory.push(structuredClone(state.currentBill)) @@ -625,7 +620,7 @@ export function BillProvider({ children }: { children: React.ReactNode }) { return } - localStorage.setItem("splitSimple_currentBill", JSON.stringify(state.currentBill)) + localStorage.setItem("splitSimple_currentBill", JSON.stringify(normalizeBillForPersistence(state.currentBill))) } catch (error) { console.error("Failed to save bill to localStorage:", error) @@ -636,8 +631,8 @@ export function BillProvider({ children }: { children: React.ReactNode }) { // Try to save with a smaller payload if the bill is too large try { const minimalBill = { - ...state.currentBill, - items: state.currentBill.items.map(item => ({ + ...normalizeBillForPersistence(state.currentBill), + items: normalizeBillForPersistence(state.currentBill).items.map(item => ({ id: item.id, name: item.name, price: item.price, diff --git a/lib/__tests__/persisted-bill.test.ts b/lib/__tests__/persisted-bill.test.ts new file mode 100644 index 0000000..d4d8dc6 --- /dev/null +++ b/lib/__tests__/persisted-bill.test.ts @@ -0,0 +1,35 @@ +import { normalizeBillForPersistence } from "@/lib/persisted-bill" +import { createMockBill, createMockItem } from "../../tests/utils/test-utils" + +describe("normalizeBillForPersistence", () => { + it("removes empty seeded rows before saving", () => { + const bill = createMockBill({ + title: " Team Lunch ", + notes: " Remember dessert ", + items: [ + createMockItem({ name: "", price: "", quantity: 1, splitWith: [] }), + createMockItem({ name: "Tacos", price: "12.34", quantity: 1, splitWith: [] }), + ], + }) + + const normalized = normalizeBillForPersistence(bill) + + expect(normalized.title).toBe("Team Lunch") + expect(normalized.notes).toBe("Remember dessert") + expect(normalized.items).toHaveLength(1) + expect(normalized.items[0].name).toBe("Tacos") + }) + + it("keeps partially filled rows that have meaningful content", () => { + const bill = createMockBill({ + items: [ + createMockItem({ name: "", price: "5.00", quantity: 1, splitWith: [] }), + ], + }) + + const normalized = normalizeBillForPersistence(bill) + + expect(normalized.items).toHaveLength(1) + expect(normalized.items[0].price).toBe("5.00") + }) +}) diff --git a/lib/persisted-bill.ts b/lib/persisted-bill.ts new file mode 100644 index 0000000..77f8c47 --- /dev/null +++ b/lib/persisted-bill.ts @@ -0,0 +1,26 @@ +import type { Bill, Item } from "@/lib/bill-types" + +function hasMeaningfulItemContent(item: Item): boolean { + return ( + item.name.trim() !== "" || + item.price.trim() !== "" || + item.quantity !== 1 || + item.splitWith.length > 0 || + Object.keys(item.customSplits ?? {}).length > 0 + ) +} + +export function normalizeBillForPersistence(bill: Bill): Bill { + return { + ...bill, + title: bill.title.trim() || "New Bill", + notes: bill.notes.trim(), + items: bill.items + .filter(hasMeaningfulItemContent) + .map((item) => ({ + ...item, + name: item.name.trim(), + price: item.price.trim(), + })), + } +} diff --git a/lib/sharing.ts b/lib/sharing.ts index 49e8d68..e87c746 100644 --- a/lib/sharing.ts +++ b/lib/sharing.ts @@ -1,4 +1,5 @@ import type { Bill, CloudBillResult, CloudStoreResult } from "@/lib/bill-types" +import { normalizeBillForPersistence } from "@/lib/persisted-bill" import { isMigratableBill, isRecord, migrateBillSchema } from "@/lib/validation" const FULL_BILL_ID_PATTERN = /^\d{13}-[a-z0-9]+$/i @@ -58,7 +59,7 @@ export async function storeBillInCloud(bill: Bill): Promise { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ bill }), + body: JSON.stringify({ bill: normalizeBillForPersistence(bill) }), }) if (!response.ok) { From f5c59c1b91a6367a92c685e3c62fe02e55b801d9 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:59:27 -0400 Subject: [PATCH 12/13] refactor: add dedicated shared route helpers --- lib/__tests__/sharing.test.ts | 29 +++++++++++++++++++++++- lib/sharing.ts | 42 +++++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/sharing.test.ts b/lib/__tests__/sharing.test.ts index e19bbbc..d53c287 100644 --- a/lib/__tests__/sharing.test.ts +++ b/lib/__tests__/sharing.test.ts @@ -1,4 +1,12 @@ -import { extractBillIdFromInput, getSharedBillIdFromSearch, stripSharedBillParams } from '@/lib/sharing' +import { + buildSharedBillPath, + extractBillIdFromInput, + getSharedBillIdFromLocationParts, + getSharedBillIdFromPathname, + getSharedBillIdFromSearch, + stripSharedBillLocation, + stripSharedBillParams, +} from '@/lib/sharing' describe('extractBillIdFromInput', () => { it('returns a raw bill ID unchanged', () => { @@ -11,6 +19,12 @@ describe('extractBillIdFromInput', () => { ).toBe('1780007206455-yojajgt') }) + it('extracts the bill ID from the dedicated shared route', () => { + expect( + extractBillIdFromInput('https://splitsimple.anuragd.me/b/1780007206455-yojajgt') + ).toBe('1780007206455-yojajgt') + }) + it('extracts the bill ID from a query string fragment', () => { expect(extractBillIdFromInput('?bill=1780007206455-yojajgt')).toBe('1780007206455-yojajgt') }) @@ -21,8 +35,21 @@ describe('shared bill search helpers', () => { expect(getSharedBillIdFromSearch('?bill=1780007206455-yojajgt&view=breakdown')).toBe('1780007206455-yojajgt') }) + it('reads the shared bill id from the dedicated shared path', () => { + expect(getSharedBillIdFromPathname('/b/1780007206455-yojajgt')).toBe('1780007206455-yojajgt') + expect(getSharedBillIdFromLocationParts('/b/1780007206455-yojajgt', '?view=breakdown')).toBe('1780007206455-yojajgt') + }) + it('clears shared bill params while preserving unrelated params', () => { expect(stripSharedBillParams('bill=1780007206455-yojajgt&view=breakdown')).toBe('?view=breakdown') expect(stripSharedBillParams('?share=1780007206455-yojajgt')).toBe('') }) + + it('strips shared bill route state back to the draft workspace', () => { + expect(stripSharedBillLocation('/b/1780007206455-yojajgt', '?view=breakdown')).toBe('/?view=breakdown') + }) + + it('builds the dedicated shared bill path', () => { + expect(buildSharedBillPath('1780007206455-yojajgt')).toBe('/b/1780007206455-yojajgt') + }) }) diff --git a/lib/sharing.ts b/lib/sharing.ts index e87c746..6e64a58 100644 --- a/lib/sharing.ts +++ b/lib/sharing.ts @@ -3,16 +3,31 @@ import { normalizeBillForPersistence } from "@/lib/persisted-bill" import { isMigratableBill, isRecord, migrateBillSchema } from "@/lib/validation" const FULL_BILL_ID_PATTERN = /^\d{13}-[a-z0-9]+$/i +const SHARED_BILL_PATH_PATTERN = /^\/b\/([^/?#]+)\/?$/ function normalizeSearchInput(search: string): string { return search.startsWith("?") ? search.slice(1) : search } +function normalizeSearchOutput(search: string): string { + if (!search) return "" + return search.startsWith("?") ? search : `?${search}` +} + export function getSharedBillIdFromSearch(search: string): string | null { const params = new URLSearchParams(normalizeSearchInput(search)) return params.get("bill") || params.get("share") } +export function getSharedBillIdFromPathname(pathname: string): string | null { + const match = pathname.match(SHARED_BILL_PATH_PATTERN) + return match ? decodeURIComponent(match[1]) : null +} + +export function getSharedBillIdFromLocationParts(pathname: string, search: string): string | null { + return getSharedBillIdFromPathname(pathname) || getSharedBillIdFromSearch(search) +} + export function stripSharedBillParams(search: string): string { const params = new URLSearchParams(normalizeSearchInput(search)) params.delete("bill") @@ -22,6 +37,20 @@ export function stripSharedBillParams(search: string): string { return nextSearch ? `?${nextSearch}` : "" } +export function buildSharedBillPath(billId: string): string { + return `/b/${encodeURIComponent(billId)}` +} + +export function buildAppUrl(pathname: string, search = "", hash = ""): string { + return `${pathname}${normalizeSearchOutput(search)}${hash}` +} + +export function stripSharedBillLocation(pathname: string, search: string, hash = ""): string { + const nextPathname = getSharedBillIdFromPathname(pathname) ? "/" : pathname + const nextSearch = stripSharedBillParams(search) + return buildAppUrl(nextPathname, nextSearch, hash) +} + export function extractBillIdFromInput(input: string): string { const trimmed = input.trim().replace(/^#/, "") if (!trimmed) return "" @@ -38,12 +67,22 @@ export function extractBillIdFromInput(input: string): string { } } + const pathnameBillId = getSharedBillIdFromPathname(trimmed) + if (pathnameBillId) { + return pathnameBillId + } + try { const url = new URL(trimmed) const sharedId = getSharedBillIdFromSearch(url.search) if (sharedId) { return sharedId.trim() } + + const pathId = getSharedBillIdFromPathname(url.pathname) + if (pathId) { + return pathId.trim() + } } catch { // Fall through to raw input so callers can show the right validation error. } @@ -157,7 +196,6 @@ export async function getBillFromCloud(billId: string): Promise // Generate shareable URL export function generateCloudShareUrl(billId: string): string { - // Ensure we always use the root path for sharing const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' - return `${baseUrl}/?bill=${billId}` + return `${baseUrl}${buildSharedBillPath(billId)}` } From aaf8651579096ee46e41e5949d9f04acae90c7e6 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 29 May 2026 01:59:32 -0400 Subject: [PATCH 13/13] feat: add dedicated shared bill routes --- app/b/[billId]/page.tsx | 10 ++++++ app/page.tsx | 45 +++++++++++++++++++++++-- components/BillLookup.tsx | 13 +++++-- components/ProBillSplitter.tsx | 12 ++++--- contexts/__tests__/BillContext.test.tsx | 20 +++++++++++ 5 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 app/b/[billId]/page.tsx diff --git a/app/b/[billId]/page.tsx b/app/b/[billId]/page.tsx new file mode 100644 index 0000000..8e7221c --- /dev/null +++ b/app/b/[billId]/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react" +import { ProBillSplitter } from "@/components/ProBillSplitter" + +export default function SharedBillPage() { + return ( + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx index ae26598..c1cf697 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,48 @@ -"use client" - import { Suspense } from "react" +import { redirect } from "next/navigation" import { ProBillSplitter } from "@/components/ProBillSplitter" +import { buildSharedBillPath, getSharedBillIdFromSearch, stripSharedBillParams } from "@/lib/sharing" + +interface HomePageProps { + searchParams?: Promise> +} + +function getFirstParamValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +export default async function HomePage({ searchParams }: HomePageProps) { + const resolvedSearchParams = (await searchParams) ?? {} + const legacyBillId = + getFirstParamValue(resolvedSearchParams.bill) || + getFirstParamValue(resolvedSearchParams.share) + + if (legacyBillId) { + const rawSearch = new URLSearchParams() + + for (const [key, value] of Object.entries(resolvedSearchParams)) { + if (Array.isArray(value)) { + value.forEach((entry) => { + if (entry !== undefined) { + rawSearch.append(key, entry) + } + }) + continue + } + + if (value !== undefined) { + rawSearch.set(key, value) + } + } + + const nextSearch = stripSharedBillParams(`?${rawSearch.toString()}`) + redirect(`${buildSharedBillPath(legacyBillId)}${nextSearch}`) + } -export default function HomePage() { return ( diff --git a/components/BillLookup.tsx b/components/BillLookup.tsx index d0443d6..198909b 100644 --- a/components/BillLookup.tsx +++ b/components/BillLookup.tsx @@ -1,12 +1,13 @@ "use client" import React, { useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Label } from "@/components/ui/label" import { Search, Loader2 } from "lucide-react" -import { extractBillIdFromInput, getBillFromCloud } from "@/lib/sharing" +import { buildAppUrl, buildSharedBillPath, extractBillIdFromInput, getBillFromCloud } from "@/lib/sharing" import { useBill } from "@/contexts/BillContext" import { useToast } from "@/hooks/use-toast" import { migrateBillSchema } from "@/lib/validation" @@ -23,6 +24,8 @@ export function BillLookup({ mode = "auto" }: BillLookupProps) { const { toast } = useToast() const analytics = useBillAnalytics() const isMobile = useIsMobile() + const router = useRouter() + const searchParams = useSearchParams() const [billId, setBillId] = useState("") const [isLoading, setIsLoading] = useState(false) const [isOpen, setIsOpen] = useState(false) @@ -89,6 +92,10 @@ export function BillLookup({ mode = "auto" }: BillLookupProps) { sharedOriginBillId: trimmedId, }, }) + const nextParams = new URLSearchParams(searchParams.toString()) + nextParams.delete("bill") + nextParams.delete("share") + router.push(buildAppUrl(buildSharedBillPath(trimmedId), nextParams.toString()), { scroll: false }) setBillId("") setIsOpen(false) @@ -139,7 +146,7 @@ export function BillLookup({ mode = "auto" }: BillLookupProps) { Load Bill by ID - Enter a bill ID to load a shared bill. You can find the bill ID in the share URL. + Enter a bill ID to load a shared bill. You can paste the full share link too.
@@ -165,7 +172,7 @@ export function BillLookup({ mode = "auto" }: BillLookupProps) {

{error}

)}

- Example: If the URL is ?bill=1763442653885-vlpkbu4, + Example: If the URL is /b/1763442653885-vlpkbu4, enter 1763442653885-vlpkbu4

diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index 84d98eb..73c51f8 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -32,7 +32,7 @@ import { BillSourceIndicator } from '@/components/BillSourceIndicator' import { SyncStatusIndicator } from '@/components/SyncStatusIndicator' import { useBillAnalytics } from '@/hooks/use-analytics' import { TIMING } from '@/lib/constants' -import { extractBillIdFromInput, getBillFromCloud, stripSharedBillParams } from '@/lib/sharing' +import { buildAppUrl, buildSharedBillPath, extractBillIdFromInput, getBillFromCloud, stripSharedBillLocation } from '@/lib/sharing' import { migrateBillSchema } from '@/lib/validation' import { useIsMobile } from '@/hooks/use-mobile' import { MobileSpreadsheetView } from '@/components/MobileSpreadsheetView' @@ -513,8 +513,8 @@ function DesktopBillSplitter() { }, [dispatch, toast]) const confirmNewBill = useCallback(() => { - const nextSearch = stripSharedBillParams(searchParams.toString()) - router.replace(nextSearch ? `${pathname}${nextSearch}` : pathname, { scroll: false }) + const nextUrl = stripSharedBillLocation(pathname, searchParams.toString()) + router.replace(nextUrl, { scroll: false }) dispatch({ type: 'NEW_BILL' }) toast({ title: "New bill created", variant: "success" }) analytics.trackBillCreated() @@ -683,6 +683,10 @@ function DesktopBillSplitter() { sharedOriginBillId: trimmedId, }, }) + const nextParams = new URLSearchParams(searchParams.toString()) + nextParams.delete('bill') + nextParams.delete('share') + router.push(buildAppUrl(buildSharedBillPath(trimmedId), nextParams.toString()), { scroll: false }) toast({ title: "Bill loaded!", description: `Loaded "${migratedBill.title}"`, @@ -696,7 +700,7 @@ function DesktopBillSplitter() { setIsLoadingBill(false) } } - }, [billId, dispatch, toast, analytics]) + }, [billId, dispatch, router, searchParams, toast, analytics]) // --- Copy Breakdown --- const copyBreakdown = useCallback(async () => { diff --git a/contexts/__tests__/BillContext.test.tsx b/contexts/__tests__/BillContext.test.tsx index f313862..4defa0a 100644 --- a/contexts/__tests__/BillContext.test.tsx +++ b/contexts/__tests__/BillContext.test.tsx @@ -514,6 +514,25 @@ describe('BillContext', () => { expect(getBillFromCloud).toHaveBeenCalledWith(sharedBill.id) }) + it('should load a shared bill from the dedicated shared route on mount', async () => { + const sharedBill = createMockBill({ + id: '1780007206455-yojajgt', + title: 'Path Shared Bill', + }) + + jest.mocked(getBillFromCloud).mockResolvedValue({ bill: sharedBill }) + window.history.replaceState({}, '', `/b/${sharedBill.id}`) + + const { result } = renderHook(() => useBill(), { wrapper }) + + await waitFor(() => { + expect(result.current.state.currentBill.id).toBe(sharedBill.id) + }) + + expect(result.current.state.billSource).toBe('shared') + expect(getBillFromCloud).toHaveBeenCalledWith(sharedBill.id) + }) + it('should load a shared bill when the URL changes after mount', async () => { const sharedBill = createMockBill({ id: '1780007206455-yojajgt', @@ -586,6 +605,7 @@ describe('BillContext', () => { expect(result.current.state.currentBill.title).toBe('Edited Copy') expect(result.current.state.billSource).toBe('shared_copy') expect(result.current.state.sharedOriginBillId).toBe(sharedBill.id) + expect(window.location.pathname).toBe('/') expect(window.location.search).toBe('') })