final touches for beta skymoney (at least i think)

This commit is contained in:
2026-01-18 00:00:44 -06:00
parent 4eae966f96
commit f4f0ae5df2
161 changed files with 26016 additions and 1966 deletions

View File

@@ -1,28 +1,48 @@
// web/src/pages/settings/PlansPage.tsx
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import {
useMemo,
useState,
useEffect,
type FormEvent,
type ReactNode,
} from "react";
import { useDashboard } from "../../hooks/useDashboard";
import SettingsNav from "./_SettingsNav";
import { useCreatePlan, useUpdatePlan, useDeletePlan } from "../../hooks/useFixedPlans";
import {
useCreatePlan,
useUpdatePlan,
useDeletePlan,
} from "../../hooks/useFixedPlans";
import { Money } from "../../components/ui";
import { useToast } from "../../components/Toast";
import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone";
function isoDateLocal(d: Date = new Date()) {
const z = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
return z.toISOString().slice(0, 10);
}
function daysUntil(iso: string) {
const today = new Date(isoDateLocal());
const due = new Date(isoDateLocal(new Date(iso)));
const diffMs = due.getTime() - today.getTime();
function daysUntil(iso: string, userTimezone: string) {
const today = getTodayInTimezone(userTimezone);
const due = isoToDateString(iso, userTimezone);
const todayDate = new Date(today);
const dueDate = new Date(due);
const diffMs = dueDate.getTime() - todayDate.getTime();
return Math.round(diffMs / (24 * 60 * 60 * 1000));
}
function DueBadge({ dueISO }: { dueISO: string }) {
const d = daysUntil(dueISO);
if (d < 0) return <span className="badge" style={{ borderColor: "#7f1d1d" }}>Overdue</span>;
function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) {
const d = daysUntil(dueISO, userTimezone);
if (d < 0)
return (
<span
className="badge"
style={{ borderColor: "#7f1d1d" }}
>
Overdue
</span>
);
if (d <= 7) return <span className="badge">Due in {d}d</span>;
return <span className="badge" aria-hidden="true">On track</span>;
return (
<span className="badge" aria-hidden="true">
On track
</span>
);
}
export default function SettingsPlansPage() {
@@ -31,136 +51,490 @@ export default function SettingsPlansPage() {
const updateM = useUpdatePlan();
const deleteM = useDeletePlan();
const { push } = useToast();
// Get user timezone from dashboard data
const userTimezone = data?.user?.timezone || getBrowserTimezone();
// Add form state
const [name, setName] = useState("");
const [total, setTotal] = useState("");
const [funded, setFunded] = useState("");
const [priority, setPriority] = useState("");
const [due, setDue] = useState(isoDateLocal());
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
// Auto-payment form state
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly");
const [dayOfMonth, setDayOfMonth] = useState(1);
const [dayOfWeek, setDayOfWeek] = useState(0);
const [everyNDays, setEveryNDays] = useState(30);
const [minFundingPercent, setMinFundingPercent] = useState(100);
const totals = useMemo(() => {
if (!data) return { funded: 0, total: 0, remaining: 0 };
const funded = data.fixedPlans.reduce((s, p) => s + p.fundedCents, 0);
const total = data.fixedPlans.reduce((s, p) => s + p.totalCents, 0);
return { funded, total, remaining: Math.max(0, total - funded) };
const funded = data.fixedPlans.reduce(
(s, p) => s + p.fundedCents,
0,
);
const total = data.fixedPlans.reduce(
(s, p) => s + p.totalCents,
0,
);
return {
funded,
total,
remaining: Math.max(0, total - funded),
};
}, [data]);
if (isLoading) return <div className="card max-w-3xl"><SettingsNav/><div className="muted">Loading</div></div>;
const overallPctFunded = useMemo(() => {
if (!totals.total) return 0;
return Math.round((totals.funded / totals.total) * 100);
}, [totals.funded, totals.total]);
if (isLoading)
return (
<div className="card max-w-3xl">
<SettingsNav />
<div className="muted">Loading</div>
</div>
);
if (error || !data) {
return (
<div className="card max-w-3xl">
<SettingsNav/>
<p className="mb-3">Couldnt load fixed plans.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
<SettingsNav />
<p className="mb-3">Couldn't load fixed expenses.</p>
<button
className="btn"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
);
}
const onAdd = (e: FormEvent) => {
e.preventDefault();
const totalCents = Math.max(0, Math.round((parseFloat(total || "0")) * 100));
const fundedCents = Math.max(0, Math.round((parseFloat(funded || "0")) * 100));
const totalCents = Math.max(
0,
Math.round((parseFloat(total || "0")) * 100),
);
const fundedCents = Math.max(
0,
Math.round((parseFloat(funded || "0")) * 100),
);
const paymentSchedule = autoPayEnabled ? {
frequency,
...(frequency === "monthly" ? { dayOfMonth } : {}),
...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}),
...(frequency === "custom" ? { everyNDays } : {}),
minFundingPercent,
} : undefined;
const body = {
name: name.trim(),
totalCents,
fundedCents: Math.min(fundedCents, totalCents),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
dueOn: new Date(due).toISOString(),
priority: Math.max(
0,
Math.floor(Number(priority) || 0),
),
dueOn: dateStringToUTCMidnight(due, userTimezone),
autoPayEnabled,
paymentSchedule,
};
if (!body.name || totalCents <= 0) return;
createM.mutate(body, {
onSuccess: () => {
push("ok", "Plan created");
setName(""); setTotal(""); setFunded(""); setPriority(""); setDue(isoDateLocal());
setName("");
setTotal("");
setFunded("");
setPriority("");
setDue(getTodayInTimezone(userTimezone));
setAutoPayEnabled(false);
setFrequency("monthly");
setDayOfMonth(1);
setDayOfWeek(0);
setEveryNDays(30);
setMinFundingPercent(100);
},
onError: (err: any) => push("err", err?.message ?? "Create failed"),
onError: (err: any) =>
push("err", err?.message ?? "Create failed"),
});
};
const onEdit = (id: number, patch: Partial<{ name: string; totalCents: number; fundedCents: number; priority: number; dueOn: string }>) => {
if ("totalCents" in patch && "fundedCents" in patch && (patch.totalCents ?? 0) < (patch.fundedCents ?? 0)) {
const onEdit = (
id: string,
patch: Partial<{
name: string;
totalCents: number;
fundedCents: number;
priority: number;
dueOn: string;
}>,
) => {
if (
"totalCents" in patch &&
"fundedCents" in patch &&
(patch.totalCents ?? 0) < (patch.fundedCents ?? 0)
) {
patch.fundedCents = patch.totalCents;
}
updateM.mutate({ id, body: patch }, {
onSuccess: () => push("ok", "Plan updated"),
onError: (err: any) => push("err", err?.message ?? "Update failed"),
updateM.mutate(
{ id, body: patch },
{
onSuccess: () => push("ok", "Plan updated"),
onError: (err: any) =>
push("err", err?.message ?? "Update failed"),
},
);
};
const onDelete = (id: string) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Plan deleted"),
onError: (err: any) =>
push("err", err?.message ?? "Delete failed"),
});
};
const onDelete = (id: number) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Plan deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed"),
});
};
const addDisabled =
!name || !total || createM.isPending;
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
<SettingsNav />
{/* Header */}
<header className="mb-4 space-y-1">
<h1 className="text-lg font-semibold">
Fixed expenses
</h1>
<p className="text-sm muted">
Long-term goals and obligations youre funding over
time.
</p>
</header>
{/* KPI strip */}
<div className="grid gap-2 sm:grid-cols-3 mb-4">
<div className="card kpi"><h3>Funded</h3><div className="val"><Money cents={totals.funded} /></div></div>
<div className="card kpi"><h3>Total</h3><div className="val"><Money cents={totals.total} /></div></div>
<div className="card kpi"><h3>Remaining</h3><div className="val"><Money cents={totals.remaining} /></div></div>
<div className="grid gap-2 sm:grid-cols-4 mb-4">
<KpiCard label="Funded">
<Money cents={totals.funded} />
</KpiCard>
<KpiCard label="Total">
<Money cents={totals.total} />
</KpiCard>
<KpiCard label="Remaining">
<Money cents={totals.remaining} />
</KpiCard>
<KpiCard label="Overall progress">
<span className="text-xl font-semibold">
{overallPctFunded}%
</span>
</KpiCard>
</div>
{/* Overall progress bar */}
<div className="mb-4 space-y-1">
<div className="row text-xs muted">
<span>All fixed expenses funded</span>
<span className="ml-auto">
{overallPctFunded}% of target
</span>
</div>
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className="h-full bg-[--color-accent]"
style={{
width: `${Math.min(100, overallPctFunded)}%`,
}}
/>
</div>
</div>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-48" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="Total $" type="number" min={0} step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} />
<input className="input w-28" placeholder="Funded $" type="number" min={0} step="0.01" value={funded} onChange={(e) => setFunded(e.target.value)} />
<input className="input w-24" placeholder="Priority" type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<input className="input w-40" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
<button className="btn" disabled={!name || !total || createM.isPending}>Add</button>
<form
onSubmit={onAdd}
className="row gap-2 mb-4 flex-wrap items-end"
>
<input
className="input w-full sm:w-48"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Total $"
type="number"
min={0}
step="0.01"
value={total}
onChange={(e) => setTotal(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Funded $"
type="number"
min={0}
step="0.01"
value={funded}
onChange={(e) => setFunded(e.target.value)}
/>
<input
className="input w-full sm:w-24"
placeholder="Priority"
type="number"
min={0}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
<input
className="input w-full sm:w-40"
type="date"
value={due}
onChange={(e) => setDue(e.target.value)}
/>
<label className="row gap-2 items-center text-sm cursor-pointer px-3 py-2 rounded-lg bg-[--color-panel] w-full sm:w-auto">
<input
type="checkbox"
checked={autoPayEnabled}
onChange={(e) => setAutoPayEnabled(e.target.checked)}
/>
<span>Auto-fund</span>
</label>
<button className="btn w-full sm:w-auto" disabled={addDisabled}>
Add
</button>
</form>
{/* Auto-payment configuration */}
{autoPayEnabled && (
<div className="card bg-[--color-panel] p-4 mb-4">
<h4 className="section-title text-sm mb-3">Auto-Fund Schedule</h4>
<div className="row gap-4 flex-wrap items-end">
<label className="stack text-sm">
<span className="muted text-xs">Frequency</span>
<select
className="input w-32"
value={frequency}
onChange={(e) => setFrequency(e.target.value as any)}
>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
<option value="daily">Daily</option>
<option value="custom">Custom</option>
</select>
</label>
{(frequency === "weekly" || frequency === "biweekly") && (
<label className="stack text-sm">
<span className="muted text-xs">Day of Week</span>
<select
className="input w-32"
value={dayOfWeek}
onChange={(e) => setDayOfWeek(Number(e.target.value))}
>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
</label>
)}
{frequency === "monthly" && (
<label className="stack text-sm">
<span className="muted text-xs">Day of Month</span>
<input
className="input w-24"
type="number"
min="1"
max="31"
value={dayOfMonth}
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
/>
</label>
)}
{frequency === "custom" && (
<label className="stack text-sm">
<span className="muted text-xs">Every N Days</span>
<input
className="input w-24"
type="number"
min="1"
value={everyNDays}
onChange={(e) => setEveryNDays(Number(e.target.value) || 30)}
/>
</label>
)}
<label className="stack text-sm">
<span className="muted text-xs">Min. Funding %</span>
<input
className="input w-24"
type="number"
min="0"
max="100"
value={minFundingPercent}
onChange={(e) => setMinFundingPercent(Math.max(0, Math.min(100, Number(e.target.value) || 0)))}
/>
</label>
</div>
<p className="text-xs muted mt-2">
Automatic payments will only occur if the expense is funded to at least the minimum percentage.
</p>
</div>
)}
{/* Table */}
{data.fixedPlans.length === 0 ? (
<div className="muted text-sm">No fixed plans yet.</div>
<div className="muted text-sm">
No fixed expenses yet.
</div>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Due</th><th>Priority</th>
<th>Funded</th><th>Total</th><th>Remaining</th><th>Status</th><th></th>
<th>Name</th>
<th>Due</th>
<th>Priority</th>
<th>Funded</th>
<th>Total</th>
<th>Remaining</th>
<th>Status</th>
<th>Auto-Pay</th>
<th></th>
</tr>
</thead>
<tbody>
{data.fixedPlans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => {
const remaining = Math.max(0, p.totalCents - p.fundedCents);
.sort(
(a, b) =>
a.priority - b.priority ||
new Date(a.dueOn).getTime() -
new Date(b.dueOn).getTime(),
)
.map((p) => {
const remaining = Math.max(
0,
p.totalCents - p.fundedCents,
);
const pctFunded = p.totalCents
? Math.round(
(p.fundedCents / p.totalCents) * 100,
)
: 0;
return (
<tr key={p.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={p.name} onChange={(v) => onEdit(p.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditDate value={p.dueOn} onChange={(iso) => onEdit(p.id, { dueOn: iso })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={p.priority} min={0} onChange={(n) => onEdit(p.id, { priority: n })} />
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) => onEdit(p.id, { fundedCents: Math.max(0, Math.min(cents, p.totalCents)) })}
<InlineEditText
value={p.name}
onChange={(v) =>
onEdit(p.id, { name: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) => onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min(p.fundedCents, cents) })}
<InlineEditDate
value={p.dueOn}
timezone={userTimezone}
onChange={(iso) =>
onEdit(p.id, { dueOn: iso })
}
/>
</td>
<td className="px-3 py-2"><Money cents={remaining} /></td>
<td className="px-3 py-2"><DueBadge dueISO={p.dueOn} /></td>
<td className="px-3 py-2">
<InlineEditNumber
value={p.priority}
min={0}
onChange={(n) =>
onEdit(p.id, { priority: n })
}
/>
</td>
<td className="px-3 py-2">
<div className="space-y-1">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) =>
onEdit(p.id, {
fundedCents: Math.max(
0,
Math.min(cents, p.totalCents),
),
})
}
/>
<div className="row text-xs muted">
<span>{pctFunded}% funded</span>
</div>
</div>
</td>
<td className="px-3 py-2">
<div className="space-y-1">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) =>
onEdit(p.id, {
totalCents: Math.max(cents, 0),
fundedCents: Math.min(
p.fundedCents,
cents,
),
})
}
/>
<FundingBar
pct={pctFunded}
/>
</div>
</td>
<td className="px-3 py-2">
<Money cents={remaining} />
</td>
<td className="px-3 py-2">
<DueBadge dueISO={p.dueOn} userTimezone={userTimezone} />
</td>
<td className="px-3 py-2">
<div className="flex items-center space-x-2">
<span className={`text-xs px-2 py-1 rounded ${
p.autoPayEnabled
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{p.autoPayEnabled ? 'Enabled' : 'Disabled'}
</span>
{p.autoPayEnabled && p.paymentSchedule && (
<span className="text-xs text-gray-500">
{p.paymentSchedule.frequency === 'custom'
? `Every ${p.paymentSchedule.customDays} days`
: p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1)
}
</span>
)}
</div>
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(p.id)} disabled={deleteM.isPending}>Delete</button>
<button
className="btn"
type="button"
onClick={() => onDelete(p.id)}
disabled={deleteM.isPending}
>
Delete
</button>
</td>
</tr>
);
@@ -173,46 +547,201 @@ export default function SettingsPlansPage() {
);
}
/* --- Inline editors (minimal) --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
/* --- Small presentational helpers --- */
function KpiCard({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div className="card kpi">
<h3>{label}</h3>
<div className="val">{children}</div>
</div>
);
}
function FundingBar({ pct }: { pct: number }) {
const clamped = Math.min(100, Math.max(0, pct));
return (
<div className="h-1.5 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className="h-full bg-[--color-accent]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
/* --- Inline editors (same behavior, slightly nicer UX) --- */
function InlineEditText({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(value);
const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); };
useEffect(() => setV(value), [value]);
const commit = () => {
const t = v.trim();
if (t && t !== value) onChange(t);
setEditing(false);
};
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
<input
className="input"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link inline-flex items-center gap-1"
onClick={() => setEditing(true)}
>
<span>{value}</span>
<span className="text-[10px] opacity-60">Edit</span>
</button>
);
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number }) {
function InlineEditNumber({
value,
onChange,
min = 0,
max = Number.MAX_SAFE_INTEGER,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
const commit = () => { const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0))); if (n !== value) onChange(n); setEditing(false); };
useEffect(() => setV(String(value)), [value]);
const commit = () => {
const n = Math.max(
min,
Math.min(max, Math.floor(Number(v) || 0)),
);
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
<input
className="input w-24 text-right"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{value}
</button>
);
}
function InlineEditMoney({ valueCents, onChange }: { valueCents: number; onChange: (cents: number) => void }) {
function InlineEditMoney({
valueCents,
onChange,
}: {
valueCents: number;
onChange: (cents: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState((valueCents / 100).toFixed(2));
useEffect(
() => setV((valueCents / 100).toFixed(2)),
[valueCents],
);
const commit = () => {
const cents = Math.max(0, Math.round((parseFloat(v || "0")) * 100));
const cents = Math.max(
0,
Math.round((parseFloat(v || "0")) * 100),
);
if (cents !== valueCents) onChange(cents);
setEditing(false);
};
return editing ? (
<input className="input w-28" type="number" step="0.01" min={0} value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{(valueCents/100).toFixed(2)}</button>;
<input
className="input w-28 text-right font-mono"
type="number"
step="0.01"
min={0}
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link font-mono"
onClick={() => setEditing(true)}
>
{(valueCents / 100).toFixed(2)}
</button>
);
}
function InlineEditDate({ value, onChange }: { value: string; onChange: (iso: string) => void }) {
function InlineEditDate({
value,
onChange,
timezone,
}: {
value: string;
onChange: (iso: string) => void;
timezone: string;
}) {
const [editing, setEditing] = useState(false);
const local = new Date(value);
const [v, setV] = useState(local.toISOString().slice(0, 10));
const commit = () => { const iso = new Date(v + "T00:00:00Z").toISOString(); if (iso !== value) onChange(iso); setEditing(false); };
const [v, setV] = useState(
isoToDateString(value, timezone),
);
useEffect(
() =>
setV(
isoToDateString(value, timezone),
),
[value, timezone],
);
const commit = () => {
const iso = dateStringToUTCMidnight(v, timezone);
if (iso !== value) onChange(iso);
setEditing(false);
};
return editing ? (
<input className="input w-40" type="date" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{new Date(value).toLocaleDateString()}</button>;
<input
className="input w-40"
type="date"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{formatDateInTimezone(value, timezone)}
</button>
);
}