feat(tracker): persist Overdue/Drift panel collapse + inline hide
The Overdue Command Center and Price-Change (drift) panels reset to expanded on every load — their collapse arrows used local useState. Now the collapsed state is remembered per-user via two new settings (tracker_overdue_collapsed / tracker_drift_collapsed), mirroring the search panel's useSearchPanelPreference pattern. Each panel header also gains an inline "Hide" control (EyeOff, like the search panel) that writes the existing tracker_show_* setting and fires an Undo toast; hidden sections come back from Settings → Tracker Layout. - services/userSettings.js: allowlist + defaults for the two collapse keys - TrackerPage: read collapse state, hide handlers with Undo, wire props - OverdueCommandCenter/DriftInsightPanel: controlled collapse + header hide Verified: typecheck 0, lint 0 errors, build, and a live settings round-trip (collapse persists, hide writes, Undo restores, allowlist rejects junk keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
384748feeb
commit
343cc3a36f
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
|
import { TrendingUp, TrendingDown, ChevronDown, ChevronUp, EyeOff } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt } from '@/lib/utils';
|
||||||
|
|
@ -133,8 +133,24 @@ function DriftRow({ row, refresh }: { row: DriftBill; refresh: () => void }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DriftInsightPanel({ driftBills, refresh }: { driftBills?: DriftBill[]; refresh: () => void }) {
|
interface DriftInsightPanelProps {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
driftBills?: DriftBill[];
|
||||||
|
refresh: () => void;
|
||||||
|
// Controlled + persisted collapse state (falls back to local state when omitted).
|
||||||
|
collapsed?: boolean;
|
||||||
|
onCollapsedChange?: (collapsed: boolean) => void;
|
||||||
|
// Fully hide the panel (writes the tracker_show_* setting on the page).
|
||||||
|
onHide?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriftInsightPanel({ driftBills, refresh, collapsed, onCollapsedChange, onHide }: DriftInsightPanelProps) {
|
||||||
|
const [localOpen, setLocalOpen] = useState(true);
|
||||||
|
const isControlled = collapsed !== undefined;
|
||||||
|
const isOpen = isControlled ? !collapsed : localOpen;
|
||||||
|
const setOpen = (open: boolean) => {
|
||||||
|
if (isControlled) onCollapsedChange?.(!open);
|
||||||
|
else setLocalOpen(open);
|
||||||
|
};
|
||||||
|
|
||||||
if (!driftBills?.length) return null;
|
if (!driftBills?.length) return null;
|
||||||
|
|
||||||
|
|
@ -143,14 +159,20 @@ export default function DriftInsightPanel({ driftBills, refresh }: { driftBills?
|
||||||
const netColor = totalNetDelta >= 0 ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400';
|
const netColor = totalNetDelta >= 0 ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400';
|
||||||
const hasIncrease = driftBills.some(b => b.direction === 'up');
|
const hasIncrease = driftBills.some(b => b.direction === 'up');
|
||||||
|
|
||||||
|
const ToggleIcon = isOpen ? ChevronDown : ChevronUp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
||||||
<div className="rounded-xl border border-amber-400/30 bg-amber-500/[0.06] shadow-sm overflow-hidden dark:bg-amber-400/[0.05]">
|
<div className="rounded-xl border border-amber-400/30 bg-amber-500/[0.06] shadow-sm overflow-hidden dark:bg-amber-400/[0.05]">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<CollapsibleTrigger asChild>
|
<div className="flex items-center gap-2 px-4 py-3">
|
||||||
<button className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-amber-500/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 focus-visible:ring-inset">
|
<CollapsibleTrigger asChild>
|
||||||
<div className="flex items-center gap-2.5">
|
<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-amber-400/40"
|
||||||
|
>
|
||||||
{hasIncrease
|
{hasIncrease
|
||||||
? <TrendingUp className="h-4 w-4 shrink-0 text-amber-400" />
|
? <TrendingUp className="h-4 w-4 shrink-0 text-amber-400" />
|
||||||
: <TrendingDown className="h-4 w-4 shrink-0 text-teal-400" />
|
: <TrendingDown className="h-4 w-4 shrink-0 text-teal-400" />
|
||||||
|
|
@ -163,15 +185,23 @@ export default function DriftInsightPanel({ driftBills, refresh }: { driftBills?
|
||||||
<span className={cn('font-mono text-sm', netColor)}>
|
<span className={cn('font-mono text-sm', netColor)}>
|
||||||
{sign}{fmt(asDollars(Math.abs(totalNetDelta)))}/mo net
|
{sign}{fmt(asDollars(Math.abs(totalNetDelta)))}/mo net
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<ToggleIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||||
<ChevronDown
|
</button>
|
||||||
className={cn(
|
</CollapsibleTrigger>
|
||||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
{onHide && (
|
||||||
isOpen && 'rotate-180'
|
<Button
|
||||||
)}
|
type="button"
|
||||||
/>
|
size="sm"
|
||||||
</button>
|
variant="ghost"
|
||||||
</CollapsibleTrigger>
|
onClick={onHide}
|
||||||
|
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Hide price-change insights"
|
||||||
|
>
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Hide</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bill rows */}
|
{/* Bill rows */}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } from 'lucide-react';
|
import { AlertCircle, ChevronDown, ChevronUp, BellOff, SkipForward, CreditCard, EyeOff } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmt, localDateString } from '@/lib/utils';
|
import { cn, fmt, localDateString } from '@/lib/utils';
|
||||||
|
|
@ -157,11 +157,22 @@ interface OverdueCommandCenterProps {
|
||||||
month: number;
|
month: number;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
onPayNow: (row: TrackerRow) => void;
|
onPayNow: (row: TrackerRow) => void;
|
||||||
|
// Controlled + persisted collapse state (falls back to local state when omitted).
|
||||||
|
collapsed?: boolean;
|
||||||
|
onCollapsedChange?: (collapsed: boolean) => void;
|
||||||
|
// Fully hide the panel (writes the tracker_show_* setting on the page).
|
||||||
|
onHide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OverdueCommandCenter({ rows, year, month, refresh, onPayNow }: OverdueCommandCenterProps) {
|
export default function OverdueCommandCenter({ rows, year, month, refresh, onPayNow, collapsed, onCollapsedChange, onHide }: OverdueCommandCenterProps) {
|
||||||
const todayStr = localDateString();
|
const todayStr = localDateString();
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [localOpen, setLocalOpen] = useState(true);
|
||||||
|
const isControlled = collapsed !== undefined;
|
||||||
|
const isOpen = isControlled ? !collapsed : localOpen;
|
||||||
|
const setOpen = (open: boolean) => {
|
||||||
|
if (isControlled) onCollapsedChange?.(!open);
|
||||||
|
else setLocalOpen(open);
|
||||||
|
};
|
||||||
|
|
||||||
const overdueRows = rows.filter(r =>
|
const overdueRows = rows.filter(r =>
|
||||||
(r.status === 'late' || r.status === 'missed') &&
|
(r.status === 'late' || r.status === 'missed') &&
|
||||||
|
|
@ -183,14 +194,20 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
||||||
return sum + Math.max(0, threshold - paid);
|
return sum + Math.max(0, threshold - paid);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
const ToggleIcon = isOpen ? ChevronDown : ChevronUp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
<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-rose-500/30 bg-rose-500/[0.055] dark:border-rose-300/25 dark:bg-rose-400/[0.05]">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<CollapsibleTrigger asChild>
|
<div className="flex items-center gap-2 px-4 py-3">
|
||||||
<button className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-rose-500/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 focus-visible:ring-inset">
|
<CollapsibleTrigger asChild>
|
||||||
<div className="flex items-center gap-2.5">
|
<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"
|
||||||
|
>
|
||||||
<AlertCircle className="h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300" />
|
<AlertCircle className="h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300" />
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{`${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
|
{`${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
|
||||||
|
|
@ -198,22 +215,28 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
||||||
<span className="font-mono text-sm font-semibold text-rose-600 dark:text-rose-300">
|
<span className="font-mono text-sm font-semibold text-rose-600 dark:text-rose-300">
|
||||||
{fmt(asDollars(totalOverdue))}
|
{fmt(asDollars(totalOverdue))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{snoozedRows.length > 0 && (
|
{snoozedRows.length > 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
({snoozedRows.length} snoozed)
|
({snoozedRows.length} snoozed)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDown
|
<ToggleIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||||
className={cn(
|
</button>
|
||||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
</CollapsibleTrigger>
|
||||||
isOpen && 'rotate-180'
|
{onHide && (
|
||||||
)}
|
<Button
|
||||||
/>
|
type="button"
|
||||||
</div>
|
size="sm"
|
||||||
</button>
|
variant="ghost"
|
||||||
</CollapsibleTrigger>
|
onClick={onHide}
|
||||||
|
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Hide overdue panel"
|
||||||
|
>
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Hide</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bill rows */}
|
{/* Bill rows */}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,8 @@ const TRACKER_SETTING_DEFAULTS: Record<string, string> = {
|
||||||
tracker_show_safe_to_spend: '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_overdue_collapsed: 'false',
|
||||||
|
tracker_drift_collapsed: 'false',
|
||||||
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
|
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -589,6 +591,9 @@ export default function TrackerPage() {
|
||||||
const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center);
|
const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center);
|
||||||
const showSafeToSpend = settingEnabled(trackerSettings.tracker_show_safe_to_spend);
|
const showSafeToSpend = settingEnabled(trackerSettings.tracker_show_safe_to_spend);
|
||||||
const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
|
const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
|
||||||
|
// Collapse state for the Overdue / Drift panels is remembered per-user (default expanded).
|
||||||
|
const overdueCollapsed = settingEnabled(trackerSettings.tracker_overdue_collapsed, false);
|
||||||
|
const driftCollapsed = settingEnabled(trackerSettings.tracker_drift_collapsed, false);
|
||||||
const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
|
const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
|
||||||
(!bannerSnoozedUntil || bannerSnoozedUntil <= today);
|
(!bannerSnoozedUntil || bannerSnoozedUntil <= today);
|
||||||
const visibleTableColumns = useMemo(
|
const visibleTableColumns = useMemo(
|
||||||
|
|
@ -637,6 +642,20 @@ export default function TrackerPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideOverduePanel() {
|
||||||
|
saveTrackerSettings({ tracker_show_overdue_command_center: 'false' });
|
||||||
|
toast('Overdue panel hidden.', {
|
||||||
|
action: { label: 'Undo', onClick: () => saveTrackerSettings({ tracker_show_overdue_command_center: 'true' }) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDriftPanel() {
|
||||||
|
saveTrackerSettings({ tracker_show_drift_insights: 'false' });
|
||||||
|
toast('Price-change insights hidden.', {
|
||||||
|
action: { label: 'Undo', onClick: () => saveTrackerSettings({ tracker_show_drift_insights: 'true' }) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const toggleFilter = (key: BoolFilterKey) => {
|
const toggleFilter = (key: BoolFilterKey) => {
|
||||||
const paramMap: Record<BoolFilterKey, string> = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
const paramMap: Record<BoolFilterKey, string> = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
|
||||||
updateParams({ [paramMap[key]]: !filters[key] });
|
updateParams({ [paramMap[key]]: !filters[key] });
|
||||||
|
|
@ -1166,6 +1185,9 @@ export default function TrackerPage() {
|
||||||
month={month}
|
month={month}
|
||||||
refresh={invalidateData}
|
refresh={invalidateData}
|
||||||
onPayNow={(row) => setCommandCenterPayRow(row)}
|
onPayNow={(row) => setCommandCenterPayRow(row)}
|
||||||
|
collapsed={overdueCollapsed}
|
||||||
|
onCollapsedChange={(c) => saveTrackerSettings({ tracker_overdue_collapsed: c ? 'true' : 'false' })}
|
||||||
|
onHide={hideOverduePanel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1184,6 +1206,9 @@ export default function TrackerPage() {
|
||||||
<DriftInsightPanel
|
<DriftInsightPanel
|
||||||
driftBills={driftData?.bills || []}
|
driftBills={driftData?.bills || []}
|
||||||
refresh={invalidateData}
|
refresh={invalidateData}
|
||||||
|
collapsed={driftCollapsed}
|
||||||
|
onCollapsedChange={(c) => saveTrackerSettings({ tracker_drift_collapsed: c ? 'true' : 'false' })}
|
||||||
|
onHide={hideDriftPanel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ const USER_SETTING_KEYS = [
|
||||||
'tracker_show_safe_to_spend',
|
'tracker_show_safe_to_spend',
|
||||||
'tracker_show_overdue_command_center',
|
'tracker_show_overdue_command_center',
|
||||||
'tracker_show_drift_insights',
|
'tracker_show_drift_insights',
|
||||||
|
'tracker_overdue_collapsed',
|
||||||
|
'tracker_drift_collapsed',
|
||||||
'tracker_table_columns',
|
'tracker_table_columns',
|
||||||
'bank_auto_categorize_merchants',
|
'bank_auto_categorize_merchants',
|
||||||
'spending_show_ready_to_assign',
|
'spending_show_ready_to_assign',
|
||||||
|
|
@ -44,6 +46,8 @@ const USER_SETTING_DEFAULTS = {
|
||||||
tracker_show_safe_to_spend: '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_overdue_collapsed: 'false',
|
||||||
|
tracker_drift_collapsed: 'false',
|
||||||
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
|
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
|
||||||
spending_show_ready_to_assign: 'true',
|
spending_show_ready_to_assign: 'true',
|
||||||
spending_show_avg: 'true',
|
spending_show_avg: 'true',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue