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:
parent
3973560e92
commit
03009e2a83
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 '—';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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"]',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue