refactor(ts): CalendarPage → TypeScript (money map + grid + cashflow + snowball glance)

This commit is contained in:
null 2026-07-04 22:58:34 -05:00
parent 82bd13036a
commit dbf3c42d62
1 changed files with 198 additions and 51 deletions

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { import {
Banknote, Banknote,
@ -15,7 +15,8 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { cn, fmtDate, todayStr, errMessage } from '@/lib/utils';
import { formatUSD, asDollars } from '@/lib/money';
import { isPaidStatus } from '@/lib/trackerUtils'; import { isPaidStatus } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -23,6 +24,123 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { CalendarFeedManager } from '@/components/CalendarFeedManager'; import { CalendarFeedManager } from '@/components/CalendarFeedManager';
function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0));
}
interface StatusSummary {
paid_count: number;
due_count: number;
skipped_count: number;
missed_count: number;
total_due?: number;
total_paid?: number;
}
interface CalDayBill {
bill_id: number;
name: string;
category_name?: string | null;
status?: string;
effective_amount?: number;
paid_amount?: number;
due_date?: string;
}
interface CalDayPayment {
payment_id: number;
bill_name?: string;
method?: string;
amount?: number;
}
interface CalDay {
date: string;
day: number;
status_summary: StatusSummary;
payments: CalDayPayment[];
bills_due: CalDayBill[];
}
interface SummaryInfo {
bill_count?: number;
paid_count?: number;
skipped_count?: number;
missed_count?: number;
paid_total?: number;
expected_total?: number;
remaining_total?: number;
paid_percent?: number;
expense_total?: number;
expense_count?: number;
result?: number;
[key: string]: unknown;
}
interface Cashflow {
has_data?: boolean;
period?: string;
period_projected?: number;
month_projected?: number;
uses_bank_balance?: boolean;
period_end_label?: string;
period_starting?: number;
period_bills_total?: number;
period_paid?: number;
period_paid_count?: number;
period_total_count?: number;
month_starting?: number;
month_bills_total?: number;
month_paid?: number;
month_paid_count?: number;
month_total_count?: number;
}
interface CalendarData {
year: number;
month: number;
days: CalDay[];
summary?: SummaryInfo;
cashflow?: Cashflow;
}
interface BankTracking {
enabled?: boolean;
effective_balance?: number;
account_name?: string;
balance?: number;
pending_payments?: number;
pending_days?: number;
unpaid_this_month?: number;
remaining?: number;
last_updated?: string;
}
interface SummaryDataShape {
starting_amounts?: {
first_amount?: number;
fifteenth_amount?: number;
other_amount?: number;
combined_amount?: number;
};
summary?: SummaryInfo;
bank_tracking?: BankTracking;
income?: { amount?: number };
}
interface SnowballDebt { name?: string; months?: number; payoff_display?: string }
interface SnowballProjection {
snowball?: {
debts?: SnowballDebt[];
months_to_freedom?: number;
payoff_display?: string;
total_interest_paid?: number;
};
comparison?: { months_saved?: number; interest_saved?: number };
}
interface MoneyMarker { label: string; amount: number }
const MONTHS = [ const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December', 'July', 'August', 'September', 'October', 'November', 'December',
@ -34,26 +152,26 @@ function currentMonth() {
return { year: now.getFullYear(), month: now.getMonth() + 1 }; return { year: now.getFullYear(), month: now.getMonth() + 1 };
} }
function shiftMonth(year, month, delta) { function shiftMonth(year: number, month: number, delta: number) {
const next = new Date(year, month - 1 + delta, 1); const next = new Date(year, month - 1 + delta, 1);
return { year: next.getFullYear(), month: next.getMonth() + 1 }; return { year: next.getFullYear(), month: next.getMonth() + 1 };
} }
function displayStatus(status) { function displayStatus(status?: string): string {
if (status === 'due_soon') return 'Due'; if (status === 'due_soon') return 'Due';
if (status === 'late') return 'Late'; if (status === 'late') return 'Late';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due'; return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
} }
function statusTone(status) { function statusTone(status?: string): string {
if (isPaidStatus(status)) return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'; if (isPaidStatus(status || '')) return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground'; if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground';
if (status === 'late') return 'border-orange-400/60 bg-orange-500/25 text-orange-800 shadow-sm shadow-orange-950/10 dark:text-orange-100'; if (status === 'late') return 'border-orange-400/60 bg-orange-500/25 text-orange-800 shadow-sm shadow-orange-950/10 dark:text-orange-100';
if (status === 'missed') return 'border-rose-400/60 bg-rose-500/30 text-rose-800 shadow-sm shadow-rose-950/10 dark:text-rose-100'; if (status === 'missed') return 'border-rose-400/60 bg-rose-500/30 text-rose-800 shadow-sm shadow-rose-950/10 dark:text-rose-100';
return 'border-primary/30 bg-primary/15 text-primary'; return 'border-primary/30 bg-primary/15 text-primary';
} }
function LegendItem({ className, label }) { function LegendItem({ className, label }: { className?: string; label: ReactNode }) {
return ( return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/75"> <span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/75">
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} /> <span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
@ -62,7 +180,13 @@ function LegendItem({ className, label }) {
); );
} }
function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) { function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }: {
icon: ComponentType<{ className?: string }>;
label: ReactNode;
value: number | null | undefined;
hint?: ReactNode;
valueClassName?: string;
}) {
return ( return (
<div className="min-w-0 rounded-lg border border-border/70 bg-background/70 p-3"> <div className="min-w-0 rounded-lg border border-border/70 bg-background/70 p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -77,7 +201,7 @@ function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) {
); );
} }
function MoneyMap({ summaryData, loading }) { function MoneyMap({ summaryData, loading }: { summaryData: SummaryDataShape | null; loading: boolean }) {
if (loading) { if (loading) {
return ( return (
<Card> <Card>
@ -94,7 +218,7 @@ function MoneyMap({ summaryData, loading }) {
const summary = summaryData?.summary || {}; const summary = summaryData?.summary || {};
const bt = summaryData?.bank_tracking; const bt = summaryData?.bank_tracking;
const bankMode = bt?.enabled === true; const bankMode = bt?.enabled === true;
const available = bankMode ? Number(bt.effective_balance || 0) : Number(starting.combined_amount || 0); const available = bankMode ? Number(bt?.effective_balance || 0) : Number(starting.combined_amount || 0);
const assigned = Number(summary.expense_total || 0); const assigned = Number(summary.expense_total || 0);
const paid = Number(summary.paid_total || 0); const paid = Number(summary.paid_total || 0);
const remaining = Number(summary.result || 0); const remaining = Number(summary.result || 0);
@ -108,7 +232,7 @@ function MoneyMap({ summaryData, loading }) {
<CardTitle className="text-base">Monthly Money Map</CardTitle> <CardTitle className="text-base">Monthly Money Map</CardTitle>
<CardDescription> <CardDescription>
{bankMode {bankMode
? `Live bank balance · ${bt.account_name}` ? `Live bank balance · ${bt?.account_name}`
: 'Available money, extra income, assigned bills, and what remains.'} : 'Available money, extra income, assigned bills, and what remains.'}
</CardDescription> </CardDescription>
</div> </div>
@ -126,15 +250,15 @@ function MoneyMap({ summaryData, loading }) {
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{bankMode ? ( {bankMode ? (
<> <>
<MoneyMetric icon={Banknote} label="Bank Balance" value={Number(bt.balance || 0)} hint={`as of last sync`} /> <MoneyMetric icon={Banknote} label="Bank Balance" value={Number(bt?.balance || 0)} hint={`as of last sync`} />
<MoneyMetric icon={PiggyBank} label="Pending" value={Number(bt.pending_payments || 0)} hint={`paid, not yet cleared (${bt.pending_days}d window)`} valueClassName="text-amber-600 dark:text-amber-400" /> <MoneyMetric icon={PiggyBank} label="Pending" value={Number(bt?.pending_payments || 0)} hint={`paid, not yet cleared (${bt?.pending_days}d window)`} valueClassName="text-amber-600 dark:text-amber-400" />
<MoneyMetric icon={CalendarDays} label="Unpaid Bills" value={Number(bt.unpaid_this_month || 0)} hint={`${summary.expense_count || 0} active bills`} /> <MoneyMetric icon={CalendarDays} label="Unpaid Bills" value={Number(bt?.unpaid_this_month || 0)} hint={`${summary.expense_count || 0} active bills`} />
<MoneyMetric <MoneyMetric
icon={CircleDollarSign} icon={CircleDollarSign}
label="After Bills" label="After Bills"
value={Number(bt.remaining || 0)} value={Number(bt?.remaining || 0)}
hint="effective balance unpaid" hint="effective balance unpaid"
valueClassName={Number(bt.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'} valueClassName={Number(bt?.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
/> />
</> </>
) : ( ) : (
@ -173,7 +297,7 @@ function MoneyMap({ summaryData, loading }) {
{bankMode && ( {bankMode && (
<div className={cn( <div className={cn(
'flex items-center justify-between gap-3 rounded-xl border px-4 py-3', 'flex items-center justify-between gap-3 rounded-xl border px-4 py-3',
Number(bt.remaining || 0) >= 0 Number(bt?.remaining || 0) >= 0
? 'border-emerald-500/25 bg-emerald-500/5' ? 'border-emerald-500/25 bg-emerald-500/5'
: 'border-destructive/25 bg-destructive/5', : 'border-destructive/25 bg-destructive/5',
)}> )}>
@ -182,21 +306,21 @@ function MoneyMap({ summaryData, loading }) {
Projected Month-End Balance Projected Month-End Balance
</p> </p>
<p className="text-[11px] text-muted-foreground/70"> <p className="text-[11px] text-muted-foreground/70">
{fmt(bt.balance || 0)} bank {fmt(bt?.balance || 0)} bank
{Number(bt.pending_payments || 0) > 0 && ` ${fmt(bt.pending_payments)} pending`} {Number(bt?.pending_payments || 0) > 0 && ` ${fmt(bt?.pending_payments)} pending`}
{` ${fmt(bt.unpaid_this_month || 0)} remaining bills`} {` ${fmt(bt?.unpaid_this_month || 0)} remaining bills`}
</p> </p>
</div> </div>
<p className={cn( <p className={cn(
'tracker-number text-2xl font-bold tabular-nums shrink-0', 'tracker-number text-2xl font-bold tabular-nums shrink-0',
Number(bt.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive', Number(bt?.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive',
)}> )}>
{Number(bt.remaining || 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bt.remaining || 0)))} {Number(bt?.remaining || 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bt?.remaining || 0)))}
</p> </p>
</div> </div>
)} )}
{bankMode && bt.last_updated && ( {bankMode && bt?.last_updated && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Balance last updated: {new Date(bt.last_updated).toLocaleString()} Balance last updated: {new Date(bt.last_updated).toLocaleString()}
</p> </p>
@ -206,7 +330,7 @@ function MoneyMap({ summaryData, loading }) {
); );
} }
function SummaryProgress({ summary }) { function SummaryProgress({ summary }: { summary?: SummaryInfo }) {
const percent = Number(summary?.paid_percent || 0); const percent = Number(summary?.paid_percent || 0);
return ( return (
@ -252,7 +376,7 @@ function SummaryProgress({ summary }) {
); );
} }
function DayIndicators({ day, moneyMarker }) { function DayIndicators({ day, moneyMarker }: { day: CalDay; moneyMarker: MoneyMarker | null }) {
const summary = day.status_summary; const summary = day.status_summary;
const hasPaid = summary.paid_count > 0; const hasPaid = summary.paid_count > 0;
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count; const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
@ -271,11 +395,16 @@ function DayIndicators({ day, moneyMarker }) {
); );
} }
function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) { function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }: {
data: CalendarData;
selectedDate?: string;
onSelectDay: (day: CalDay) => void;
moneyMarkers?: Record<string, MoneyMarker>;
}) {
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay(); const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
const cells = [ const cells: ({ type: 'blank'; key: string } | { type: 'day'; key: string; day: CalDay })[] = [
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })), ...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank' as const, key: `blank-${index}` })),
...data.days.map(day => ({ type: 'day', key: day.date, day })), ...data.days.map(day => ({ type: 'day' as const, key: day.date, day })),
]; ];
const today = todayStr(); const today = todayStr();
@ -363,11 +492,24 @@ function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
); );
} }
function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }) { function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }: {
label: ReactNode;
projected: number;
starting?: number;
billsTotal?: number;
paid?: number;
paidCount?: number;
totalCount?: number;
period?: string;
year: number;
month: number;
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const billsTotalN = Number(billsTotal || 0);
const paidN = Number(paid || 0);
const isNegative = projected < 0; const isNegative = projected < 0;
const amountPct = billsTotal > 0 ? Math.min(100, Math.round((paid / billsTotal) * 100)) : 0; const amountPct = billsTotalN > 0 ? Math.min(100, Math.round((paidN / billsTotalN) * 100)) : 0;
const unpaidCount = totalCount - paidCount; const unpaidCount = Number(totalCount || 0) - Number(paidCount || 0);
const bucketParam = period === '1st' ? 'b1=1' : 'b2=1'; const bucketParam = period === '1st' ? 'b1=1' : 'b2=1';
function goToUnpaid() { function goToUnpaid() {
@ -393,7 +535,7 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
<div className="space-y-1.5"> <div className="space-y-1.5">
{/* Amount-based progress bar */} {/* Amount-based progress bar */}
<div className="flex items-center justify-between text-[11px] text-muted-foreground"> <div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{fmt(paid)} of {fmt(billsTotal)} paid</span> <span>{fmt(paidN)} of {fmt(billsTotalN)} paid</span>
{unpaidCount > 0 && ( {unpaidCount > 0 && (
<button <button
type="button" type="button"
@ -404,7 +546,7 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
{unpaidCount} unpaid {unpaidCount} unpaid
</button> </button>
)} )}
{unpaidCount === 0 && totalCount > 0 && ( {unpaidCount === 0 && Number(totalCount || 0) > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">All paid </span> <span className="text-emerald-600 dark:text-emerald-400">All paid </span>
)} )}
</div> </div>
@ -418,14 +560,14 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
/> />
</div> </div>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{fmt(starting)} starting · {fmt(billsTotal)} due {fmt(starting)} starting · {fmt(billsTotalN)} due
</p> </p>
</div> </div>
</div> </div>
); );
} }
function CashFlowCard({ cashflow, year, month }) { function CashFlowCard({ cashflow, year, month }: { cashflow?: Cashflow; year: number; month: number }) {
if (!cashflow?.has_data) return null; if (!cashflow?.has_data) return null;
const periodProjected = Number(cashflow.period_projected ?? 0); const periodProjected = Number(cashflow.period_projected ?? 0);
@ -503,7 +645,7 @@ function CashFlowCard({ cashflow, year, month }) {
); );
} }
function DebtPayoffGlance({ projection }) { function DebtPayoffGlance({ projection }: { projection: SnowballProjection | null }) {
const snowball = projection?.snowball; const snowball = projection?.snowball;
const comparison = projection?.comparison; const comparison = projection?.comparison;
const targetDebt = snowball?.debts?.[0] || null; const targetDebt = snowball?.debts?.[0] || null;
@ -626,7 +768,12 @@ function DebtPayoffGlance({ projection }) {
); );
} }
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) { function DayDetailDialog({ day, open, onOpenChange, moneyMarker }: {
day: CalDay | null;
open: boolean;
onOpenChange: (open: boolean) => void;
moneyMarker: MoneyMarker | null;
}) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl"> <DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
@ -730,7 +877,7 @@ function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
); );
} }
function CalendarSubscribeDialog({ open, onOpenChange }) { function CalendarSubscribeDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-3xl"> <DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-3xl">
@ -758,12 +905,12 @@ export default function CalendarPage() {
const initial = currentMonth(); const initial = currentMonth();
const [year, setYear] = useState(initial.year); const [year, setYear] = useState(initial.year);
const [month, setMonth] = useState(initial.month); const [month, setMonth] = useState(initial.month);
const [data, setData] = useState(null); const [data, setData] = useState<CalendarData | null>(null);
const [summaryData, setSummaryData] = useState(null); const [summaryData, setSummaryData] = useState<SummaryDataShape | null>(null);
const [snowballProjection, setSnowballProjection] = useState(null); const [snowballProjection, setSnowballProjection] = useState<SnowballProjection | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [selectedDay, setSelectedDay] = useState(null); const [selectedDay, setSelectedDay] = useState<CalDay | null>(null);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [calendarFeedOpen, setCalendarFeedOpen] = useState(false); const [calendarFeedOpen, setCalendarFeedOpen] = useState(false);
@ -781,14 +928,14 @@ export default function CalendarPage() {
throw calendarResult.reason; throw calendarResult.reason;
} }
const result = calendarResult.value; const result = calendarResult.value as CalendarData;
setData(result); setData(result);
setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value : null); setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value as SummaryDataShape : null);
setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value : null); setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value as SnowballProjection : null);
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null); setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
} catch (err) { } catch (err) {
setError(err.message || 'Calendar data could not be loaded.'); setError(errMessage(err, 'Calendar data could not be loaded.'));
toast.error(err.message || 'Calendar data could not be loaded.'); toast.error(errMessage(err, 'Calendar data could not be loaded.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -798,11 +945,11 @@ export default function CalendarPage() {
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]); const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0; const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
const moneyMarkers = useMemo(() => { const moneyMarkers = useMemo<Record<string, MoneyMarker>>(() => {
const starting = summaryData?.starting_amounts; const starting = summaryData?.starting_amounts;
if (!starting) return {}; if (!starting) return {};
const markers = {}; const markers: Record<string, MoneyMarker> = {};
const firstAmount = Number(starting.first_amount || 0); const firstAmount = Number(starting.first_amount || 0);
const fifteenthAmount = Number(starting.fifteenth_amount || 0); const fifteenthAmount = Number(starting.fifteenth_amount || 0);
@ -823,7 +970,7 @@ export default function CalendarPage() {
}, [month, summaryData, year]); }, [month, summaryData, year]);
const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null; const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null;
function navigate(delta) { function navigate(delta: number) {
const next = shiftMonth(year, month, delta); const next = shiftMonth(year, month, delta);
setYear(next.year); setYear(next.year);
setMonth(next.month); setMonth(next.month);