Compare commits
3 Commits
b25be070ed
...
c41f1a8a7b
| Author | SHA1 | Date |
|---|---|---|
|
|
c41f1a8a7b | |
|
|
df1f61d6cc | |
|
|
1238ac5b14 |
|
|
@ -37,18 +37,21 @@ export default function SearchFilterPanel({
|
|||
<section className={cn(
|
||||
embedded
|
||||
? 'rounded-lg'
|
||||
: 'surface-premium',
|
||||
: 'overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm shadow-black/5 backdrop-blur-sm',
|
||||
className,
|
||||
)}>
|
||||
<div className={cn('flex flex-wrap items-center gap-3', embedded ? 'py-1' : 'px-4 py-3')}>
|
||||
<div className={cn(
|
||||
'flex flex-wrap items-center gap-3',
|
||||
embedded ? 'py-1' : 'bg-muted/25 px-3 py-2.5 sm:px-4',
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCollapsedChange?.(!collapsed)}
|
||||
aria-expanded={!collapsed}
|
||||
className="group inline-flex min-w-0 flex-1 items-center gap-3 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="group inline-flex min-w-0 flex-1 items-center gap-2.5 rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-foreground">{title}</span>
|
||||
|
|
@ -60,7 +63,7 @@ export default function SearchFilterPanel({
|
|||
</button>
|
||||
|
||||
{hasFilters && onClear && (
|
||||
<Button type="button" variant="ghost" onClick={onClear} className="h-8 gap-2 px-2.5 text-xs">
|
||||
<Button type="button" variant="outline" onClick={onClear} className="h-8 gap-2 px-2.5 text-xs">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
|
|
@ -71,7 +74,7 @@ export default function SearchFilterPanel({
|
|||
{!collapsed && (
|
||||
<div className={cn(
|
||||
'space-y-3',
|
||||
embedded ? 'pt-3' : 'border-t border-border/60 px-4 py-4',
|
||||
embedded ? 'pt-3' : 'border-t border-border/60 bg-card/45 px-3 py-3 sm:px-4',
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useId, type ComponentProps } from 'react';
|
||||
import { type ComponentProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BRAND, BRAND_GLYPHS, type BrandGlyphName } from '@/lib/brand';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
|
@ -35,104 +35,18 @@ export function BrandGlyph({
|
|||
className?: string;
|
||||
}) {
|
||||
const glyph = BRAND_GLYPHS[name];
|
||||
const gradientId = `bt-glyph-${useId().replace(/:/g, '')}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl',
|
||||
'border border-primary/20 bg-primary/[0.08] text-primary shadow-sm shadow-primary/10',
|
||||
className,
|
||||
)}
|
||||
aria-label={glyph.label}
|
||||
role="img"
|
||||
>
|
||||
<svg viewBox="0 0 48 48" className="h-7 w-7" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="8" x2="40" y1="40" y2="8" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="currentColor" stopOpacity="0.68" />
|
||||
<stop offset="1" stopColor="currentColor" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<BrandGlyphShape name={name} gradientId={gradientId} />
|
||||
</svg>
|
||||
</span>
|
||||
<img
|
||||
src={glyph.asset}
|
||||
alt={glyph.label}
|
||||
width={40}
|
||||
height={40}
|
||||
draggable={false}
|
||||
// Fail soft: if the asset can't load, hide rather than show a broken image —
|
||||
// the adjacent title/label carries the meaning.
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
className={cn('inline-block h-10 w-10 shrink-0 select-none rounded-xl object-contain shadow-sm', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandGlyphShape({ name, gradientId }: { name: BrandGlyphName; gradientId: string }) {
|
||||
const fill = `url(#${gradientId})`;
|
||||
const commonStroke = 'currentColor';
|
||||
|
||||
switch (name) {
|
||||
case 'bills':
|
||||
return (
|
||||
<>
|
||||
<path d="M13 8h22v31l-4-3-4 3-4-3-4 3-4-3-2 1.5V8Z" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinejoin="round" opacity="0.88" />
|
||||
<path d="M18 17h12M18 24h10M18 31h7" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" opacity="0.72" />
|
||||
<path d="m27 17 3 3 7-8" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
);
|
||||
case 'calendar':
|
||||
return (
|
||||
<>
|
||||
<rect x="10" y="12" width="28" height="27" rx="5" fill="none" stroke={commonStroke} strokeWidth="3" />
|
||||
<path d="M16 8v8M32 8v8M11 20h26" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" />
|
||||
<rect x="17" y="25" width="6" height="6" rx="2" fill={fill} />
|
||||
<path d="m25 31 3 3 7-8" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
);
|
||||
case 'analytics':
|
||||
return (
|
||||
<>
|
||||
<path d="M10 36h30" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" opacity="0.35" />
|
||||
<path d="M12 31 20 23l7 5 10-14" fill="none" stroke={commonStroke} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="20" cy="23" r="3" fill={fill} />
|
||||
<circle cx="37" cy="14" r="3" fill={fill} />
|
||||
</>
|
||||
);
|
||||
case 'data':
|
||||
return (
|
||||
<>
|
||||
<ellipse cx="24" cy="13" rx="13" ry="5" fill="none" stroke={commonStroke} strokeWidth="3" />
|
||||
<path d="M11 13v19c0 2.8 5.8 5 13 5s13-2.2 13-5V13" fill="none" stroke={commonStroke} strokeWidth="3" />
|
||||
<path d="M11 22c0 2.8 5.8 5 13 5s13-2.2 13-5M11 31c0 2.8 5.8 5 13 5s13-2.2 13-5" stroke={commonStroke} strokeWidth="3" opacity="0.58" />
|
||||
<path d="m29 23 3 3 7-8" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
);
|
||||
case 'banking':
|
||||
return (
|
||||
<>
|
||||
<path d="M8 18 24 9l16 9H8Z" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinejoin="round" />
|
||||
<path d="M13 21v13M21 21v13M29 21v13M37 21v13M10 38h28" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" />
|
||||
<path d="m28 29 3 3 7-8" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
);
|
||||
case 'snowball':
|
||||
return (
|
||||
<>
|
||||
<circle cx="17" cy="18" r="5" fill="none" stroke={commonStroke} strokeWidth="3" opacity="0.74" />
|
||||
<circle cx="29" cy="28" r="9" fill="none" stroke={commonStroke} strokeWidth="3" />
|
||||
<path d="M11 37c8-1 18-7 27-23" fill="none" stroke={commonStroke} strokeWidth="4" strokeLinecap="round" />
|
||||
<path d="m31 14 7 0 0 7" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
);
|
||||
case 'security':
|
||||
return (
|
||||
<>
|
||||
<path d="M24 8 37 13v10c0 8-5 14-13 17-8-3-13-9-13-17V13l13-5Z" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinejoin="round" />
|
||||
<path d="m18 24 4 4 9-10" fill="none" stroke={commonStroke} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
);
|
||||
case 'tracker':
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<rect x="10" y="25" width="7" height="13" rx="2.5" fill={fill} opacity="0.78" />
|
||||
<rect x="20.5" y="18" width="7" height="20" rx="2.5" fill={fill} opacity="0.88" />
|
||||
<rect x="31" y="11" width="7" height="27" rx="2.5" fill={fill} />
|
||||
<path d="m10 15 8 8 20-16" fill="none" stroke={commonStroke} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {
|
|||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
const [counts, setCounts] = useState<{ bills: number; categories: number }>({ bills: 0, categories: 0 });
|
||||
const [counts, setCounts] = useState<{ bills: number; categories: number; payments: number; transactions: number }>(
|
||||
{ bills: 0, categories: 0, payments: 0, transactions: 0 }
|
||||
);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
|
|
@ -25,9 +27,14 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {
|
|||
useEffect(() => {
|
||||
api.seededStatus()
|
||||
.then(raw => {
|
||||
const data = raw as { seeded?: boolean; seededBills?: number; seededCategories?: number };
|
||||
const data = raw as { seeded?: boolean; seededBills?: number; seededCategories?: number; seededPayments?: number; seededTransactions?: number };
|
||||
setSeeded(!!data.seeded);
|
||||
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
|
||||
if (data.seeded) setCounts({
|
||||
bills: data.seededBills || 0,
|
||||
categories: data.seededCategories || 0,
|
||||
payments: data.seededPayments || 0,
|
||||
transactions: data.seededTransactions || 0,
|
||||
});
|
||||
})
|
||||
.catch(err => console.error('Failed to check seeded status:', err))
|
||||
.finally(() => setStatusLoading(false));
|
||||
|
|
@ -36,11 +43,16 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {
|
|||
const handleSeed = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.seedDemoData() as { billsCreated?: number; categoriesCreated?: number } | null;
|
||||
const data = await api.seedDemoData() as { billsCreated?: number; categoriesCreated?: number; paymentsCreated?: number; transactionsCreated?: number } | null;
|
||||
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
|
||||
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
|
||||
setCounts({
|
||||
bills: data.billsCreated || 0,
|
||||
categories: data.categoriesCreated || 0,
|
||||
payments: data.paymentsCreated || 0,
|
||||
transactions: data.transactionsCreated || 0,
|
||||
});
|
||||
setSeeded(true);
|
||||
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
|
||||
toast.success(`Seeded ${data.billsCreated || 0} bills with ${data.paymentsCreated || 0} payments and ${data.transactionsCreated || 0} transactions.`);
|
||||
setTimeout(() => onSeeded?.(), 100);
|
||||
} catch (err) {
|
||||
console.error('Seed error:', err);
|
||||
|
|
@ -53,11 +65,11 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {
|
|||
const handleClearDemoData = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const data = await api.clearDemoData() as { billsDeleted?: number; categoriesDeleted?: number };
|
||||
const data = await api.clearDemoData() as { removed?: number };
|
||||
setSeeded(false);
|
||||
setCounts({ bills: 0, categories: 0 });
|
||||
setCounts({ bills: 0, categories: 0, payments: 0, transactions: 0 });
|
||||
setShowClearConfirm(false);
|
||||
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
|
||||
toast.success(`Removed all demo data (${data.removed || 0} records). Your own data is untouched.`);
|
||||
onSeeded?.();
|
||||
} catch (err) {
|
||||
toast.error(errMessage(err, 'Failed to clear demo data.'));
|
||||
|
|
@ -79,6 +91,14 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {
|
|||
<p className="text-muted-foreground">Bills</p>
|
||||
<p className="font-semibold">{counts.bills}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Payments</p>
|
||||
<p className="font-semibold">{counts.payments}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Transactions</p>
|
||||
<p className="font-semibold">{counts.transactions}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Categories</p>
|
||||
<p className="font-semibold">{counts.categories}</p>
|
||||
|
|
@ -87,8 +107,9 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {
|
|||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create 20 realistic demo bills and 8 demo categories for testing purposes.
|
||||
The data will be associated with your account.
|
||||
Populate every page with a realistic dataset — bills, months of payment history,
|
||||
bank transactions, budgets, a debt snowball, and more — so you can explore the whole app.
|
||||
It's tied to your account and fully removable; your own data is never touched.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -107,7 +128,9 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone.
|
||||
This removes all seeded demo data — {counts.bills} bills, {counts.payments} payments,
|
||||
{' '}{counts.transactions} transactions and related records. Your own bills and categories
|
||||
are kept. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FilterChipProps {
|
||||
|
|
@ -12,13 +13,15 @@ export function FilterChip({ active, children, onClick }: FilterChipProps) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={!!active}
|
||||
className={cn(
|
||||
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
|
||||
'inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary/50 bg-primary/15 text-primary'
|
||||
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{active && <Check className="h-3 w-3" aria-hidden="true" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
|||
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<typeof motion.div>['onDragEnd']}
|
||||
onDrop={dragProps?.onDrop}
|
||||
className={cn(
|
||||
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
|
||||
'rounded-lg border border-border/60 bg-card/70 p-3 shadow-sm shadow-black/5',
|
||||
'space-y-3 transition-colors',
|
||||
isSkipped ? 'opacity-55' : rowBg,
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
|
|
@ -325,17 +325,17 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/50 bg-muted/20">
|
||||
<div className="rounded-md border border-border/50 bg-muted/15">
|
||||
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<div className="rounded-md border border-border/45 bg-muted/30 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Paid </span>
|
||||
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
||||
<span className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<div className="rounded-md border border-border/45 bg-muted/30 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Date </span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { AlertCircle, ChevronDown, ChevronUp, BellOff, SkipForward, CreditCard, EyeOff } from 'lucide-react';
|
||||
import { BellOff, CalendarClock, ChevronDown, ChevronUp, Clock3, CreditCard, EyeOff, SkipForward } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { fmt, localDateString } from '@/lib/utils';
|
||||
|
|
@ -42,6 +42,7 @@ interface OverdueRowProps {
|
|||
function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const threshold = row.actual_amount ?? row.expected_amount;
|
||||
const remaining = Math.max(0, threshold - (row.total_paid ?? 0));
|
||||
|
||||
async function handleSkip() {
|
||||
setLoading(true);
|
||||
|
|
@ -82,7 +83,7 @@ function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps)
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 sm:flex-nowrap">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg px-2 py-2.5 transition-colors hover:bg-card/55 sm:flex-nowrap">
|
||||
{/* Bill info */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
|
|
@ -93,27 +94,33 @@ function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps)
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-rose-600 dark:text-rose-300">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-200">
|
||||
<Clock3 className="h-3 w-3" aria-hidden="true" />
|
||||
{daysOverdueLabel(row.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<span className="shrink-0 font-mono text-sm font-semibold text-rose-600 dark:text-rose-300">
|
||||
{fmt(threshold)}
|
||||
</span>
|
||||
<div className="flex shrink-0 flex-col items-end leading-tight">
|
||||
<span className="tracker-number text-sm font-semibold text-foreground">
|
||||
{fmt(asDollars(remaining > 0 ? remaining : threshold))}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/65">
|
||||
remaining
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 border-rose-500/40 px-2.5 text-xs text-rose-600 hover:border-rose-500/70 hover:bg-rose-500/[0.08] hover:text-rose-700 dark:text-rose-300 dark:hover:text-rose-200"
|
||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||
disabled={loading}
|
||||
onClick={() => onPayNow(row)}
|
||||
>
|
||||
<CreditCard className="h-3 w-3" />
|
||||
Pay Now
|
||||
Pay now
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
|
@ -198,25 +205,32 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
|||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
||||
<div className="surface-premium overflow-hidden border-rose-500/30 bg-rose-500/[0.055] dark:border-rose-300/25 dark:bg-rose-400/[0.05]">
|
||||
<div className="surface-premium overflow-hidden border-amber-500/30 bg-amber-500/[0.055] dark:border-amber-300/25 dark:bg-amber-400/[0.05]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<div className="flex items-center gap-2 px-3 py-3 sm:px-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/45"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300" />
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{`${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-200">
|
||||
<CalendarClock className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-rose-600 dark:text-rose-300">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
Review overdue bills
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{`${overdueRows.length} ${overdueRows.length === 1 ? 'item' : 'items'} ready for pay, skip, or snooze`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tracker-number shrink-0 text-sm font-semibold text-foreground">
|
||||
{fmt(asDollars(totalOverdue))}
|
||||
</span>
|
||||
{snoozedRows.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
({snoozedRows.length} snoozed)
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -240,7 +254,7 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
|||
|
||||
{/* Bill rows */}
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y divide-border/40 px-4 pb-2">
|
||||
<div className="border-t border-border/50 px-2 pb-2 pt-1 sm:px-3">
|
||||
{overdueRows.map(row => (
|
||||
<OverdueRow
|
||||
key={row.id}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { AlertCircle, CheckCircle2, Circle, Clock3, Loader2, SkipForward } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { STATUS_META, isPaidStatus } from '@/lib/trackerUtils';
|
||||
import type { TrackerStatus } from '@/types';
|
||||
|
|
@ -13,9 +13,24 @@ interface StatusBadgeProps {
|
|||
|
||||
export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }: StatusBadgeProps) {
|
||||
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
|
||||
const StatusIcon = useMemo(() => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
case 'autodraft':
|
||||
return CheckCircle2;
|
||||
case 'late':
|
||||
case 'missed':
|
||||
return AlertCircle;
|
||||
case 'due_soon':
|
||||
return Clock3;
|
||||
case 'skipped':
|
||||
return SkipForward;
|
||||
default:
|
||||
return Circle;
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const isSkipped = status === 'skipped';
|
||||
const isUrgent = status === 'late' || status === 'missed';
|
||||
const canClick = clickable && !isSkipped && !loading;
|
||||
|
||||
return (
|
||||
|
|
@ -24,26 +39,27 @@ export const StatusBadge = React.memo(function StatusBadge({ status, clickable,
|
|||
disabled={!canClick || loading}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] rounded-full font-semibold',
|
||||
'uppercase tracking-wide whitespace-nowrap',
|
||||
'transition-all duration-150',
|
||||
isUrgent && 'gap-1.5 px-2.5 py-1 text-xs',
|
||||
canClick && 'cursor-pointer hover:scale-105 hover:shadow-sm',
|
||||
'transition-colors duration-150',
|
||||
(status === 'late' || status === 'missed') && 'px-2.5 py-1 text-xs',
|
||||
canClick && 'cursor-pointer hover:shadow-sm',
|
||||
canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40',
|
||||
canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
|
||||
loading && 'opacity-60 cursor-wait',
|
||||
meta.cls,
|
||||
)}
|
||||
title={canClick ? (isPaidStatus(status) ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
|
||||
aria-label={`${meta.label}${canClick ? (isPaidStatus(status) ? ', click to mark unpaid' : ', click to mark paid') : ''}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
{meta.label}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isUrgent && <AlertCircle className="h-3.5 w-3.5" />}
|
||||
<StatusIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{meta.label}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,16 +15,16 @@ interface CardDef {
|
|||
|
||||
const CARD_DEFS: Record<CardType, CardDef> = {
|
||||
starting: {
|
||||
label: 'Starting',
|
||||
label: 'Starting cash',
|
||||
icon: TrendingUp,
|
||||
valueClass: 'text-foreground',
|
||||
tone: 'neutral',
|
||||
activateWhen: () => true,
|
||||
},
|
||||
paid: {
|
||||
label: 'Total Paid',
|
||||
label: 'Paid',
|
||||
icon: CheckCircle2,
|
||||
valueClass: 'text-emerald-600 dark:text-emerald-200',
|
||||
valueClass: 'text-foreground',
|
||||
tone: 'good',
|
||||
activateWhen: (v) => v > 0,
|
||||
},
|
||||
|
|
@ -32,13 +32,13 @@ const CARD_DEFS: Record<CardType, CardDef> = {
|
|||
label: 'Remaining',
|
||||
icon: Clock,
|
||||
valueClass: 'text-foreground',
|
||||
tone: 'info',
|
||||
activateWhen: () => true,
|
||||
tone: 'warn',
|
||||
activateWhen: (v) => v > 0,
|
||||
},
|
||||
overdue: {
|
||||
label: 'Overdue',
|
||||
icon: AlertCircle,
|
||||
valueClass: 'text-red-500 dark:text-rose-200',
|
||||
valueClass: 'text-foreground',
|
||||
tone: 'danger',
|
||||
activateWhen: (v) => v > 0,
|
||||
},
|
||||
|
|
@ -58,12 +58,12 @@ export function TrendIndicator({ trend }: { trend?: TrendInfo | null }) {
|
|||
switch (direction) {
|
||||
case 'up':
|
||||
icon = '↑';
|
||||
color = 'text-emerald-500';
|
||||
color = 'text-emerald-600 dark:text-emerald-300';
|
||||
text = `${icon} ${percent_change}%`;
|
||||
break;
|
||||
case 'down':
|
||||
icon = '↓';
|
||||
color = 'text-red-500';
|
||||
color = 'text-rose-600 dark:text-rose-300';
|
||||
text = `${icon} ${Math.abs(percent_change)}%`;
|
||||
break;
|
||||
default:
|
||||
|
|
@ -73,8 +73,8 @@ export function TrendIndicator({ trend }: { trend?: TrendInfo | null }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-lg font-bold ${color}`}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1">
|
||||
<span className={`tracker-number text-lg font-bold ${color}`}>
|
||||
{text}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
|
|
@ -105,7 +105,7 @@ export function SummaryCard({ type, value, onEdit, hint, label }: SummaryCardPro
|
|||
icon={Icon}
|
||||
label={displayLabel}
|
||||
value={(
|
||||
<span className={cn(isActive ? def.valueClass : 'text-foreground')}>
|
||||
<span className={cn('block tabular-nums', isActive ? def.valueClass : 'text-foreground')}>
|
||||
{fmt(value)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -113,11 +113,11 @@ export function SummaryCard({ type, value, onEdit, hint, label }: SummaryCardPro
|
|||
action={type === 'starting' && onEdit ? (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="ml-auto grid h-7 w-7 place-items-center rounded-lg border border-border/60 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
title="Edit monthly starting amounts"
|
||||
aria-label="Edit monthly starting amounts"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : undefined}
|
||||
/>
|
||||
|
|
@ -134,8 +134,8 @@ export function TrendCard({ trend }: { trend?: TrendInfo | null }) {
|
|||
icon={TrendingUp}
|
||||
label="3-Month Trend"
|
||||
value={(
|
||||
<span className="flex min-h-10 items-center justify-center">
|
||||
<TrendIndicator trend={trend} />
|
||||
<span className="flex min-h-8 items-center">
|
||||
<TrendIndicator trend={trend} />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -223,10 +223,10 @@ export function TrackerBucket({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="surface-premium overflow-hidden">
|
||||
<div className="table-surface overflow-hidden">
|
||||
|
||||
{/* Bucket header */}
|
||||
<div className="flex flex-col gap-2 bg-muted/35 px-3 py-3 border-b border-border/80 sm:px-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-2 border-b border-border/70 bg-card/60 px-3 py-3 sm:px-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
|
|
@ -239,7 +239,7 @@ export function TrackerBucket({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex min-w-[7rem] items-center gap-2">
|
||||
<div className="h-1.5 w-16 rounded-full bg-border overflow-hidden sm:w-24">
|
||||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-border sm:w-24">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-700',
|
||||
|
|
@ -254,7 +254,7 @@ export function TrackerBucket({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="tracker-number whitespace-nowrap">
|
||||
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
||||
{fmt(asDollars(totalPaidTowardDue))}
|
||||
</span>
|
||||
|
|
@ -308,10 +308,10 @@ export function TrackerBucket({
|
|||
</AlertDialog>
|
||||
|
||||
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
|
||||
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
||||
<div className="grid gap-2.5 bg-background/20 p-2.5 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse">
|
||||
<div key={i} className="animate-pulse rounded-lg border border-border/60 bg-card/65 p-3">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
|
|
@ -334,7 +334,7 @@ export function TrackerBucket({
|
|||
</div>
|
||||
))
|
||||
) : rows.length === 0 ? (
|
||||
<div className="rounded-lg bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No bills match — try adjusting your filters.
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -361,7 +361,7 @@ export function TrackerBucket({
|
|||
<div className="overflow-x-auto">
|
||||
<Table className={cn(visibleColumns.length <= 4 ? 'min-w-[720px]' : 'min-w-[1120px]')}>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
|
||||
<TableRow className="border-border/80 bg-muted/20 hover:bg-muted/20">
|
||||
<SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead>
|
||||
{showColumn('due') && (
|
||||
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function PageHeader({
|
|||
return (
|
||||
<section className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{glyph && <BrandGlyph name={glyph} className="mt-0.5 hidden sm:inline-grid" />}
|
||||
{glyph && <BrandGlyph name={glyph} className="mt-0.5 hidden sm:inline-block" />}
|
||||
{Icon && !glyph && (
|
||||
<span className="mt-0.5 hidden h-10 w-10 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/[0.08] text-primary shadow-sm shadow-primary/10 sm:grid">
|
||||
<Icon className="h-5 w-5" />
|
||||
|
|
@ -120,14 +120,18 @@ export function MetricCard({
|
|||
<div className={cn('metric-card', tone !== 'neutral' && toneClasses[tone], className)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0 text-current opacity-80" />}
|
||||
{Icon && (
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/[0.08] text-current">
|
||||
<Icon className="h-3.5 w-3.5 opacity-90" />
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<p className="metric-value mt-3 text-2xl leading-none sm:text-[1.75rem]">{value}</p>
|
||||
<p className="metric-value mt-4 text-2xl leading-none sm:text-[1.72rem]">{value}</p>
|
||||
{hint && <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -162,16 +162,16 @@
|
|||
}
|
||||
|
||||
.metric-card {
|
||||
@apply relative overflow-hidden rounded-xl border border-border/75 bg-card/95 px-5 py-4 shadow-sm shadow-black/10;
|
||||
@apply relative min-h-[7.75rem] overflow-hidden rounded-xl border border-border/75 bg-card/95 px-4 py-4 shadow-sm shadow-black/10 sm:px-5;
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, oklch(var(--primary) / 0.85), oklch(var(--accent) / 0.68));
|
||||
opacity: 0.76;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
/* Stat cards */
|
||||
|
|
@ -194,6 +194,7 @@
|
|||
|
||||
.metric-value {
|
||||
@apply tracker-number font-bold tracking-tight text-foreground;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { BRAND, BRAND_GLYPHS } from './brand';
|
||||
|
||||
// Assets are referenced by absolute URL (/brand/...) and served from client/public.
|
||||
const publicDir = resolve(dirname(fileURLToPath(import.meta.url)), '../public');
|
||||
const toFile = (assetUrl: string) => resolve(publicDir, assetUrl.replace(/^\//, ''));
|
||||
|
||||
describe('brand glyph assets', () => {
|
||||
// TS proves each BrandGlyphName has an asset path; it can't prove the FILE exists.
|
||||
// This is the exact failure mode of "images added but the path is wrong / renamed".
|
||||
it('every glyph resolves to a real, non-empty SVG file on disk', () => {
|
||||
for (const [name, glyph] of Object.entries(BRAND_GLYPHS)) {
|
||||
expect(glyph.asset, `${name} asset path shape`).toMatch(/^\/brand\/glyphs\/.+\.svg$/);
|
||||
const file = toFile(glyph.asset);
|
||||
expect(existsSync(file), `${name} → ${glyph.asset} missing on disk`).toBe(true);
|
||||
expect(readFileSync(file, 'utf8'), `${name} is a valid <svg>`).toMatch(/<svg[\s>]/);
|
||||
}
|
||||
});
|
||||
|
||||
it('every /brand asset referenced in brand.ts exists on disk', () => {
|
||||
for (const [key, url] of Object.entries(BRAND.assets)) {
|
||||
if (!url.startsWith('/brand/')) continue; // skip third-party (/img/) icons
|
||||
expect(existsSync(toFile(url)), `${key} → ${url} missing on disk`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -19,7 +19,6 @@ export const BRAND = {
|
|||
socialPreview: '/brand/social-preview.svg',
|
||||
socialPreviewPng: '/brand/social-preview.png',
|
||||
emptyState: '/brand/empty-state.svg',
|
||||
legacyLogo: '/img/logo.png',
|
||||
},
|
||||
colors: {
|
||||
ink: '#101417',
|
||||
|
|
@ -51,5 +50,3 @@ export const BRAND_GLYPHS: Record<BrandGlyphName, { label: string; asset: string
|
|||
snowball: { label: 'Snowball', asset: '/brand/glyphs/snowball-payoff.svg' },
|
||||
security: { label: 'Security', asset: '/brand/glyphs/admin-security.svg' },
|
||||
};
|
||||
|
||||
export const ADMIN_GLYPH = { label: 'Admin' } as const;
|
||||
|
|
|
|||
|
|
@ -41,20 +41,20 @@ export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries(
|
|||
);
|
||||
|
||||
export const ROW_STATUS_CLS: Record<string, string> = {
|
||||
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
|
||||
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
|
||||
upcoming: '',
|
||||
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
|
||||
late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
|
||||
missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
|
||||
paid: 'border-l-2 border-l-emerald-500/45 bg-emerald-500/[0.025] dark:bg-emerald-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-emerald-500/45',
|
||||
autodraft: 'border-l-2 border-l-teal-500/45 bg-teal-500/[0.025] dark:bg-teal-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-teal-500/45',
|
||||
upcoming: 'border-l-2 border-l-transparent [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-transparent',
|
||||
due_soon: 'border-l-2 border-l-amber-400/65 bg-amber-400/[0.055] dark:bg-amber-300/[0.02] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-amber-400/65',
|
||||
late: 'border-l-2 border-l-orange-400/80 bg-orange-500/[0.09] ring-1 ring-inset ring-orange-400/20 dark:bg-orange-400/[0.07] dark:ring-orange-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-orange-400/80',
|
||||
missed: 'border-l-2 border-l-rose-400/80 bg-rose-500/[0.1] ring-1 ring-inset ring-rose-400/20 dark:bg-rose-400/[0.075] dark:ring-rose-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-rose-400/80',
|
||||
};
|
||||
|
||||
export const STATUS_META = {
|
||||
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
|
||||
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
|
||||
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-700 border border-amber-400/35 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
|
||||
late: { label: 'Late', cls: 'bg-orange-500/20 text-orange-700 border border-orange-500/50 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
|
||||
missed: { label: 'Missed', cls: 'bg-rose-500/20 text-rose-700 border border-rose-500/55 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
|
||||
late: { label: 'Late', cls: 'bg-orange-500/15 text-orange-700 border border-orange-500/45 shadow-sm shadow-orange-950/10 dark:bg-orange-400/20 dark:text-orange-100 dark:border-orange-300/50' },
|
||||
missed: { label: 'Missed', cls: 'bg-rose-500/15 text-rose-700 border border-rose-500/45 shadow-sm shadow-rose-950/10 dark:bg-rose-400/20 dark:text-rose-100 dark:border-rose-300/50' },
|
||||
autodraft: { label: 'Autodraft', cls: 'bg-teal-400/15 text-teal-700 border border-teal-400/35 dark:bg-teal-300/10 dark:text-teal-200 dark:border-teal-300/28' },
|
||||
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1135,7 +1135,7 @@ export default function TrackerPage() {
|
|||
|
||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||
{showSummaryCards && loading ? (
|
||||
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:flex" aria-busy="true">
|
||||
<Skeleton variant="card" className="h-32" />
|
||||
<Skeleton variant="card" className="h-32" />
|
||||
<Skeleton variant="card" className="h-32" />
|
||||
|
|
@ -1144,7 +1144,7 @@ export default function TrackerPage() {
|
|||
{summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
|
||||
</div>
|
||||
) : showSummaryCards ? (
|
||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:flex">
|
||||
{bankTracking?.enabled ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1158,24 +1158,31 @@ export default function TrackerPage() {
|
|||
)}
|
||||
title="Click to see income breakdown"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Landmark className={cn('h-4 w-4', Number(bankTracking.remaining ?? 0) >= 0 ? 'text-emerald-500' : 'text-destructive')} />
|
||||
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{bankTracking.account_name}
|
||||
</p>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn(
|
||||
'grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/[0.08]',
|
||||
Number(bankTracking.remaining ?? 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive',
|
||||
)}>
|
||||
<Landmark className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{bankTracking.account_name}
|
||||
</p>
|
||||
</div>
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
<StatusDot tone="good" pulse className="h-1.5 w-1.5 [&_span]:h-1.5 [&_span]:w-1.5" />
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[1.75rem] font-bold tracking-tight font-mono leading-none text-foreground">
|
||||
<p className="metric-value mt-4 text-2xl leading-none sm:text-[1.72rem]">
|
||||
{fmt(bankTracking.effective_balance ?? 0)}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected after bills
|
||||
</p>
|
||||
{bankTracking.last_updated && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground/60">
|
||||
<p className="mt-1 text-[10px] leading-4 text-muted-foreground/70">
|
||||
as of {fmtBalanceAge(bankTracking.last_updated)}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -1218,14 +1225,23 @@ export default function TrackerPage() {
|
|||
{/* Compact month-progress line — quick "how far through the month am I?" */}
|
||||
{showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => {
|
||||
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
|
||||
const progress = Math.min(100, Math.max(0, (Number(summary.paid_toward_due || 0) / Number(summary.total_expected || 1)) * 100));
|
||||
return (
|
||||
<p className="px-1 -mt-1 text-[11px] text-muted-foreground">
|
||||
<span className="font-mono font-semibold text-foreground">{fmt(summary.paid_toward_due)}</span>
|
||||
{' of '}
|
||||
<span className="font-mono">{fmt(summary.total_expected)}</span>
|
||||
{' paid'}
|
||||
{billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left</>}
|
||||
</p>
|
||||
<div className="-mt-1 rounded-xl border border-border/65 bg-card/55 px-3 py-2 shadow-sm shadow-black/5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<p>
|
||||
<span className="tracker-number font-semibold text-foreground">{fmt(summary.paid_toward_due)}</span>
|
||||
{' of '}
|
||||
<span className="tracker-number">{fmt(summary.total_expected)}</span>
|
||||
{' paid'}
|
||||
{billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left</>}
|
||||
</p>
|
||||
<span className="tracker-number font-semibold text-foreground">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary/75" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
|
|
|||
|
|
@ -1766,5 +1766,36 @@ module.exports = function buildVersionedMigrations(deps) {
|
|||
console.log('[v1.06] category_groups table + categories.group_id added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v1.07',
|
||||
description: 'demo data: is_seeded marker on user-scoped tables the demo seeder touches (for surgical clear-demo removal)',
|
||||
dependsOn: ['v1.06'],
|
||||
run: function() {
|
||||
// Tables the demo seeder writes that are NOT bill-children (bill-children
|
||||
// cascade-delete with the seeded bill, so they need no marker). Marking
|
||||
// these lets clear-demo remove ONLY seeded rows without touching the
|
||||
// user's real planning / bank / spending / snowball data.
|
||||
const SEED_MARKER_TABLES = [
|
||||
'category_groups', 'bill_templates', 'snowball_plans',
|
||||
'spending_category_rules', 'spending_budgets',
|
||||
'monthly_income', 'monthly_starting_amounts',
|
||||
'data_sources', 'financial_accounts', 'transactions',
|
||||
'calendar_tokens', 'declined_subscription_hints',
|
||||
];
|
||||
let added = 0;
|
||||
for (const table of SEED_MARKER_TABLES) {
|
||||
const exists = db.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?"
|
||||
).get(table);
|
||||
if (!exists) continue; // defensive — all created by <= v1.06
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||
if (!cols.includes('is_seeded')) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0`);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
console.log(`[v1.07] is_seeded marker ensured on ${added} demo table(s)`);
|
||||
}
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,80 +7,80 @@ const router = express.Router();
|
|||
const { getDb } = require('../db/database.cts');
|
||||
const { seedDemoData } = require('../scripts/seedDemoData.cts');
|
||||
const { demoDataLimiter } = require('../middleware/rateLimiter.cts');
|
||||
const { eraseUserData } = require('../services/userDataService.cts');
|
||||
const { eraseUserData, clearSeededDemoData } = require('../services/userDataService.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
|
||||
// GET /api/user/seeded-status — returns whether the current user has any seeded data
|
||||
// GET /api/user/seeded-status — whether the current user has demo data, with counts
|
||||
router.get('/seeded-status', (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check for seeded bills
|
||||
const seededBillsResult = db.prepare('SELECT COUNT(*) as count FROM bills WHERE user_id = ? AND is_seeded = 1').get(userId);
|
||||
const seededBillsCount = seededBillsResult.count;
|
||||
|
||||
// Check for seeded categories
|
||||
const seededCategoriesResult = db.prepare('SELECT COUNT(*) as count FROM categories WHERE user_id = ? AND is_seeded = 1').get(userId);
|
||||
const seededCategoriesCount = seededCategoriesResult.count;
|
||||
|
||||
const hasSeededData = seededBillsCount > 0 || seededCategoriesCount > 0;
|
||||
|
||||
const one = (sql: string) => db.prepare(sql).get(userId).count as number;
|
||||
|
||||
const seededBills = one('SELECT COUNT(*) as count FROM bills WHERE user_id = ? AND is_seeded = 1');
|
||||
const seededCategories = one('SELECT COUNT(*) as count FROM categories WHERE user_id = ? AND is_seeded = 1');
|
||||
// Bill-children (cascade with the seeded bill) counted via their bill.
|
||||
const seededPayments = one('SELECT COUNT(*) as count FROM payments WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ? AND is_seeded = 1)');
|
||||
const seededTransactions = one('SELECT COUNT(*) as count FROM transactions WHERE user_id = ? AND is_seeded = 1');
|
||||
|
||||
res.json({
|
||||
seeded: hasSeededData,
|
||||
seededBills: seededBillsCount,
|
||||
seededCategories: seededCategoriesCount,
|
||||
seeded: seededBills > 0 || seededCategories > 0,
|
||||
seededBills,
|
||||
seededCategories,
|
||||
seededPayments,
|
||||
seededTransactions,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({ error: status === 500 ? 'Seeded status check failed' : err.message });
|
||||
res.status(err.status || 500).json(standardizeError(err.message || 'Seeded status check failed', err.code || 'SEEDED_STATUS_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/user/clear-demo-data — removes all seeded bills and categories for the requesting user
|
||||
// POST /api/user/clear-demo-data — surgically remove ONLY seeded demo data for the
|
||||
// requesting user (in one transaction, child → parent). Marker tables are removed
|
||||
// by is_seeded; bill-children cascade with their seeded bill. A seeded category is
|
||||
// removed only if no remaining (non-seeded) bill references it, so a user's own bill
|
||||
// that reused a demo category keeps its category. Mirrors eraseUserData().
|
||||
router.post('/clear-demo-data', demoDataLimiter, (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
|
||||
// Delete seeded bills
|
||||
const billsResult = db.prepare('DELETE FROM bills WHERE user_id = ? AND is_seeded = 1').run(userId);
|
||||
const billsDeleted = billsResult.changes;
|
||||
|
||||
// Delete seeded categories
|
||||
const categoriesResult = db.prepare('DELETE FROM categories WHERE user_id = ? AND is_seeded = 1').run(userId);
|
||||
const categoriesDeleted = categoriesResult.changes;
|
||||
|
||||
// Audit logging: record the clear action to import_history
|
||||
db.prepare(
|
||||
`INSERT INTO import_history (user_id, imported_at, source_filename, file_type, rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json)
|
||||
VALUES (?, datetime('now'), ?, 'clear-demo', ?, 0, 0, 0, 0, 0, ?, ?)`
|
||||
).run(userId, 'clear-demo-data', billsDeleted + categoriesDeleted, JSON.stringify({ action: 'clear-demo-data', userId }), JSON.stringify({ bills_deleted: billsDeleted, categories_deleted: categoriesDeleted }));
|
||||
|
||||
const result = clearSeededDemoData(getDb(), req.user.id);
|
||||
res.json({
|
||||
success: true,
|
||||
billsDeleted,
|
||||
categoriesDeleted,
|
||||
removed: result.removed,
|
||||
billsDeleted: result.counts.bills || 0,
|
||||
categoriesDeleted: result.counts.categories || 0,
|
||||
counts: result.counts,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({ error: status === 500 ? 'Clear demo data operation failed' : err.message });
|
||||
res.status(err.status || 500).json(standardizeError(err.message || 'Clear demo data operation failed', err.code || 'CLEAR_DEMO_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/user/seed-demo-data — seeds demo bills for the requesting user
|
||||
router.post('/seed-demo-data', (req: Req, res: Res) => {
|
||||
// POST /api/user/seed-demo-data — seed the full demo dataset for the requesting user
|
||||
router.post('/seed-demo-data', demoDataLimiter, (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const result = seedDemoData(req.user.id);
|
||||
db.prepare(
|
||||
`INSERT INTO import_history (user_id, imported_at, source_filename, file_type, rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json)
|
||||
VALUES (?, datetime('now'), 'seed-demo-data', 'seed-demo', ?, ?, 0, 0, 0, 0, ?, ?)`
|
||||
).run(
|
||||
req.user.id,
|
||||
result.billsCreated + result.paymentsCreated + result.transactionsCreated,
|
||||
result.billsCreated,
|
||||
JSON.stringify({ action: 'seed-demo-data' }),
|
||||
JSON.stringify(result.counts),
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Created ${result.billsCreated} demo bills and ${result.categoriesCreated} demo categories`,
|
||||
message: `Created ${result.billsCreated} bills, ${result.paymentsCreated} payments and ${result.transactionsCreated} transactions`,
|
||||
billsCreated: result.billsCreated,
|
||||
categoriesCreated: result.categoriesCreated,
|
||||
paymentsCreated: result.paymentsCreated,
|
||||
transactionsCreated: result.transactionsCreated,
|
||||
counts: result.counts,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({ error: status === 500 ? 'Seed operation failed' : err.message });
|
||||
res.status(err.status || 500).json(standardizeError(err.message || 'Seed operation failed', err.code || 'SEED_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,458 +1,419 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Seed Demo Data Script
|
||||
* Creates realistic bills across common categories for demo purposes.
|
||||
* Idempotent: can be run multiple times safely.
|
||||
* Seed Demo Data — a full "take it for a ride" dataset.
|
||||
*
|
||||
* Populates every user-facing surface (bills, categories + groups, payment
|
||||
* history with drift + debt paydown, monthly overrides/skips/snoozes, planning
|
||||
* income/starting amounts, spending budgets/rules/transactions, a demo bank
|
||||
* source + accounts + matched/unmatched/subscription transactions, an active
|
||||
* snowball plan, merchant rules, a calendar feed) so a new user can explore the
|
||||
* whole app.
|
||||
*
|
||||
* Removal contract: everything created here is either marked `is_seeded = 1` on a
|
||||
* user-scoped table, or is a bill-child that cascade-deletes with its seeded bill.
|
||||
* `POST /user/clear-demo-data` removes exactly these rows and nothing else — see
|
||||
* routes/user.cts. Keep that in sync when adding a new seeded surface.
|
||||
*
|
||||
* Idempotent: refuses to run when the account already has seeded bills.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Use DB_PATH from env or default to db/bills.db
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'db', 'bills.db');
|
||||
// DB_PATH is read at require-time by db/database — keep the default in sync.
|
||||
process.env.DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'db', 'bills.db');
|
||||
|
||||
// Import database helper
|
||||
const { getDb, ensureUserDefaultCategories } = require('../db/database.cts');
|
||||
// Money columns (expected_amount, current_balance, minimum_payment) are stored as
|
||||
// integer cents since migration v1.03 — convert the demo dollars before insert.
|
||||
// Money columns are integer cents (migration v1.03) — convert demo dollars.
|
||||
const { toCents } = require('../utils/money.mts');
|
||||
|
||||
const CATEGORIES = [
|
||||
'Utilities',
|
||||
'Housing',
|
||||
'Insurance',
|
||||
'Subscriptions',
|
||||
'Transportation',
|
||||
'Healthcare',
|
||||
'Credit Cards',
|
||||
'Loans',
|
||||
'Entertainment',
|
||||
// ── Static demo content ─────────────────────────────────────────────────────
|
||||
|
||||
const BILL_CATEGORIES = [
|
||||
'Utilities', 'Housing', 'Insurance', 'Subscriptions',
|
||||
'Transportation', 'Healthcare', 'Credit Cards', 'Loans', 'Entertainment',
|
||||
];
|
||||
|
||||
// billing_cycle CHECK: 'monthly' | 'quarterly' | 'annually' | 'irregular'
|
||||
// cycle_type CHECK: 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual'
|
||||
// Spending categories are distinct from bill categories and from the app's
|
||||
// DEFAULT_CATEGORIES, so they are always seed-created (and thus removable).
|
||||
const SPENDING_CATEGORIES = ['Groceries', 'Dining Out', 'Gas & Fuel', 'Shopping', 'Coffee'];
|
||||
|
||||
const CATEGORY_GROUPS = [
|
||||
{ name: 'Living Expenses', members: ['Groceries', 'Gas & Fuel'] },
|
||||
{ name: 'Discretionary', members: ['Dining Out', 'Shopping', 'Coffee'] },
|
||||
];
|
||||
|
||||
// billing_cycle: 'monthly' | 'quarterly' | 'annually' | 'irregular'
|
||||
// cycle_type: 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual'
|
||||
const BILLS = [
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'Electric Company',
|
||||
category: 'Utilities',
|
||||
amount: 85,
|
||||
dueDay: 15,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
},
|
||||
{
|
||||
name: 'Gas Utility',
|
||||
category: 'Utilities',
|
||||
amount: 35,
|
||||
dueDay: 12,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
},
|
||||
{
|
||||
name: 'City Water Dept',
|
||||
category: 'Utilities',
|
||||
amount: 45,
|
||||
dueDay: 20,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
},
|
||||
{
|
||||
name: 'Internet Provider',
|
||||
category: 'Utilities',
|
||||
amount: 70,
|
||||
dueDay: 18,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
},
|
||||
{
|
||||
name: 'Cell Phone',
|
||||
category: 'Utilities',
|
||||
amount: 65,
|
||||
dueDay: 25,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
currentBalance: 648, // remaining on 24-month 0% carrier installment plan
|
||||
minPayment: 27,
|
||||
snowballOrder: 0, // smallest balance — first in snowball
|
||||
snowballInclude: 1,
|
||||
},
|
||||
{
|
||||
name: 'Trash Service',
|
||||
category: 'Utilities',
|
||||
amount: 25,
|
||||
dueDay: 28,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
},
|
||||
// ── Utilities ──
|
||||
{ name: 'Electric Company', category: 'Utilities', amount: 85, dueDay: 15, cycle: 'monthly', cycleType: 'monthly', autopay: true, autopayVerified: true },
|
||||
{ name: 'Gas Utility', category: 'Utilities', amount: 35, dueDay: 12, cycle: 'monthly', cycleType: 'monthly', autopay: true },
|
||||
{ name: 'City Water Dept', category: 'Utilities', amount: 45, dueDay: 20, cycle: 'monthly', cycleType: 'monthly', autopay: true },
|
||||
{ name: 'Internet Provider', category: 'Utilities', amount: 70, dueDay: 18, cycle: 'monthly', cycleType: 'monthly', autopay: true, autopayVerified: true },
|
||||
{ name: 'Cell Phone', category: 'Utilities', amount: 65, dueDay: 25, cycle: 'monthly', cycleType: 'monthly', autopay: true, currentBalance: 648, minPayment: 27, interestRate: 0, snowballOrder: 0, snowballInclude: 1 },
|
||||
{ name: 'Trash Service', category: 'Utilities', amount: 25, dueDay: 28, cycle: 'monthly', cycleType: 'monthly', autopay: false },
|
||||
|
||||
// ── Housing ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'Mortgage',
|
||||
category: 'Housing',
|
||||
amount: 1450, // paying $250/mo above minimum → extra principal paydown
|
||||
dueDay: 1,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 3.25,
|
||||
currentBalance: 185000,
|
||||
minPayment: 1200,
|
||||
snowballOrder: null, // not included in snowball — too large, handled separately
|
||||
snowballInclude: 0,
|
||||
snowballExempt: 1,
|
||||
},
|
||||
// ── Housing ──
|
||||
{ name: 'Mortgage', category: 'Housing', amount: 1450, dueDay: 1, cycle: 'monthly', cycleType: 'monthly', autopay: true, autopayVerified: true, interestRate: 3.25, currentBalance: 185000, minPayment: 1200, snowballInclude: 0, snowballExempt: 1 },
|
||||
|
||||
// ── Insurance ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'Car Insurance',
|
||||
category: 'Insurance',
|
||||
amount: 120,
|
||||
dueDay: 5,
|
||||
cycle: 'quarterly',
|
||||
cycleType: 'quarterly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
},
|
||||
{
|
||||
name: 'Homeowners Insurance',
|
||||
category: 'Insurance',
|
||||
amount: 300,
|
||||
dueDay: 10,
|
||||
cycle: 'annually',
|
||||
cycleType: 'annual',
|
||||
autopay: false,
|
||||
interestRate: 0,
|
||||
},
|
||||
{
|
||||
name: 'Health Insurance',
|
||||
category: 'Healthcare',
|
||||
amount: 200,
|
||||
dueDay: 1,
|
||||
cycle: 'quarterly',
|
||||
cycleType: 'quarterly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
},
|
||||
{
|
||||
name: 'Dental Insurance',
|
||||
category: 'Healthcare',
|
||||
amount: 40,
|
||||
dueDay: 15,
|
||||
cycle: 'quarterly',
|
||||
cycleType: 'quarterly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
},
|
||||
// ── Insurance / Healthcare ──
|
||||
{ name: 'Car Insurance', category: 'Insurance', amount: 120, dueDay: 5, cycle: 'quarterly', cycleType: 'quarterly', autopay: true },
|
||||
{ name: 'Homeowners Insurance', category: 'Insurance', amount: 300, dueDay: 10, cycle: 'annually', cycleType: 'annual', autopay: false },
|
||||
{ name: 'Health Insurance', category: 'Healthcare', amount: 200, dueDay: 1, cycle: 'quarterly', cycleType: 'quarterly', autopay: true },
|
||||
{ name: 'Dental Insurance', category: 'Healthcare', amount: 40, dueDay: 15, cycle: 'quarterly', cycleType: 'quarterly', autopay: true },
|
||||
|
||||
// ── Credit Cards (Snowball — ordered smallest→largest balance) ─────────────
|
||||
{
|
||||
name: 'Discover It Card',
|
||||
category: 'Credit Cards',
|
||||
amount: 65,
|
||||
dueDay: 26,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 22.99,
|
||||
currentBalance: 920,
|
||||
minPayment: 35,
|
||||
snowballOrder: 1,
|
||||
snowballInclude: 1,
|
||||
website: 'https://discover.com',
|
||||
has2fa: true,
|
||||
},
|
||||
{
|
||||
name: 'Capital One Quicksilver',
|
||||
category: 'Credit Cards',
|
||||
amount: 95,
|
||||
dueDay: 28,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 24.49,
|
||||
currentBalance: 1850,
|
||||
minPayment: 55,
|
||||
snowballOrder: 2,
|
||||
snowballInclude: 1,
|
||||
website: 'https://capitalone.com',
|
||||
has2fa: true,
|
||||
},
|
||||
{
|
||||
name: 'Chase Freedom',
|
||||
category: 'Credit Cards',
|
||||
amount: 140,
|
||||
dueDay: 12,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 21.49,
|
||||
currentBalance: 3200,
|
||||
minPayment: 90,
|
||||
snowballOrder: 3,
|
||||
snowballInclude: 1,
|
||||
website: 'https://chase.com',
|
||||
has2fa: true,
|
||||
},
|
||||
// ── Credit Cards (snowball, smallest→largest) ──
|
||||
{ name: 'Discover It Card', category: 'Credit Cards', amount: 65, dueDay: 26, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 22.99, currentBalance: 920, minPayment: 35, snowballOrder: 1, snowballInclude: 1, website: 'https://discover.com', has2fa: true },
|
||||
{ name: 'Capital One Quicksilver', category: 'Credit Cards', amount: 95, dueDay: 28, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 24.49, currentBalance: 1850, minPayment: 55, snowballOrder: 2, snowballInclude: 1, website: 'https://capitalone.com', has2fa: true },
|
||||
{ name: 'Chase Freedom', category: 'Credit Cards', amount: 140, dueDay: 12, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 21.49, currentBalance: 3200, minPayment: 90, snowballOrder: 3, snowballInclude: 1, website: 'https://chase.com', has2fa: true },
|
||||
|
||||
// ── Loans (Snowball — ordered by balance after credit cards) ──────────────
|
||||
{
|
||||
name: 'Car Payment',
|
||||
category: 'Loans',
|
||||
amount: 425, // paying $75/mo above contractual minimum to shorten payoff
|
||||
dueDay: 22,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 4.5,
|
||||
currentBalance: 8400,
|
||||
minPayment: 350,
|
||||
snowballOrder: 4,
|
||||
snowballInclude: 1,
|
||||
},
|
||||
{
|
||||
name: 'Student Loan',
|
||||
category: 'Loans',
|
||||
amount: 250,
|
||||
dueDay: 15,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 5.5,
|
||||
currentBalance: 12500,
|
||||
minPayment: 150,
|
||||
snowballOrder: 5,
|
||||
snowballInclude: 1,
|
||||
},
|
||||
// ── Loans ──
|
||||
{ name: 'Car Payment', category: 'Loans', amount: 425, dueDay: 22, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 4.5, currentBalance: 8400, minPayment: 350, snowballOrder: 4, snowballInclude: 1 },
|
||||
{ name: 'Student Loan', category: 'Loans', amount: 250, dueDay: 15, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 5.5, currentBalance: 12500, minPayment: 150, snowballOrder: 5, snowballInclude: 1 },
|
||||
|
||||
// ── Subscriptions ─────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'Netflix',
|
||||
category: 'Subscriptions',
|
||||
amount: 15.99,
|
||||
dueDay: 22,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
isSubscription: true,
|
||||
subscriptionType: 'streaming',
|
||||
website: 'https://netflix.com',
|
||||
},
|
||||
{
|
||||
name: 'Spotify',
|
||||
category: 'Subscriptions',
|
||||
amount: 9.99,
|
||||
dueDay: 14,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
isSubscription: true,
|
||||
subscriptionType: 'music',
|
||||
website: 'https://spotify.com',
|
||||
},
|
||||
{
|
||||
name: 'Adobe Creative Cloud',
|
||||
category: 'Subscriptions',
|
||||
amount: 54.99,
|
||||
dueDay: 8,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
isSubscription: true,
|
||||
subscriptionType: 'software',
|
||||
website: 'https://adobe.com',
|
||||
has2fa: true,
|
||||
},
|
||||
{
|
||||
name: 'Amazon Prime',
|
||||
category: 'Subscriptions',
|
||||
amount: 139,
|
||||
dueDay: 1,
|
||||
cycle: 'annually',
|
||||
cycleType: 'annual',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
isSubscription: true,
|
||||
subscriptionType: 'shopping',
|
||||
website: 'https://amazon.com',
|
||||
},
|
||||
{
|
||||
name: 'Apple iCloud+',
|
||||
category: 'Subscriptions',
|
||||
amount: 2.99,
|
||||
dueDay: 18,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
isSubscription: true,
|
||||
subscriptionType: 'cloud',
|
||||
website: 'https://icloud.com',
|
||||
},
|
||||
{
|
||||
name: 'Gym Membership',
|
||||
category: 'Subscriptions',
|
||||
amount: 45,
|
||||
dueDay: 10,
|
||||
cycle: 'monthly',
|
||||
cycleType: 'monthly',
|
||||
autopay: true,
|
||||
interestRate: 0,
|
||||
isSubscription: true,
|
||||
subscriptionType: 'fitness',
|
||||
},
|
||||
// ── Subscriptions ──
|
||||
{ name: 'Netflix', category: 'Subscriptions', amount: 15.99, dueDay: 22, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'streaming', website: 'https://netflix.com' },
|
||||
{ name: 'Spotify', category: 'Subscriptions', amount: 9.99, dueDay: 14, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'music', website: 'https://spotify.com' },
|
||||
{ name: 'Adobe Creative Cloud', category: 'Subscriptions', amount: 54.99, dueDay: 8, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'software', website: 'https://adobe.com', has2fa: true },
|
||||
{ name: 'Amazon Prime', category: 'Subscriptions', amount: 139, dueDay: 1, cycle: 'annually', cycleType: 'annual', autopay: true, isSubscription: true, subscriptionType: 'shopping', website: 'https://amazon.com' },
|
||||
{ name: 'Apple iCloud+', category: 'Subscriptions', amount: 2.99, dueDay: 18, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'cloud', website: 'https://icloud.com' },
|
||||
{ name: 'Gym Membership', category: 'Subscriptions', amount: 45, dueDay: 10, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'fitness' },
|
||||
|
||||
// ── Entertainment ─────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'Grocery Delivery',
|
||||
category: 'Entertainment',
|
||||
amount: 30,
|
||||
dueDay: 3,
|
||||
cycle: 'irregular',
|
||||
cycleType: 'monthly',
|
||||
autopay: false,
|
||||
interestRate: 0,
|
||||
},
|
||||
// ── Entertainment (a genuine recurring entertainment bill, monthly) ──
|
||||
{ name: 'AMC A-List', category: 'Entertainment', amount: 24.99, dueDay: 3, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'entertainment', website: 'https://amctheatres.com' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Get or create a category by name for a user
|
||||
*/
|
||||
function getCategoryByName(db, userId, name) {
|
||||
let category = db.prepare('SELECT id FROM categories WHERE user_id = ? AND LOWER(name) = LOWER(?)').get(userId, name);
|
||||
if (!category) {
|
||||
const result = db.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)').run(userId, name);
|
||||
category = { id: result.lastInsertRowid };
|
||||
// Bills deliberately left OVERDUE this month (due date passed, no payment).
|
||||
const OVERDUE_THIS_MONTH = new Set(['Trash Service', 'Dental Insurance']);
|
||||
// Bills whose most-recent payments also appear as matched bank transactions.
|
||||
const BANK_MATCHED = new Set(['Electric Company', 'Internet Provider', 'Netflix', 'Spotify', 'Car Payment']);
|
||||
// One skipped month (bill leaves that month unpaid, flagged skipped not overdue).
|
||||
const SKIP = { bill: 'Gym Membership', monthsAgo: 1 };
|
||||
// One amount override (actual differs from expected) and one note.
|
||||
const OVERRIDE = { bill: 'Electric Company', monthsAgo: 1, actual: 132.40 };
|
||||
const NOTE = { bill: 'City Water Dept', text: 'Called about the leak — credit expected next cycle.' };
|
||||
|
||||
// Un-tracked subscription charges (in the catalog, no matching bill) → drive the
|
||||
// Subscriptions "recommendations" surface.
|
||||
const UNTRACKED_SUBS = [
|
||||
{ payee: 'HULU', amount: 17.99, day: 6 },
|
||||
{ payee: 'HBO MAX', amount: 15.99, day: 11 },
|
||||
{ payee: 'YOUTUBE PREMIUM', amount: 13.99, day: 19 },
|
||||
];
|
||||
|
||||
// Everyday spending (not bills) → populate the Spending page + budgets/rules.
|
||||
const SPENDING_MERCHANTS = [
|
||||
{ payee: 'WHOLE FOODS MARKET', category: 'Groceries', amounts: [82.14, 46.03, 118.77] },
|
||||
{ payee: 'TRADER JOE\'S', category: 'Groceries', amounts: [54.20, 61.88] },
|
||||
{ payee: 'CHIPOTLE', category: 'Dining Out', amounts: [14.85, 22.40] },
|
||||
{ payee: 'OLIVE GARDEN', category: 'Dining Out', amounts: [63.12] },
|
||||
{ payee: 'SHELL OIL', category: 'Gas & Fuel', amounts: [48.90, 51.35] },
|
||||
{ payee: 'AMAZON MARKETPLACE', category: 'Shopping', amounts: [39.99, 27.49, 112.00] },
|
||||
{ payee: 'STARBUCKS', category: 'Coffee', amounts: [6.45, 5.75, 6.45] },
|
||||
];
|
||||
const SPENDING_RULES = [
|
||||
{ merchant: 'WHOLE FOODS', category: 'Groceries' },
|
||||
{ merchant: 'SHELL', category: 'Gas & Fuel' },
|
||||
{ merchant: 'STARBUCKS', category: 'Coffee' },
|
||||
{ merchant: 'AMAZON', category: 'Shopping' },
|
||||
];
|
||||
const SPENDING_BUDGETS = { Groceries: 500, 'Dining Out': 200, 'Gas & Fuel': 160, Shopping: 250, Coffee: 40 };
|
||||
|
||||
const HISTORY_MONTHS = 6; // months of payment history to generate (incl. current)
|
||||
|
||||
// ── Date helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function ymd(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
function clampedDate(year, monthIdx0, day) {
|
||||
const lastDay = new Date(year, monthIdx0 + 1, 0).getDate();
|
||||
return new Date(year, monthIdx0, Math.min(day, lastDay));
|
||||
}
|
||||
// Month offsets (0 = current, negative = past) a bill occurs in, within the window.
|
||||
function cycleOffsets(bill) {
|
||||
switch (bill.cycle) {
|
||||
case 'annually': return [-1];
|
||||
case 'quarterly': return [0, -3];
|
||||
case 'irregular': return [0, -1, -2];
|
||||
default: return Array.from({ length: HISTORY_MONTHS }, (_, i) => -(HISTORY_MONTHS - 1) + i); // -5..0
|
||||
}
|
||||
return category;
|
||||
}
|
||||
// Small deterministic-ish drift on variable bills so amounts differ from expected.
|
||||
function driftedAmount(bill, offset) {
|
||||
const variable = ['Utilities'].includes(bill.category) && !bill.currentBalance;
|
||||
if (!variable) return bill.amount;
|
||||
const pct = ((Math.abs((bill.name.length * 7 + offset * 13)) % 21) - 10) / 100; // ±10%
|
||||
return Math.round(bill.amount * (1 + pct) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main seed function
|
||||
* @param {number} [userId] - User ID to seed data for. If not provided, uses the first admin user.
|
||||
*/
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function seedDemoData(userId = null) {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const todayStr = ymd(now);
|
||||
|
||||
// Check if data already exists for this user (if userId provided) or globally
|
||||
let existingCheck;
|
||||
if (userId !== null) {
|
||||
existingCheck = db.prepare('SELECT COUNT(*) AS count FROM bills WHERE user_id = ?').get(userId);
|
||||
} else {
|
||||
existingCheck = db.prepare('SELECT COUNT(*) AS count FROM bills').get();
|
||||
const targetUser = userId !== null
|
||||
? db.prepare('SELECT id FROM users WHERE id = ?').get(userId)
|
||||
: db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
|
||||
if (!targetUser) throw new Error('User not found. Please create a user first.');
|
||||
const uid = targetUser.id;
|
||||
|
||||
const alreadySeeded = db.prepare('SELECT COUNT(*) AS c FROM bills WHERE user_id = ? AND is_seeded = 1').get(uid).c;
|
||||
if (alreadySeeded > 0) {
|
||||
const err = new Error('Demo data is already seeded for this account. Clear it first to re-seed.');
|
||||
err.status = 409;
|
||||
err.code = 'ALREADY_SEEDED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (existingCheck.count > 0) {
|
||||
console.log(`⚠️ Found ${existingCheck.count} existing bills. Skipping seed to prevent duplicates.`);
|
||||
console.log(' Run again with --force to overwrite.');
|
||||
return { billsCreated: 0, categoriesCreated: 0, message: 'Data already exists' };
|
||||
}
|
||||
const counts = {
|
||||
bills: 0, categories: 0, groups: 0, payments: 0, transactions: 0,
|
||||
monthlyState: 0, planning: 0, budgets: 0, spendingRules: 0,
|
||||
merchantRules: 0, snowballPlans: 0, calendarTokens: 0,
|
||||
};
|
||||
|
||||
// Get user (or admin if userId not provided)
|
||||
let targetUser;
|
||||
if (userId !== null) {
|
||||
targetUser = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
|
||||
} else {
|
||||
targetUser = db.prepare('SELECT id FROM users WHERE role = ? ORDER BY id LIMIT 1', 'admin').get();
|
||||
}
|
||||
db.transaction(() => {
|
||||
ensureUserDefaultCategories(uid);
|
||||
|
||||
if (!targetUser) {
|
||||
throw new Error('User not found. Please create a user first.');
|
||||
}
|
||||
// ── Categories (bill + spending), flagging ONLY ones this seed creates ──
|
||||
const catId = {};
|
||||
const getOrCreateCategory = (name, { spending = false } = {}) => {
|
||||
const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE').get(uid, name);
|
||||
if (existing) { catId[name] = existing.id; return; }
|
||||
const r = db.prepare('INSERT INTO categories (user_id, name, spending_enabled, is_seeded) VALUES (?, ?, ?, 1)')
|
||||
.run(uid, name, spending ? 1 : 0);
|
||||
catId[name] = r.lastInsertRowid;
|
||||
counts.categories++;
|
||||
};
|
||||
for (const name of BILL_CATEGORIES) getOrCreateCategory(name);
|
||||
for (const name of SPENDING_CATEGORIES) getOrCreateCategory(name, { spending: true });
|
||||
|
||||
const targetUserId = targetUser.id;
|
||||
console.log(`📝 Seeding demo data for user: ${targetUserId}`);
|
||||
|
||||
// Ensure default categories exist for this user
|
||||
ensureUserDefaultCategories(targetUserId);
|
||||
|
||||
// Create our demo categories if they don't exist
|
||||
const categoriesMap = {};
|
||||
let categoriesCreated = 0;
|
||||
|
||||
for (const categoryName of CATEGORIES) {
|
||||
const before = db.prepare('SELECT id FROM categories WHERE user_id = ? AND LOWER(name) = LOWER(?)').get(targetUserId, categoryName);
|
||||
const category = getCategoryByName(db, targetUserId, categoryName);
|
||||
categoriesMap[categoryName] = category.id;
|
||||
db.prepare('UPDATE categories SET is_seeded = 1 WHERE id = ?').run(category.id);
|
||||
if (!before) categoriesCreated++;
|
||||
}
|
||||
|
||||
// Create bills
|
||||
let billsCreated = 0;
|
||||
const insertBill = db.prepare(`
|
||||
INSERT INTO bills (
|
||||
user_id, name, category_id, due_day,
|
||||
billing_cycle, cycle_type,
|
||||
expected_amount, autopay_enabled, interest_rate,
|
||||
current_balance, minimum_payment,
|
||||
snowball_order, snowball_include, snowball_exempt,
|
||||
is_subscription, subscription_type,
|
||||
website, has_2fa,
|
||||
active, is_seeded
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
|
||||
`);
|
||||
|
||||
for (const billData of BILLS) {
|
||||
const category = categoriesMap[billData.category];
|
||||
|
||||
try {
|
||||
insertBill.run(
|
||||
targetUserId,
|
||||
billData.name,
|
||||
category,
|
||||
billData.dueDay || Math.floor(Math.random() * 28) + 1,
|
||||
billData.cycle || 'monthly',
|
||||
billData.cycleType || 'monthly',
|
||||
toCents(billData.amount),
|
||||
billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : 0,
|
||||
billData.interestRate ?? 0,
|
||||
billData.currentBalance != null ? toCents(billData.currentBalance) : null,
|
||||
billData.minPayment != null ? toCents(billData.minPayment) : null,
|
||||
billData.snowballOrder ?? null,
|
||||
billData.snowballInclude ?? 0,
|
||||
billData.snowballExempt ?? 0,
|
||||
billData.isSubscription ? 1 : 0,
|
||||
billData.subscriptionType ?? null,
|
||||
billData.website ?? null,
|
||||
billData.has2fa ? 1 : 0,
|
||||
);
|
||||
billsCreated++;
|
||||
} catch (err) {
|
||||
console.error(`Failed to create bill "${billData.name}":`, err.message);
|
||||
// ── Category groups (organise spending categories) ──
|
||||
for (const [i, grp] of CATEGORY_GROUPS.entries()) {
|
||||
const r = db.prepare('INSERT INTO category_groups (user_id, name, sort_order, is_seeded) VALUES (?, ?, ?, 1)')
|
||||
.run(uid, grp.name, i);
|
||||
counts.groups++;
|
||||
for (const member of grp.members) {
|
||||
if (catId[member]) db.prepare('UPDATE categories SET group_id = ? WHERE id = ?').run(r.lastInsertRowid, catId[member]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Created ${billsCreated} demo bills`);
|
||||
console.log(`✅ Created ${categoriesCreated} demo categories`);
|
||||
// ── Bills ──
|
||||
const insertBill = db.prepare(`
|
||||
INSERT INTO bills (
|
||||
user_id, name, category_id, due_day, billing_cycle, cycle_type,
|
||||
expected_amount, autopay_enabled, autodraft_status, autopay_verified_at, interest_rate,
|
||||
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt,
|
||||
is_subscription, subscription_type, website, has_2fa, active, is_seeded
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
|
||||
`);
|
||||
const billId = {};
|
||||
for (const b of BILLS) {
|
||||
const verifiedAt = b.autopayVerified ? ymd(clampedDate(now.getFullYear(), now.getMonth(), Math.min(b.dueDay, 28))) : null;
|
||||
const r = insertBill.run(
|
||||
uid, b.name, catId[b.category] ?? null, b.dueDay, b.cycle, b.cycleType,
|
||||
toCents(b.amount), b.autopay ? 1 : 0, b.autopayVerified ? 'confirmed' : 'none', verifiedAt, b.interestRate ?? 0,
|
||||
b.currentBalance != null ? toCents(b.currentBalance) : null,
|
||||
b.minPayment != null ? toCents(b.minPayment) : null,
|
||||
b.snowballOrder ?? null, b.snowballInclude ?? 0, b.snowballExempt ?? 0,
|
||||
b.isSubscription ? 1 : 0, b.subscriptionType ?? null, b.website ?? null, b.has2fa ? 1 : 0,
|
||||
);
|
||||
billId[b.name] = r.lastInsertRowid;
|
||||
counts.bills++;
|
||||
}
|
||||
|
||||
return { billsCreated, categoriesCreated };
|
||||
// ── Demo bank source + account (for matched/unmatched transactions) ──
|
||||
const source = db.prepare(
|
||||
"INSERT INTO data_sources (user_id, type, provider, name, status, is_seeded) VALUES (?, 'manual', 'demo', 'Demo Bank', 'active', 1)"
|
||||
).run(uid);
|
||||
const sourceId = source.lastInsertRowid;
|
||||
const account = db.prepare(`
|
||||
INSERT INTO financial_accounts
|
||||
(user_id, data_source_id, provider_account_id, name, org_name, account_type, currency, balance, available_balance, monitored, is_seeded)
|
||||
VALUES (?, ?, 'demo-checking', 'Everyday Checking', 'Demo Bank', 'checking', 'USD', ?, ?, 1, 1)
|
||||
`).run(uid, sourceId, toCents(4210.55), toCents(4210.55));
|
||||
const accountId = account.lastInsertRowid;
|
||||
|
||||
let txnSeq = 0;
|
||||
const insertTxn = db.prepare(`
|
||||
INSERT INTO transactions
|
||||
(user_id, data_source_id, account_id, provider_transaction_id, source_type, transaction_type,
|
||||
posted_date, transacted_at, amount, currency, description, payee, category,
|
||||
matched_bill_id, match_status, spending_category_id, is_seeded)
|
||||
VALUES (?, ?, ?, ?, 'manual', ?, ?, ?, ?, 'USD', ?, ?, ?, ?, ?, ?, 1)
|
||||
`);
|
||||
const addTxn = ({ type = 'debit', date, amountDollars, description, payee, category = null, matchedBillId = null, matchStatus = 'unmatched', spendingCategoryId = null }) => {
|
||||
const cents = type === 'credit' ? toCents(Math.abs(amountDollars)) : -toCents(Math.abs(amountDollars));
|
||||
const r = insertTxn.run(uid, sourceId, accountId, `demo-${++txnSeq}`, type, date, date, cents,
|
||||
description, payee, category, matchedBillId, matchStatus, spendingCategoryId);
|
||||
counts.transactions++;
|
||||
return r.lastInsertRowid;
|
||||
};
|
||||
|
||||
// ── Payment history + matched bank transactions + monthly overrides ──
|
||||
const insertPayment = db.prepare(`
|
||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source, transaction_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const b of BILLS) {
|
||||
const bid = billId[b.name];
|
||||
for (const offset of cycleOffsets(b)) {
|
||||
const y = now.getFullYear(), m = now.getMonth() + offset;
|
||||
const payDate = clampedDate(y, m, b.dueDay);
|
||||
if (payDate > now) continue; // future occurrence → naturally "upcoming", no payment
|
||||
|
||||
// Deliberate skipped month → recorded as a monthly_bill_state skip, no payment.
|
||||
if (b.name === SKIP.bill && offset === -SKIP.monthsAgo) {
|
||||
db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, is_skipped) VALUES (?, ?, ?, 1)')
|
||||
.run(bid, payDate.getFullYear(), payDate.getMonth() + 1);
|
||||
counts.monthlyState++;
|
||||
continue;
|
||||
}
|
||||
// Current month: leave the designated overdue bills unpaid (past due, no payment).
|
||||
if (offset === 0 && OVERDUE_THIS_MONTH.has(b.name)) continue;
|
||||
|
||||
let amount = driftedAmount(b, offset);
|
||||
if (b.name === OVERRIDE.bill && offset === -OVERRIDE.monthsAgo) amount = OVERRIDE.actual;
|
||||
|
||||
// Debt bills: split into interest + principal so the balance pays down.
|
||||
let balanceDelta = null;
|
||||
if (b.currentBalance) {
|
||||
const interest = Math.round((toCents(b.currentBalance) * (b.interestRate ?? 0)) / 100 / 12);
|
||||
balanceDelta = -Math.max(0, toCents(amount) - interest);
|
||||
}
|
||||
|
||||
// A subset of recent payments are auto-matched to a seeded bank transaction.
|
||||
let txnId = null, source_ = 'manual';
|
||||
if (BANK_MATCHED.has(b.name) && offset >= -1) {
|
||||
txnId = addTxn({
|
||||
date: ymd(payDate), amountDollars: amount,
|
||||
description: `${b.name.toUpperCase()} AUTOPAY`, payee: b.name,
|
||||
matchedBillId: bid, matchStatus: 'matched',
|
||||
});
|
||||
source_ = 'transaction_match';
|
||||
}
|
||||
|
||||
insertPayment.run(bid, toCents(amount), ymd(payDate), 'demo', null, balanceDelta, source_, txnId);
|
||||
counts.payments++;
|
||||
}
|
||||
}
|
||||
|
||||
// Amount-override state (actual recorded above; flag the month's actual_amount) + a note.
|
||||
{
|
||||
const od = clampedDate(now.getFullYear(), now.getMonth() - OVERRIDE.monthsAgo, 1);
|
||||
db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount) VALUES (?, ?, ?, ?)')
|
||||
.run(billId[OVERRIDE.bill], od.getFullYear(), od.getMonth() + 1, toCents(OVERRIDE.actual));
|
||||
counts.monthlyState++;
|
||||
db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, notes) VALUES (?, ?, ?, ?)')
|
||||
.run(billId[NOTE.bill], now.getFullYear(), now.getMonth() + 1, NOTE.text);
|
||||
counts.monthlyState++;
|
||||
// Snooze one overdue bill's alert (Overdue Command Center demo).
|
||||
const snoozeUntil = ymd(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 5));
|
||||
db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, snoozed_until) VALUES (?, ?, ?, ?)')
|
||||
.run(billId['Trash Service'], now.getFullYear(), now.getMonth() + 1, snoozeUntil);
|
||||
counts.monthlyState++;
|
||||
}
|
||||
|
||||
// ── Unmatched bank transactions that look like bills → Matches suggestions ──
|
||||
for (const name of ['Gas Utility', 'City Water Dept']) {
|
||||
const b = BILLS.find(x => x.name === name);
|
||||
const d = clampedDate(now.getFullYear(), now.getMonth(), b.dueDay);
|
||||
if (d <= now) addTxn({ date: ymd(d), amountDollars: b.amount, description: `${name.toUpperCase()} PMT`, payee: name });
|
||||
}
|
||||
// ── Un-tracked subscription charges → Subscriptions recommendations ──
|
||||
for (const s of UNTRACKED_SUBS) {
|
||||
const d = clampedDate(now.getFullYear(), now.getMonth(), s.day);
|
||||
if (d <= now) addTxn({ date: ymd(d), amountDollars: s.amount, description: `${s.payee} SUBSCRIPTION`, payee: s.payee });
|
||||
}
|
||||
// ── Everyday spending transactions (categorised) → Spending page ──
|
||||
for (const merchant of SPENDING_MERCHANTS) {
|
||||
const scid = catId[merchant.category] ?? null;
|
||||
merchant.amounts.forEach((amt, i) => {
|
||||
const d = clampedDate(now.getFullYear(), now.getMonth() - (i % 2), 2 + (i * 5) % 26);
|
||||
if (d <= now) addTxn({ date: ymd(d), amountDollars: amt, description: merchant.payee, payee: merchant.payee, spendingCategoryId: scid });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Planning: monthly income + starting amounts (recent months) ──
|
||||
for (let off = -3; off <= 0; off++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + off, 1);
|
||||
const y = d.getFullYear(), mo = d.getMonth() + 1;
|
||||
const hasIncome = db.prepare('SELECT 1 FROM monthly_income WHERE user_id = ? AND year = ? AND month = ?').get(uid, y, mo);
|
||||
if (!hasIncome) {
|
||||
db.prepare('INSERT INTO monthly_income (user_id, year, month, label, amount, is_seeded) VALUES (?, ?, ?, ?, ?, 1)')
|
||||
.run(uid, y, mo, 'Paycheck', toCents(5200));
|
||||
counts.planning++;
|
||||
}
|
||||
const hasStart = db.prepare('SELECT 1 FROM monthly_starting_amounts WHERE user_id = ? AND year = ? AND month = ?').get(uid, y, mo);
|
||||
if (!hasStart) {
|
||||
db.prepare('INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, is_seeded) VALUES (?, ?, ?, ?, ?, 1)')
|
||||
.run(uid, y, mo, toCents(3200), toCents(2800));
|
||||
counts.planning++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spending budgets + auto-categorise rules ──
|
||||
for (const [cat, amount] of Object.entries(SPENDING_BUDGETS)) {
|
||||
if (!catId[cat]) continue;
|
||||
db.prepare('INSERT INTO spending_budgets (user_id, category_id, year, month, amount, is_seeded) VALUES (?, ?, ?, ?, ?, 1)')
|
||||
.run(uid, catId[cat], now.getFullYear(), now.getMonth() + 1, toCents(amount));
|
||||
counts.budgets++;
|
||||
}
|
||||
for (const rule of SPENDING_RULES) {
|
||||
if (!catId[rule.category]) continue;
|
||||
db.prepare('INSERT INTO spending_category_rules (user_id, category_id, merchant, is_seeded) VALUES (?, ?, ?, 1)')
|
||||
.run(uid, catId[rule.category], rule.merchant);
|
||||
counts.spendingRules++;
|
||||
}
|
||||
|
||||
// ── Snowball: an active plan + the user's extra-payment setting ──
|
||||
const debtBills = BILLS.filter(b => b.snowballInclude).sort((a, b) => (a.snowballOrder ?? 99) - (b.snowballOrder ?? 99));
|
||||
const snapshot = JSON.stringify({
|
||||
method: 'snowball',
|
||||
extra_payment_cents: toCents(200),
|
||||
order: debtBills.map(b => ({ bill_id: billId[b.name], name: b.name, balance_cents: toCents(b.currentBalance) })),
|
||||
});
|
||||
db.prepare(`INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, is_seeded)
|
||||
VALUES (?, 'My Debt Snowball', 'snowball', 'active', ?, ?, 1)`).run(uid, toCents(200), snapshot);
|
||||
counts.snowballPlans++;
|
||||
// Only set the extra-payment if the user hasn't chosen one (don't clobber real data).
|
||||
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ? AND COALESCE(snowball_extra_payment, 0) = 0')
|
||||
.run(toCents(200), uid);
|
||||
|
||||
// ── Merchant → bill auto-match rules (cascade with their bill) ──
|
||||
for (const name of ['Netflix', 'Spotify']) {
|
||||
db.prepare('INSERT INTO bill_merchant_rules (user_id, bill_id, merchant) VALUES (?, ?, ?)')
|
||||
.run(uid, billId[name], name.toUpperCase());
|
||||
counts.merchantRules++;
|
||||
}
|
||||
|
||||
// ── Calendar feed subscription token ──
|
||||
const crypto = require('crypto');
|
||||
db.prepare('INSERT INTO calendar_tokens (user_id, token, label, active, is_seeded) VALUES (?, ?, ?, 1, 1)')
|
||||
.run(uid, crypto.randomBytes(24).toString('hex'), 'Demo Calendar Feed');
|
||||
counts.calendarTokens++;
|
||||
})();
|
||||
|
||||
return {
|
||||
billsCreated: counts.bills,
|
||||
categoriesCreated: counts.categories,
|
||||
paymentsCreated: counts.payments,
|
||||
transactionsCreated: counts.transactions,
|
||||
counts,
|
||||
};
|
||||
}
|
||||
|
||||
// Run seed if called directly
|
||||
// CLI entry
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const result = seedDemoData();
|
||||
console.log('\nSeed complete:', result);
|
||||
console.log('\n✅ Seed complete:', JSON.stringify(result.counts, null, 2));
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('Seed failed:', err.message);
|
||||
process.exit(1);
|
||||
process.exit(err.code === 'ALREADY_SEEDED' ? 0 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,4 +56,54 @@ function eraseUserData(db: Db, userId: number): { erased: number; counts: Record
|
|||
return { erased, counts };
|
||||
}
|
||||
|
||||
module.exports = { eraseUserData };
|
||||
// User-scoped tables the demo seeder marks with is_seeded = 1 (not bill-children,
|
||||
// which cascade with their seeded bill). Keep in sync with scripts/seedDemoData.cts
|
||||
// and the v1.07 migration.
|
||||
const DEMO_MARKER_TABLES = [
|
||||
'transactions', 'financial_accounts', 'data_sources',
|
||||
'category_groups', 'bill_templates', 'snowball_plans', 'spending_category_rules',
|
||||
'spending_budgets', 'monthly_income', 'monthly_starting_amounts',
|
||||
'calendar_tokens', 'declined_subscription_hints',
|
||||
];
|
||||
|
||||
/**
|
||||
* Surgically remove ONLY the demo data seeded by scripts/seedDemoData.cts for one
|
||||
* user — never their real data. Runs in one transaction, child → parent so it holds
|
||||
* with foreign_keys ON. Marker tables go by is_seeded; bill-children cascade with the
|
||||
* seeded bill. A seeded category is removed only if no surviving (non-seeded) bill
|
||||
* still references it, so a user's own bill that reused a demo category keeps it.
|
||||
* Mirrors eraseUserData(); audits to import_history.
|
||||
*/
|
||||
function clearSeededDemoData(db: Db, userId: number): { removed: number; counts: Record<string, number> } {
|
||||
const counts: Record<string, number> = {};
|
||||
const del = (label: string, sql: string) => { counts[label] = db.prepare(sql).run(userId).changes; };
|
||||
|
||||
const run = db.transaction(() => {
|
||||
// Bank chain first (transactions cascade their match rejections), then the rest
|
||||
// of the independent marker tables.
|
||||
for (const t of DEMO_MARKER_TABLES) del(t, `DELETE FROM ${t} WHERE user_id = ? AND is_seeded = 1`);
|
||||
|
||||
// Bill-children counted explicitly (they also cascade with the bill below).
|
||||
del('payments', 'DELETE FROM payments WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ? AND is_seeded = 1)');
|
||||
del('bills', 'DELETE FROM bills WHERE user_id = ? AND is_seeded = 1');
|
||||
// Seeded categories only if no surviving bill still references them.
|
||||
counts.categories = db.prepare(
|
||||
`DELETE FROM categories WHERE user_id = ? AND is_seeded = 1
|
||||
AND id NOT IN (SELECT category_id FROM bills WHERE user_id = ? AND category_id IS NOT NULL)`
|
||||
).run(userId, userId).changes;
|
||||
// Reset the demo snowball extra-payment.
|
||||
db.prepare('UPDATE users SET snowball_extra_payment = 0 WHERE id = ?').run(userId);
|
||||
});
|
||||
run();
|
||||
|
||||
const removed = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
||||
db.prepare(`
|
||||
INSERT INTO import_history (user_id, imported_at, source_filename, file_type,
|
||||
rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json)
|
||||
VALUES (?, ?, 'clear-demo-data', 'clear-demo', ?, 0, 0, 0, 0, 0, ?, ?)
|
||||
`).run(userId, new Date().toISOString(), removed, JSON.stringify({ action: 'clear-demo-data' }), JSON.stringify(counts));
|
||||
|
||||
return { removed, counts };
|
||||
}
|
||||
|
||||
module.exports = { eraseUserData, clearSeededDemoData };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
'use strict';
|
||||
|
||||
// Demo seed + surgical removal. seedDemoData() must populate every user-facing
|
||||
// surface, and clearSeededDemoData() must remove EXACTLY the seeded rows — never a
|
||||
// user's own bills/categories/planning, and leave no orphans. This is the contract
|
||||
// the "Seed / Clear Demo Data" buttons depend on.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-seed-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database.cts');
|
||||
const { seedDemoData } = require('../scripts/seedDemoData.cts');
|
||||
const { clearSeededDemoData, eraseUserData } = require('../services/userDataService.cts');
|
||||
|
||||
const mkUser = (db, name) =>
|
||||
db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES (?, 'hash', 'user', 1)").run(name).lastInsertRowid;
|
||||
const num = (db, sql, ...p) => db.prepare(sql).get(...p).n;
|
||||
|
||||
let db, userA, userB, userC;
|
||||
test.before(() => {
|
||||
db = getDb();
|
||||
db.pragma('foreign_keys = ON');
|
||||
userA = mkUser(db, 'seed-a');
|
||||
userB = mkUser(db, 'seed-b');
|
||||
userC = mkUser(db, 'seed-c');
|
||||
seedDemoData(userA);
|
||||
seedDemoData(userB);
|
||||
seedDemoData(userC);
|
||||
});
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} }
|
||||
});
|
||||
|
||||
test('seed populates every user-facing surface', () => {
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=? AND is_seeded=1', userA), 23);
|
||||
// 8 of 9 bill categories reuse the user's defaults (not flagged); Healthcare + 5 spending are created.
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM categories WHERE user_id=? AND is_seeded=1', userA), 6);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM categories WHERE user_id=? AND is_seeded=1 AND spending_enabled=1', userA), 5);
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM payments p JOIN bills b ON b.id=p.bill_id WHERE b.user_id=?', userA) > 40, 'payment history');
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=? AND is_seeded=1', userA) > 10, 'bank transactions');
|
||||
assert.ok(num(db, "SELECT COUNT(*) n FROM transactions WHERE user_id=? AND match_status='matched'", userA) >= 3, 'matched txns');
|
||||
assert.ok(num(db, "SELECT COUNT(*) n FROM transactions WHERE user_id=? AND match_status='unmatched'", userA) >= 5, 'unmatched txns → suggestions/recommendations');
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=? AND spending_category_id IS NOT NULL', userA) > 5, 'categorised spending');
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM monthly_bill_state m JOIN bills b ON b.id=m.bill_id WHERE b.user_id=?', userA) >= 4, 'skip/override/note/snooze');
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM category_groups WHERE user_id=? AND is_seeded=1', userA), 2);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM snowball_plans WHERE user_id=? AND is_seeded=1', userA), 1);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM spending_budgets WHERE user_id=? AND is_seeded=1', userA), 5);
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM spending_category_rules WHERE user_id=? AND is_seeded=1', userA) >= 3);
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM monthly_income WHERE user_id=? AND is_seeded=1', userA) >= 1);
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM monthly_starting_amounts WHERE user_id=? AND is_seeded=1', userA) >= 1);
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM bill_merchant_rules r JOIN bills b ON b.id=r.bill_id WHERE b.user_id=?', userA) >= 1);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM data_sources WHERE user_id=? AND is_seeded=1', userA), 1);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM financial_accounts WHERE user_id=? AND is_seeded=1', userA), 1);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM calendar_tokens WHERE user_id=? AND is_seeded=1', userA), 1);
|
||||
assert.ok(db.prepare('SELECT snowball_extra_payment e FROM users WHERE id=?').get(userA).e > 0, 'snowball extra-payment set');
|
||||
|
||||
// Matched payments link a real seeded transaction; money is integer cents.
|
||||
assert.ok(num(db, "SELECT COUNT(*) n FROM payments WHERE payment_source='transaction_match' AND transaction_id IN (SELECT id FROM transactions)") >= 3);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM payments WHERE amount <> CAST(amount AS INT)'), 0, 'cents-clean');
|
||||
});
|
||||
|
||||
test('seed refuses to double-seed (idempotency guard)', () => {
|
||||
assert.throws(() => seedDemoData(userA), (e) => e.code === 'ALREADY_SEEDED' && e.status === 409);
|
||||
});
|
||||
|
||||
test('clear removes ONLY seeded data — user bills/categories & no orphans', () => {
|
||||
// A user's OWN bill in a seed-created category (Healthcare) + one in a default category.
|
||||
const healthcareId = db.prepare("SELECT id FROM categories WHERE user_id=? AND name='Healthcare'").get(userA).id;
|
||||
assert.equal(db.prepare('SELECT is_seeded s FROM categories WHERE id=?').get(healthcareId).s, 1, 'Healthcare is a seeded category');
|
||||
const utilitiesId = db.prepare("SELECT id FROM categories WHERE user_id=? AND name='Utilities'").get(userA).id;
|
||||
const ownBill1 = db.prepare("INSERT INTO bills (user_id, name, category_id, due_day, expected_amount, active, is_seeded) VALUES (?, 'My Doctor', ?, 9, 5000, 1, 0)").run(userA, healthcareId).lastInsertRowid;
|
||||
const ownBill2 = db.prepare("INSERT INTO bills (user_id, name, category_id, due_day, expected_amount, active, is_seeded) VALUES (?, 'My Power', ?, 9, 6000, 1, 0)").run(userA, utilitiesId).lastInsertRowid;
|
||||
|
||||
const result = clearSeededDemoData(db, userA);
|
||||
assert.ok(result.removed > 0);
|
||||
|
||||
// Every seeded surface gone.
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=? AND is_seeded=1', userA), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=? AND is_seeded=1', userA), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM data_sources WHERE user_id=? AND is_seeded=1', userA), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM financial_accounts WHERE user_id=? AND is_seeded=1', userA), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM snowball_plans WHERE user_id=? AND is_seeded=1', userA), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM spending_budgets WHERE user_id=? AND is_seeded=1', userA), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM monthly_income WHERE user_id=? AND is_seeded=1', userA), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM category_groups WHERE user_id=? AND is_seeded=1', userA), 0);
|
||||
|
||||
// No orphans: payments/transactions/state for deleted bills are gone.
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM payments p LEFT JOIN bills b ON b.id=p.bill_id WHERE b.id IS NULL'), 0, 'no orphan payments');
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM monthly_bill_state m LEFT JOIN bills b ON b.id=m.bill_id WHERE b.id IS NULL'), 0, 'no orphan monthly_bill_state');
|
||||
|
||||
// User's own data preserved.
|
||||
assert.ok(db.prepare('SELECT 1 FROM bills WHERE id=?').get(ownBill1), 'user bill 1 survives');
|
||||
assert.ok(db.prepare('SELECT 1 FROM bills WHERE id=?').get(ownBill2), 'user bill 2 survives');
|
||||
assert.equal(db.prepare('SELECT category_id c FROM bills WHERE id=?').get(ownBill1).c, healthcareId, 'still categorised (not SET NULL)');
|
||||
// Healthcare seeded category SURVIVES because a user bill still references it.
|
||||
assert.ok(db.prepare('SELECT 1 FROM categories WHERE id=?').get(healthcareId), 'referenced seeded category kept');
|
||||
// A seeded spending category with no user bill IS removed.
|
||||
assert.equal(num(db, "SELECT COUNT(*) n FROM categories WHERE user_id=? AND name='Groceries'", userA), 0, 'unreferenced seeded category removed');
|
||||
// Default categories untouched.
|
||||
assert.ok(db.prepare('SELECT 1 FROM categories WHERE id=?').get(utilitiesId), 'default category kept');
|
||||
// Snowball extra-payment reset.
|
||||
assert.equal(db.prepare('SELECT snowball_extra_payment e FROM users WHERE id=?').get(userA).e, 0);
|
||||
});
|
||||
|
||||
test('clear leaves other users\' seeded data untouched', () => {
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=? AND is_seeded=1', userB), 23, 'user B intact after clearing A');
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=? AND is_seeded=1', userB) > 0);
|
||||
});
|
||||
|
||||
test('clear is audited to import_history', () => {
|
||||
const row = db.prepare("SELECT file_type, source_filename FROM import_history WHERE user_id=? AND file_type='clear-demo' ORDER BY id DESC LIMIT 1").get(userA);
|
||||
assert.equal(row.file_type, 'clear-demo');
|
||||
assert.equal(row.source_filename, 'clear-demo-data');
|
||||
});
|
||||
|
||||
test('eraseUserData still wipes seeded demo data wholesale', () => {
|
||||
assert.ok(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=?', userC) > 0);
|
||||
eraseUserData(db, userC);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=?', userC), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=?', userC), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM snowball_plans WHERE user_id=?', userC), 0);
|
||||
assert.equal(num(db, 'SELECT COUNT(*) n FROM payments p LEFT JOIN bills b ON b.id=p.bill_id WHERE b.id IS NULL'), 0, 'no orphans after erase');
|
||||
});
|
||||
|
|
@ -115,6 +115,6 @@ export default defineConfig({
|
|||
// `npm run test:client`; `npm run test:all` runs both.
|
||||
test: {
|
||||
environment: 'node', // hook/component tests opt into jsdom via @vitest-environment
|
||||
include: ['client/**/*.test.{js,jsx}'],
|
||||
include: ['client/**/*.test.{js,jsx,ts,tsx}'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue