@@ -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() {
-
- {people.length === 0 ? (
-
- Add first person
- {modKey}{shiftKey}P
-
- ) : (
-
{
- if (items.length === 0) {
- addItem()
- return
- }
- setSelectedCell({ row: 0, col: 'name' })
- setEditing(true)
- }}
- className="w-full h-9 px-3 rounded-md bg-primary hover:bg-primary/90 text-xs font-bold text-white transition-transform active:scale-[0.97] flex items-center justify-between"
- >
- Add items
- {modKey}{shiftKey}N
-
- )}
- {people.length === 0 ? (
-
{
- addPerson()
- if (items.length === 0) {
- addItem()
- }
- }}
- className="w-full h-9 px-3 rounded-md bg-muted hover:bg-muted-foreground/15 text-xs font-bold text-foreground transition-colors flex items-center justify-between"
- title="Adds a person first, then takes you to add items"
- >
- Add items
- {modKey}{shiftKey}N
-
- ) : (
+
+
+
+
+ {people.length === 0 ? (
+
+ Add first person
+ {modKey}{shiftKey}P
+
+ ) : (
+ {
+ if (items.length === 0) {
+ addItem()
+ return
+ }
+ setSelectedCell({ row: 0, col: 'name' })
+ setEditing(true)
+ }}
+ className="w-full h-9 px-3 rounded-md bg-primary hover:bg-primary/90 text-xs font-bold text-white transition-transform active:scale-[0.97] flex items-center justify-between"
+ >
+ Add items
+ {modKey}{shiftKey}N
+
+ )}
- Add another person
+ {people.length === 0 ? "Add first person" : "Add another person"}
{modKey}{shiftKey}P
- )}
-
- 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.
-
-
- Add Item
-
+
) : (
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/