fix(frontend): fix off-by-one date shown in the backfill horizon picker

daysAgoToISODate built the date at local midnight but formatted it with
toISOString(), which converts to UTC first -- in any timezone ahead of
UTC (e.g. Europe/Paris), local midnight is still the previous day in
UTC, so the picker displayed a date one day earlier than the one that
was actually selected/stored. Format from the Date's local
year/month/day components instead of going through UTC.
This commit is contained in:
2026-07-26 21:52:38 +02:00
parent 0211dffa1e
commit d3b26d7d41

View File

@@ -31,6 +31,54 @@ function NumberField({
); );
} }
// Renders/edits BackfillHorizonDays as a calendar date (the day sync should
// backfill through) rather than a raw day count, which is easier to reason
// about than counting days back -- the stored value is still a day count
// (what the backend actually consumes), just presented as its equivalent
// date, recomputed against "today" on every render/change.
function daysAgoToISODate(days: number): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - days);
// Deliberately not toISOString(): that formats in UTC, which in any
// timezone ahead of UTC shifts local midnight back to the previous day.
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function isoDateToDaysAgo(iso: string): number {
const picked = new Date(iso + "T00:00:00");
const today = new Date();
today.setHours(0, 0, 0, 0);
const diffMs = today.getTime() - picked.getTime();
return Math.max(0, Math.round(diffMs / (24 * 60 * 60 * 1000)));
}
function BackfillHorizonField({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
return (
<label>
Past sync (days)
<input
type="date"
value={daysAgoToISODate(value)}
max={daysAgoToISODate(0)}
onChange={(e) => {
if (e.target.value) onChange(isoDateToDaysAgo(e.target.value));
}}
/>
<span className="field-hint">{value} day{value === 1 ? "" : "s"} ago</span>
</label>
);
}
// How long to wait after the last edit before actually saving, so typing a // How long to wait after the last edit before actually saving, so typing a
// name or a multi-digit number doesn't fire one request per keystroke -- // name or a multi-digit number doesn't fire one request per keystroke --
// only the last edit in a burst triggers a save, covering every field // only the last edit in a burst triggers a save, covering every field
@@ -162,8 +210,7 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
onChange={(e) => set("GarminPassword", e.target.value)} onChange={(e) => set("GarminPassword", e.target.value)}
/> />
</label> </label>
<NumberField <BackfillHorizonField
label="Past sync (days)"
value={profile.BackfillHorizonDays} value={profile.BackfillHorizonDays}
onChange={(v) => set("BackfillHorizonDays", v)} onChange={(v) => set("BackfillHorizonDays", v)}
/> />