From f2b3920cbc4bf62fae2fa01700c08a388a5f493c Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Fri, 18 Sep 2026 01:12:58 +0200 Subject: [PATCH] Share one date formatter, and stop describing files that are gone Two components each carried the same four lines turning a date into an ISO string from local parts. They now share one helper, deliberately separate from the one that formats in UTC: substituting one for the other shifts the day by one anywhere east of Greenwich, Sweden included, so a test pins the difference rather than a comment asking nicely. Creating a period ran its own copy of the sync-and-report sequence purely so it could name a wish the lookup could not yet see; it now passes that wish to the ordinary path instead. Two comments still explained their relationship to components deleted some time ago, which would send the next reader looking for files that are not there, and some styling outlived the markup it described. --- .../src/components/calendar/PresetRail.tsx | 6 +- .../src/components/common/CalendarWeek.tsx | 12 +-- .../src/components/dashboard/PlanFold.tsx | 30 +++--- frontend/src/pages/Calendar.tsx | 98 ++++++++----------- frontend/src/styles/components.css | 4 - frontend/src/styles/responsive.css | 10 -- frontend/src/utils/dates.ts | 19 ++++ frontend/tests/unit/utils/dates.test.ts | 32 +++++- 8 files changed, 112 insertions(+), 99 deletions(-) diff --git a/frontend/src/components/calendar/PresetRail.tsx b/frontend/src/components/calendar/PresetRail.tsx index aed53e5..1838b9f 100644 --- a/frontend/src/components/calendar/PresetRail.tsx +++ b/frontend/src/components/calendar/PresetRail.tsx @@ -13,9 +13,9 @@ export interface SeasonPreset { } /** - * The four season quick-picks, copied from `SEASON_PRESETS` in - * `WishEditor.tsx` (PLANNING_ENGINE_SPEC.md §10.7) — deliberately NOT - * imported from there since that component is owned by another task. + * The four season quick-picks (PLANNING_ENGINE_SPEC.md §10.7). This is the + * sole copy of `SEASON_PRESETS` — the `WishEditor.tsx` component it was + * originally copied from has since been deleted. * Every one of these is a `flexible` wish: the dates below are the bounding * range the allocator searches within for the best-costing placement, not * the final dates. A fixed (exact-dates) period is what dragging on the diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx index e08c634..ae8dea6 100644 --- a/frontend/src/components/common/CalendarWeek.tsx +++ b/frontend/src/components/common/CalendarWeek.tsx @@ -1,7 +1,7 @@ import { ReactNode, useMemo } from 'react' import type { PlannedLeaveDay, CalendarDayEntry } from '@/types' import { getColorForType, getLabelForType } from '@/utils/leaveTypes' -import { getISOWeekNumber } from '@/utils/dates' +import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates' import { useTranslation } from '@/i18n' import { CalendarDay } from './CalendarDay' import { CalendarEvent } from './CalendarEvent' @@ -290,7 +290,7 @@ export function CalendarWeek({ ) } - -function getTodayString(): string { - const today = new Date() - const year = today.getFullYear() - const month = String(today.getMonth() + 1).padStart(2, '0') - const day = String(today.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} diff --git a/frontend/src/components/dashboard/PlanFold.tsx b/frontend/src/components/dashboard/PlanFold.tsx index 1e7a765..31858d2 100644 --- a/frontend/src/components/dashboard/PlanFold.tsx +++ b/frontend/src/components/dashboard/PlanFold.tsx @@ -1,10 +1,8 @@ /** * Folds the wished-vs-funded/surplus/balance content that used to live only - * on the Plan tab's `PlanResults.tsx` into Översikt, collapsed below the - * fold. Read `PlanResults.tsx` for the reference implementation; this is a - * separate, trimmed-down component (not a shared import) because - * `PlanResults.tsx` is scheduled for deletion by another task and must not - * be edited here. + * on the Plan tab's `PlanResults.tsx` (since deleted) into Översikt, + * collapsed below the fold. This is now the sole implementation of that + * content — a trimmed-down component, not a shared import. * * Read-only. The calendar's `POST /api/plan/sync` (see `Calendar.tsx`) is * now the only way a plan is written — it proposes and persists in one @@ -16,14 +14,14 @@ * like a proposal, and `/api/plan/propose` is documented read-only — * "persists nothing, safe to spam" (`backend/app/routers/plan.py`). * - * Dropped relative to `PlanResults.tsx` (kept there / on the Plan tab - * instead, to avoid duplicating content the Hero and `Age4Bars` already - * cover): the warnings section, the age4Projections table, and the - * per-window explanation/remedy detail cards. Also dropped, as of the move - * to `/api/plan/sync`: the apply button, collision-confirmation UI, and - * rejection rendering — applying is no longer a thing this fold does (see - * `frontend/src/utils/rejectionMessages.ts` for where rejection rendering - * now lives, for the sync toast in `Calendar.tsx`). + * Dropped relative to the old `PlanResults.tsx` (content not duplicated here + * since the Hero and `Age4Bars` already cover it): the warnings section, the + * age4Projections table, and the per-window explanation/remedy detail cards. + * Also dropped, as of the move to `/api/plan/sync`: the apply button, + * collision-confirmation UI, and rejection rendering — applying is no + * longer a thing this fold does (see `frontend/src/utils/rejectionMessages.ts` + * for where rejection rendering now lives, for the sync toast in + * `Calendar.tsx`). */ import { ReactNode, useEffect } from 'react' import type { PersonalData, SurplusAdvice, WindowOutcome } from '@/types' @@ -34,9 +32,9 @@ import { resolveWishName } from '@/utils/wishName' type Translate = (key: TranslationKey, params?: TranslationParams) => string -// §9.2 surplus-advice kinds — mirrors `PlanResults.tsx`'s `SURPLUS_ADVICE_KEYS`/ -// `SURPLUS_ADVICE_BODY_KEYS` (the two catalogues are independent copies, not -// a shared import — see the file banner above). +// §9.2 surplus-advice kinds — originally mirrored the deleted `PlanResults.tsx`'s +// `SURPLUS_ADVICE_KEYS`/`SURPLUS_ADVICE_BODY_KEYS`; this is now the sole copy +// (see the file banner above). const SURPLUS_ADVICE_KEYS: Record = { wish_more_leave: 'planResults.surplusAdviceWishMoreLeave', shift_mix_to_fp: 'planResults.surplusAdviceShiftMixToFp', diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index 2c65bfb..2d57964 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -30,7 +30,7 @@ import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calen import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail' import { getColorForType } from '@/utils/leaveTypes' import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName' -import { datesInRange, formatDateForDisplay } from '@/utils/dates' +import { datesInRange, formatDateForDisplay, formatDateLocalISO } from '@/utils/dates' import { renderRejection, type Translate } from '@/utils/rejectionMessages' import { useTranslation } from '@/i18n' import './calendar.css' @@ -285,7 +285,17 @@ export function Calendar(): ReactNode { const runSync = useCallback( async ( workspaceId: string, - options?: { silent?: boolean; undo?: CalendarToast['undo']; watchWishId?: string }, + options?: { + silent?: boolean + undo?: CalendarToast['undo'] + watchWishId?: string + // Used right after creating a wish, where `wishesHook.data` is + // whatever it was as of this closure's own render (captured before + // the create's background refetch lands) and so never actually + // contains a wish created moments ago. Passing the just-created + // wish object directly sidesteps that — see `buildSyncToastMessage`. + watchWish?: LeaveWish + }, ): Promise => { try { const result = await syncHook.sync(workspaceId) @@ -297,6 +307,7 @@ export function Calendar(): ReactNode { membersHook.data, options?.watchWishId, t, + options?.watchWish, ) if (message) showToast(message, options?.undo ?? null) } @@ -319,46 +330,6 @@ export function Calendar(): ReactNode { void runSync(workspace.id, { silent: true }) }, [workspace?.id, runSync]) - // Runs the sync that persists a just-created wish, then reports it and - // wires up undo — split out from a plain `runSync(..., { undo })` call - // because undo here needs to pass the just-created wish's own fields - // (`created`) to `buildSyncToastMessage` rather than looking `wishId` up - // in `wishesHook.data`, which is whatever it was as of this closure's own - // render (captured before the create's background refetch lands) and so - // never actually contains a wish created moments ago. - // - // Undo itself is just "delete the wish, re-sync": the backend deletes - // every `planned_leave_day` row the wish produced as part of deleting the - // wish (`LeaveWishService.delete()`), so there is nothing left here for - // the frontend to individually track or clean up. - const runSyncAfterWishCreate = useCallback( - async (workspaceId: string, created: LeaveWish): Promise => { - const wishId = created.id - const result = await runSync(workspaceId, { silent: true }) - if (!result) return result - - const message = buildSyncToastMessage( - result, - wishesHook.data, - membersHook.data, - wishId, - t, - created, - ) - if (message) { - showToast(message, { - label: t('calendar.undoButton'), - run: async () => { - await wishesHook.remove(wishId) - await runSync(workspaceId, { silent: true }) - }, - }) - } - return result - }, - [runSync, wishesHook, membersHook.data, t, showToast], - ) - const handleCreatePeriodFromDrag = useCallback( async (dates: string[]) => { setSelectedDates(new Set()) @@ -393,7 +364,21 @@ export function Calendar(): ReactNode { parentPersonalDataUuid: personalDataUuid, windowMode: 'fixed', }) - await runSyncAfterWishCreate(workspace.id, created) + // Undo is just "delete the wish, re-sync": the backend deletes every + // `planned_leave_day` row the wish produced as part of deleting the + // wish (`LeaveWishService.delete()`), so there is nothing left here + // for the frontend to individually track or clean up. + await runSync(workspace.id, { + watchWishId: created.id, + watchWish: created, + undo: { + label: t('calendar.undoButton'), + run: async () => { + await wishesHook.remove(created.id) + await runSync(workspace.id, { silent: true }) + }, + }, + }) } catch (err) { showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null) } finally { @@ -406,7 +391,7 @@ export function Calendar(): ReactNode { personalDataUuid, wishesHook, t, - runSyncAfterWishCreate, + runSync, showToast, ], ) @@ -461,11 +446,11 @@ export function Calendar(): ReactNode { for (let i = 0; i < startOffset; i++) { const prevDate = new Date(year, month, -startOffset + i + 1) - currentWeek.push(formatDate(prevDate)) + currentWeek.push(formatDateLocalISO(prevDate)) } for (let day = 1; day <= daysInMonth; day++) { - currentWeek.push(formatDate(new Date(year, month, day))) + currentWeek.push(formatDateLocalISO(new Date(year, month, day))) if (currentWeek.length === 7) { weeks.push(currentWeek) currentWeek = [] @@ -474,7 +459,7 @@ export function Calendar(): ReactNode { const remaining = 7 - currentWeek.length for (let i = 0; i < remaining; i++) { - currentWeek.push(formatDate(new Date(year, month + 1, i + 1))) + currentWeek.push(formatDateLocalISO(new Date(year, month + 1, i + 1))) } weeks.push(currentWeek) @@ -764,7 +749,17 @@ export function Calendar(): ReactNode { windowMode: 'flexible', }) - const result = await runSyncAfterWishCreate(workspace.id, created) + const result = await runSync(workspace.id, { + watchWishId: created.id, + watchWish: created, + undo: { + label: t('calendar.undoButton'), + run: async () => { + await wishesHook.remove(created.id) + await runSync(workspace.id, { silent: true }) + }, + }, + }) if (result) { const placedDates = result.changes @@ -1018,10 +1013,3 @@ export function Calendar(): ReactNode { ) } - -function formatDate(date: Date): string { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 4807cd7..fe7a6ee 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -304,10 +304,6 @@ color: var(--c-danger); } -.plan-fold__success { - color: var(--c-success); -} - /* Button Styles */ button { background-color: var(--c-accent); diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css index b91dec2..0cb8f52 100644 --- a/frontend/src/styles/responsive.css +++ b/frontend/src/styles/responsive.css @@ -142,12 +142,6 @@ font-size: 36px; } - /* Progress chart */ - .progress-chart__stats { - flex-direction: column; - gap: var(--sp-2); - } - /* Calendar responsiveness */ .calendar__grid { font-size: var(--fs-sm); @@ -193,10 +187,6 @@ animation: none; } - .progress-chart__fill { - transition: none; - } - .header__nav-link, .header__logout-button, .header__child-select, diff --git a/frontend/src/utils/dates.ts b/frontend/src/utils/dates.ts index fe66840..4a60e69 100644 --- a/frontend/src/utils/dates.ts +++ b/frontend/src/utils/dates.ts @@ -45,6 +45,25 @@ export function formatDateForInput(date: string | Date): string { return `${year}-${month}-${day}` } +/** + * Format a Date's *local* calendar date as YYYY-MM-DD, using + * getFullYear/getMonth/getDate rather than the UTC getters. + * + * This is deliberately NOT `formatDateForInput`, which uses UTC getters and + * assumes the Date was built from an ISO string at UTC midnight. Callers + * here (the calendar grid) build Dates from local year/month/day components + * (`new Date(year, month, day)`), so reading them back with UTC getters + * would shift the date by one day in any timezone ahead of UTC (Sweden + * included). Do not "consolidate" this with `formatDateForInput` — they + * serve different Date constructions and are not interchangeable. + */ +export function formatDateLocalISO(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + /** * Parse an ISO date string (YYYY-MM-DD) to a Date object. */ diff --git a/frontend/tests/unit/utils/dates.test.ts b/frontend/tests/unit/utils/dates.test.ts index 0b6732d..285fc9b 100644 --- a/frontend/tests/unit/utils/dates.test.ts +++ b/frontend/tests/unit/utils/dates.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach, vi } from 'vitest' import { formatDateForDisplay, formatDateForInput, + formatDateLocalISO, parseInputDate, isValidDate, getDayName, @@ -81,6 +82,35 @@ describe('dates.ts utilities', () => { }) }) + describe('formatDateLocalISO', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('formats a Date built from local year/month/day components', () => { + const date = new Date(2026, 5, 15) // local June 15, 2026 + expect(formatDateLocalISO(date)).toBe('2026-06-15') + }) + + it('pads single-digit month and day', () => { + const date = new Date(2026, 0, 5) // local January 5, 2026 + expect(formatDateLocalISO(date)).toBe('2026-01-05') + }) + + it('reads the local calendar date, not the UTC one — this is the whole point of the function', () => { + // Pacific/Kiritimati is UTC+14. A moment at 23:30 UTC on Jan 1 is + // already local Jan 2 there. `formatDateForInput` (UTC getters) must + // report Jan 1; `formatDateLocalISO` (local getters) must report + // Jan 2. If this ever collapses to the same value, someone has + // swapped in the UTC variant and reintroduced the off-by-one-day bug + // this function exists to avoid. + vi.stubEnv('TZ', 'Pacific/Kiritimati') + const date = new Date(Date.UTC(2026, 0, 1, 23, 30)) + expect(formatDateForInput(date)).toBe('2026-01-01') + expect(formatDateLocalISO(date)).toBe('2026-01-02') + }) + }) + describe('parseInputDate', () => { it('parses ISO date string to Date object', () => { const result = parseInputDate('2026-06-15')