refactor(client): de-duplicate parseUtc + settingEnabled to lib/utils (plan A2/A3)

- Extract parseUtc (was copy-pasted in 4 files) and settingEnabled (3 copies,
  one named settingsBool) into client/lib/utils.ts as the single source.
- Replace all local copies with the shared import.
- Add tracker_show_safe_to_spend to the tracker's local settings defaults so it
  matches SettingsPage's list (behavior unchanged; aligns the two sources).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 14:38:12 -05:00
parent 3973560e92
commit 03009e2a83
7 changed files with 30 additions and 51 deletions

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { AlertTriangle, Info } from 'lucide-react'; import { AlertTriangle, Info } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils'; import { errMessage, parseUtc } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
@ -25,12 +25,6 @@ interface BankSyncConfig {
encryption_key_source?: string; encryption_key_source?: string;
} }
function parseUtc(str: string | null | undefined): Date | null {
if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function timeAgo(iso: string | null | undefined): string | null { function timeAgo(iso: string | null | undefined): string | null {
const d = parseUtc(iso); const d = parseUtc(iso);
if (!d) return null; if (!d) return null;

View File

@ -5,7 +5,7 @@ import {
Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink, Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn, errMessage } from '@/lib/utils'; import { cn, errMessage, parseUtc } from '@/lib/utils';
import { formatUSD, formatCentsUSD, type Cents, type Dollars } from '@/lib/money'; import { formatUSD, formatCentsUSD, type Cents, type Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -109,12 +109,6 @@ function TokenInput({ value, onChange, disabled }: {
); );
} }
function parseUtc(str: string | null | undefined): Date | null {
if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function fmtDate(iso: string | null | undefined): string { function fmtDate(iso: string | null | undefined): string {
const d = parseUtc(iso); const d = parseUtc(iso);
if (!d) return '—'; if (!d) return '—';

View File

@ -6,7 +6,7 @@ import {
ArrowUp, ArrowDown, ArrowUpDown, ArrowUp, ArrowDown, ArrowUpDown,
} from 'lucide-react'; } from 'lucide-react';
import { api, type QueryParams } from '@/api'; import { api, type QueryParams } from '@/api';
import { cn, errMessage } from '@/lib/utils'; import { cn, errMessage, parseUtc } from '@/lib/utils';
import { asDollars } from '@/lib/money'; import { asDollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -192,12 +192,6 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
); );
} }
function parseUtc(str: string | null | undefined): Date | null {
if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function timeAgo(iso: string | null | undefined): string | null { function timeAgo(iso: string | null | undefined): string | null {
const d = parseUtc(iso); const d = parseUtc(iso);
if (!d) return null; if (!d) return null;

View File

@ -33,6 +33,23 @@ export function localDateString(date: Date = new Date()): string {
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
// Parse a server timestamp as UTC. SQLite datetime('now') returns
// "2026-06-07 23:40:15" — no `T`, no `Z` — which `new Date()` would otherwise
// read as *local* time. Normalize to an explicit UTC instant.
export function parseUtc(str: string | null | undefined): Date | null {
if (!str) return null;
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
// Interpret a persisted string/boolean setting as on/off. Server settings are
// stored as strings ('true'/'false'); an absent/empty value falls back (default
// on). Single source of truth for the tracker/spending display toggles.
export function settingEnabled(value: unknown, fallback = true): boolean {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
export function todayStr(): string { export function todayStr(): string {
return localDateString(); return localDateString();
} }

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react'; import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn, errMessage } from '@/lib/utils'; import { cn, errMessage, settingEnabled } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { CalendarFeedManager } from '@/components/CalendarFeedManager'; import { CalendarFeedManager } from '@/components/CalendarFeedManager';
@ -270,10 +270,6 @@ function LinkImportToggle() {
); );
} }
function settingsBool(value: unknown, fallback = true): boolean {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
interface SettingsState { interface SettingsState {
currency: string; currency: string;
@ -435,7 +431,7 @@ export default function SettingsPage() {
description="Show the account balance and projected-after-bills banner on the tracker." description="Show the account balance and projected-after-bills banner on the tracker."
> >
<Switch <Switch
checked={settingsBool(settings.tracker_show_bank_projection_banner)} checked={settingEnabled(settings.tracker_show_bank_projection_banner)}
onCheckedChange={(checked) => set('tracker_show_bank_projection_banner', String(checked))} onCheckedChange={(checked) => set('tracker_show_bank_projection_banner', String(checked))}
aria-label="Show Bank Projection Banner" aria-label="Show Bank Projection Banner"
/> />
@ -445,7 +441,7 @@ export default function SettingsPage() {
description="Show the tracker search, filters, and sort controls." description="Show the tracker search, filters, and sort controls."
> >
<Switch <Switch
checked={settingsBool(settings.tracker_show_search_sort)} checked={settingEnabled(settings.tracker_show_search_sort)}
onCheckedChange={(checked) => set('tracker_show_search_sort', String(checked))} onCheckedChange={(checked) => set('tracker_show_search_sort', String(checked))}
aria-label="Show Search and sort" aria-label="Show Search and sort"
/> />
@ -455,7 +451,7 @@ export default function SettingsPage() {
description="Show the bank balance, paid, overdue, previous month, and trend cards." description="Show the bank balance, paid, overdue, previous month, and trend cards."
> >
<Switch <Switch
checked={settingsBool(settings.tracker_show_summary_cards)} checked={settingEnabled(settings.tracker_show_summary_cards)}
onCheckedChange={(checked) => set('tracker_show_summary_cards', String(checked))} onCheckedChange={(checked) => set('tracker_show_summary_cards', String(checked))}
aria-label="Show Summary cards" aria-label="Show Summary cards"
/> />
@ -465,7 +461,7 @@ export default function SettingsPage() {
description="Show the safe-to-spend projection card with the payday countdown and upcoming bills." description="Show the safe-to-spend projection card with the payday countdown and upcoming bills."
> >
<Switch <Switch
checked={settingsBool(settings.tracker_show_safe_to_spend)} checked={settingEnabled(settings.tracker_show_safe_to_spend)}
onCheckedChange={(checked) => set('tracker_show_safe_to_spend', String(checked))} onCheckedChange={(checked) => set('tracker_show_safe_to_spend', String(checked))}
aria-label="Show Safe to Spend" aria-label="Show Safe to Spend"
/> />
@ -475,7 +471,7 @@ export default function SettingsPage() {
description="Show the quick-action panel for overdue bills." description="Show the quick-action panel for overdue bills."
> >
<Switch <Switch
checked={settingsBool(settings.tracker_show_overdue_command_center)} checked={settingEnabled(settings.tracker_show_overdue_command_center)}
onCheckedChange={(checked) => set('tracker_show_overdue_command_center', String(checked))} onCheckedChange={(checked) => set('tracker_show_overdue_command_center', String(checked))}
aria-label="Show Overdue Command Center" aria-label="Show Overdue Command Center"
/> />
@ -485,7 +481,7 @@ export default function SettingsPage() {
description="Show price-change and bill-drift insights on the tracker." description="Show price-change and bill-drift insights on the tracker."
> >
<Switch <Switch
checked={settingsBool(settings.tracker_show_drift_insights)} checked={settingEnabled(settings.tracker_show_drift_insights)}
onCheckedChange={(checked) => set('tracker_show_drift_insights', String(checked))} onCheckedChange={(checked) => set('tracker_show_drift_insights', String(checked))}
aria-label="Show Drift insights" aria-label="Show Drift insights"
/> />

View File

@ -19,7 +19,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { CategoryPicker } from '@/components/transactions/CategoryPicker'; import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { formatUSD, asDollars } from '@/lib/money'; import { formatUSD, asDollars } from '@/lib/money';
import { errMessage } from '@/lib/utils'; import { errMessage, settingEnabled } from '@/lib/utils';
import type { import type {
Category, Category,
SpendingCategoryEntry as CatEntry, SpendingCategoryEntry as CatEntry,
@ -37,11 +37,6 @@ function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0)); return formatUSD(asDollars(Number(v) || 0));
} }
function settingEnabled(value: unknown, fallback = true): boolean {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
type Level = 'over' | 'warn' | 'ok'; type Level = 'over' | 'warn' | 'ok';
type RealCatEntry = CatEntry & { category_id: number }; type RealCatEntry = CatEntry & { category_id: number };

View File

@ -8,7 +8,7 @@ import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import { cn, localDateString } from '@/lib/utils'; import { cn, localDateString, parseUtc, settingEnabled } from '@/lib/utils';
import { formatUSD, asDollars, type Dollars } from '@/lib/money'; import { formatUSD, asDollars, type Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -89,24 +89,12 @@ interface TrackerFilters {
type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt'; type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt';
type Patch = Record<string, string | number | boolean | null | undefined>; type Patch = Record<string, string | number | boolean | null | undefined>;
function parseUtc(str: string | null | undefined): Date | null {
if (!str) return null;
// SQLite datetime('now') returns "2026-06-07 23:40:15" — no T, no Z. Treat as UTC.
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function fmtBalanceAge(isoStr: string | null | undefined): string | null { function fmtBalanceAge(isoStr: string | null | undefined): string | null {
const d = parseUtc(isoStr); const d = parseUtc(isoStr);
if (!d) return null; if (!d) return null;
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
} }
function settingEnabled(value: unknown, fallback = true): boolean {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }: { function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }: {
bankTracking?: BankTracking; bankTracking?: BankTracking;
onSnooze: () => void; onSnooze: () => void;
@ -315,6 +303,7 @@ export default function TrackerPage() {
tracker_bank_projection_banner_snoozed_until: '', tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true', tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true', tracker_show_summary_cards: 'true',
tracker_show_safe_to_spend: 'true',
tracker_show_overdue_command_center: 'true', tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true', tracker_show_drift_insights: 'true',
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]', tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',