Skip to content

Commit 65e9cc5

Browse files
committed
refactor: extract BillEntryForm to dedupe price/amount-entry forms
Quality Gate still failed at exactly the 3.0% duplication threshold after the first dedup pass — the price-history and amount-entries 'quick add' forms in bills/[id]/page.tsx were themselves duplicated near-verbatim (amount + date inputs, notes, Cancel/Save buttons; only the date's aria-label and the optional 'Valid until' field differ). Extract into a shared BillEntryForm component (controlled props, a children slot for the price-history form's extra EndDate field). Verified pixel-identical rendering: ran the four bill visual tests locally against a fresh production build — all four pass against the baselines the update-visual-snapshots bot already committed, meaning this refactor produced byte-for-byte the same output as what's on the branch now. lint, tsc --noEmit, npm run build, and the full Jest suite (1156 tests) all still pass. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbB6xayXB6kdEAq18fEVkF
1 parent a9cea98 commit 65e9cc5

2 files changed

Lines changed: 127 additions & 101 deletions

File tree

anything-frontend/src/app/bills/[id]/page.tsx

Lines changed: 27 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
FREQUENCY_LABELS,
2121
} from "@/hooks/useBills";
2222
import { Button } from "@/components/ui/button";
23+
import { BillEntryForm } from "@/components/BillEntryForm";
2324
import { toast } from "sonner";
2425
import { isSafeUrl } from "@/lib/utils";
2526
import {
@@ -402,30 +403,20 @@ export default function BillDetailPage() {
402403

403404
{/* Add price form */}
404405
{showAddPrice && (
405-
<form
406+
<BillEntryForm
406407
onSubmit={handleAddPrice}
407-
className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3 space-y-2"
408+
amount={newAmount}
409+
onAmountChange={setNewAmount}
410+
date={newDate}
411+
onDateChange={setNewDate}
412+
dateAriaLabel="Effective date"
413+
notes={newNotes}
414+
onNotesChange={setNewNotes}
415+
onCancel={() => setShowAddPrice(false)}
416+
isPending={addPrice.isPending}
417+
isOnline={isOnline}
418+
offlineTitle="Adding a price entry requires an internet connection"
408419
>
409-
<div className="flex gap-2">
410-
<input
411-
type="number"
412-
value={newAmount}
413-
onChange={(e) => setNewAmount(e.target.value)}
414-
placeholder="Amount"
415-
min="0.01"
416-
step="0.01"
417-
required
418-
className="flex-1 px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
419-
/>
420-
<input
421-
type="date"
422-
value={newDate}
423-
onChange={(e) => setNewDate(e.target.value)}
424-
required
425-
aria-label="Effective date"
426-
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
427-
/>
428-
</div>
429420
<div>
430421
<label htmlFor="bill-price-end-date" className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
431422
Valid until (optional)
@@ -439,34 +430,7 @@ export default function BillDetailPage() {
439430
className="w-full px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
440431
/>
441432
</div>
442-
<input
443-
type="text"
444-
value={newNotes}
445-
onChange={(e) => setNewNotes(e.target.value)}
446-
placeholder="Notes (optional)"
447-
className="w-full px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
448-
/>
449-
<div className="flex gap-2">
450-
<Button
451-
type="button"
452-
variant="outline"
453-
size="sm"
454-
className="flex-1"
455-
onClick={() => setShowAddPrice(false)}
456-
>
457-
Cancel
458-
</Button>
459-
<Button
460-
type="submit"
461-
size="sm"
462-
className="flex-1"
463-
disabled={addPrice.isPending || !isOnline}
464-
title={isOnline ? undefined : "Adding a price entry requires an internet connection"}
465-
>
466-
{addPrice.isPending ? "Saving..." : "Save"}
467-
</Button>
468-
</div>
469-
</form>
433+
</BillEntryForm>
470434
)}
471435

472436
{historyLoading && (
@@ -544,58 +508,20 @@ export default function BillDetailPage() {
544508
</div>
545509

546510
{showAddAmountEntry && (
547-
<form
511+
<BillEntryForm
548512
onSubmit={handleAddAmountEntry}
549-
className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3 space-y-2"
550-
>
551-
<div className="flex gap-2">
552-
<input
553-
type="number"
554-
value={newEntryAmount}
555-
onChange={(e) => setNewEntryAmount(e.target.value)}
556-
placeholder="Amount"
557-
min="0.01"
558-
step="0.01"
559-
required
560-
className="flex-1 px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
561-
/>
562-
<input
563-
type="date"
564-
value={newEntryPeriodDate}
565-
onChange={(e) => setNewEntryPeriodDate(e.target.value)}
566-
required
567-
aria-label="Period date"
568-
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
569-
/>
570-
</div>
571-
<input
572-
type="text"
573-
value={newEntryNotes}
574-
onChange={(e) => setNewEntryNotes(e.target.value)}
575-
placeholder="Notes (optional)"
576-
className="w-full px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
577-
/>
578-
<div className="flex gap-2">
579-
<Button
580-
type="button"
581-
variant="outline"
582-
size="sm"
583-
className="flex-1"
584-
onClick={() => setShowAddAmountEntry(false)}
585-
>
586-
Cancel
587-
</Button>
588-
<Button
589-
type="submit"
590-
size="sm"
591-
className="flex-1"
592-
disabled={addAmountEntry.isPending || !isOnline}
593-
title={isOnline ? undefined : "Adding an amount entry requires an internet connection"}
594-
>
595-
{addAmountEntry.isPending ? "Saving..." : "Save"}
596-
</Button>
597-
</div>
598-
</form>
513+
amount={newEntryAmount}
514+
onAmountChange={setNewEntryAmount}
515+
date={newEntryPeriodDate}
516+
onDateChange={setNewEntryPeriodDate}
517+
dateAriaLabel="Period date"
518+
notes={newEntryNotes}
519+
onNotesChange={setNewEntryNotes}
520+
onCancel={() => setShowAddAmountEntry(false)}
521+
isPending={addAmountEntry.isPending}
522+
isOnline={isOnline}
523+
offlineTitle="Adding an amount entry requires an internet connection"
524+
/>
599525
)}
600526

601527
{amountEntriesLoading && (
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"use client";
2+
3+
import type { ReactNode } from "react";
4+
import { Button } from "@/components/ui/button";
5+
6+
interface BillEntryFormProps {
7+
onSubmit: (e: React.FormEvent) => void;
8+
amount: string;
9+
onAmountChange: (value: string) => void;
10+
date: string;
11+
onDateChange: (value: string) => void;
12+
dateAriaLabel: string;
13+
notes: string;
14+
onNotesChange: (value: string) => void;
15+
onCancel: () => void;
16+
isPending: boolean;
17+
isOnline: boolean;
18+
offlineTitle: string;
19+
/** Extra field(s) rendered between the amount/date row and notes — e.g. an optional end date. */
20+
children?: ReactNode;
21+
}
22+
23+
/**
24+
* Inline "quick add" form shared by the price-history and amount-entries
25+
* sections of the bill detail page: amount + date, optional extra fields,
26+
* notes, then Cancel/Save. Both sections have the same shape (an amount tied
27+
* to a date, plus a note) — this keeps them from drifting apart in copy or
28+
* behavior as one changes.
29+
*/
30+
export function BillEntryForm({
31+
onSubmit,
32+
amount,
33+
onAmountChange,
34+
date,
35+
onDateChange,
36+
dateAriaLabel,
37+
notes,
38+
onNotesChange,
39+
onCancel,
40+
isPending,
41+
isOnline,
42+
offlineTitle,
43+
children,
44+
}: Readonly<BillEntryFormProps>) {
45+
return (
46+
<form
47+
onSubmit={onSubmit}
48+
className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3 space-y-2"
49+
>
50+
<div className="flex gap-2">
51+
<input
52+
type="number"
53+
value={amount}
54+
onChange={(e) => onAmountChange(e.target.value)}
55+
placeholder="Amount"
56+
min="0.01"
57+
step="0.01"
58+
required
59+
className="flex-1 px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
60+
/>
61+
<input
62+
type="date"
63+
value={date}
64+
onChange={(e) => onDateChange(e.target.value)}
65+
required
66+
aria-label={dateAriaLabel}
67+
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
68+
/>
69+
</div>
70+
{children}
71+
<input
72+
type="text"
73+
value={notes}
74+
onChange={(e) => onNotesChange(e.target.value)}
75+
placeholder="Notes (optional)"
76+
className="w-full px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
77+
/>
78+
<div className="flex gap-2">
79+
<Button
80+
type="button"
81+
variant="outline"
82+
size="sm"
83+
className="flex-1"
84+
onClick={onCancel}
85+
>
86+
Cancel
87+
</Button>
88+
<Button
89+
type="submit"
90+
size="sm"
91+
className="flex-1"
92+
disabled={isPending || !isOnline}
93+
title={isOnline ? undefined : offlineTitle}
94+
>
95+
{isPending ? "Saving..." : "Save"}
96+
</Button>
97+
</div>
98+
</form>
99+
);
100+
}

0 commit comments

Comments
 (0)