From 94030230e70f3bed71980f38a9a27e31aa78dc6e Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 7 Jul 2026 16:26:23 +0200 Subject: [PATCH] fix(attendance): timezone-proof print dates; overnight-only time prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the attendance admin print (Tisk on Správa docházky): 1. Viewer-timezone date shift: day rows are built from date-only strings ('2026-06-01'), which new Date() parses as UTC midnight per spec; local formatting then rendered the PREVIOUS day for any viewer west of UTC — a June print opened with '31. 5. Neděle' (a May row holding June 1's punches) and every weekday name + weekend tint shifted by one. Czech viewers were unaffected (UTC+1/+2), US viewers always hit it. formatDate / getCzechWeekday / isWeekendDate now parse the calendar day from the string parts — identical output in every timezone. 2. Every time cell carried a bogus date prefix ('1.6. 08:00'): the overnight-only guard compared the extracted date part against the raw wire shift_date ('2026-06-01T02:00:00') — never equal. Now compares normalized date parts; the prefix fires only for true overnight punches. Verified end-to-end against the production June payload; +8 regression tests (timezone-proof by construction — parts-based, no UTC parse). Co-Authored-By: Claude Fable 5 --- src/__tests__/attendance-print-tz.test.ts | 77 +++++++++++++++++++++++ src/admin/utils/attendanceHelpers.ts | 23 ++++++- src/admin/utils/formatters.ts | 10 ++- 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/attendance-print-tz.test.ts diff --git a/src/__tests__/attendance-print-tz.test.ts b/src/__tests__/attendance-print-tz.test.ts new file mode 100644 index 0000000..9c1dfcf --- /dev/null +++ b/src/__tests__/attendance-print-tz.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { + getCzechWeekday, + isWeekendDate, + formatTimeOrDatetimePrint, +} from "../admin/utils/attendanceHelpers"; +import { formatDate } from "../admin/utils/formatters"; + +/** + * Regression: the attendance admin print builds its day rows from date-ONLY + * strings ("2026-06-01"). `new Date("YYYY-MM-DD")` is UTC midnight per the + * ECMAScript spec, so formatting it with local getters rendered the PREVIOUS + * day for viewers west of UTC — a June print opened with "31. 5. 2026 Neděle" + * (a May row) and every weekday name was shifted by one. The helpers now + * parse the calendar day from the string parts, which is timezone-proof by + * construction: these assertions hold in EVERY zone (on a machine west of + * UTC the old implementation fails them). + */ +describe("attendance print — timezone-proof date rendering", () => { + it("formatDate renders a date-only string as that exact calendar day", () => { + expect(formatDate("2026-06-01")).toBe("1. 6. 2026"); + expect(formatDate("2026-06-30")).toBe("30. 6. 2026"); + }); + + it("formatDate keeps naive-datetime (wire format) behavior", () => { + expect(formatDate("2026-06-01T02:00:00")).toBe("1. 6. 2026"); + }); + + it("getCzechWeekday names the string's own calendar day", () => { + expect(getCzechWeekday("2026-06-01")).toBe("Pondělí"); + expect(getCzechWeekday("2026-06-06")).toBe("Sobota"); + expect(getCzechWeekday("2026-06-07")).toBe("Neděle"); + // naive-datetime wire format resolves to the same day + expect(getCzechWeekday("2026-06-01T02:00:00")).toBe("Pondělí"); + }); + + it("isWeekendDate flags the string's own calendar day", () => { + expect(isWeekendDate("2026-06-06")).toBe(true); // Saturday + expect(isWeekendDate("2026-06-07")).toBe(true); // Sunday + expect(isWeekendDate("2026-06-01")).toBe(false); // Monday + expect(isWeekendDate("2026-06-06T02:00:00")).toBe(true); + }); + + it("agrees with a locally-constructed date in the current zone (guards a regression to UTC parsing)", () => { + // Local construction is the ground truth for "calendar day" in any zone. + const local = new Date(2026, 5, 1, 12).toLocaleDateString("cs-CZ", { + weekday: "long", + }); + const expected = local.charAt(0).toUpperCase() + local.slice(1); + expect(getCzechWeekday("2026-06-01")).toBe(expected); + }); +}); + +describe("attendance print — overnight date prefix on time cells", () => { + // shift_date arrives as a naive datetime on the wire ("2026-06-01T02:00:00"); + // the guard used to compare it raw against the extracted date part, so the + // overnight-only "d.m." prefix fired on EVERY cell ("1.6. 08:00"). + const wireShiftDate = "2026-06-01T02:00:00"; + + it("same-day punch renders time only", () => { + expect( + formatTimeOrDatetimePrint("2026-06-01T08:00:00", wireShiftDate), + ).toBe("08:00"); + }); + + it("overnight punch keeps the date prefix", () => { + expect( + formatTimeOrDatetimePrint("2026-06-02T01:30:00", wireShiftDate), + ).toBe("2.6. 01:30"); + }); + + it("date-only shiftDate also matches", () => { + expect(formatTimeOrDatetimePrint("2026-06-01T08:00:00", "2026-06-01")).toBe( + "08:00", + ); + }); +}); diff --git a/src/admin/utils/attendanceHelpers.ts b/src/admin/utils/attendanceHelpers.ts index bd23082..b058ddd 100644 --- a/src/admin/utils/attendanceHelpers.ts +++ b/src/admin/utils/attendanceHelpers.ts @@ -173,7 +173,10 @@ export const formatTimeOrDatetimePrint = ( const timeDate = dateMatch ? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}` : ""; - if (timeDate && timeDate !== shiftDate) { + // Compare DATE PARTS: shiftDate arrives as a naive datetime off the wire + // ("2026-06-01T02:00:00"), so a raw string compare never matched and the + // overnight-only date prefix fired on every single time cell. + if (timeDate && timeDate !== normalizeDateStr(shiftDate)) { const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`; return `${datePart} ${extractTime(datetime)}`; } @@ -203,9 +206,23 @@ export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => { // Print template helpers (mzda-style counting) // --------------------------------------------------------------------------- +/** + * Parse the calendar day of a date-only or naive-datetime string as a LOCAL + * Date. `new Date("YYYY-MM-DD")` is UTC midnight per the ECMAScript spec — + * in timezones west of UTC it renders as the PREVIOUS day, so a US viewer's + * June print started with "31. 5. Neděle" and every weekday name was shifted. + * Building the Date from its string parts keeps the calendar day identical + * in every timezone. + */ +const localDayFromString = (dateStr: string): Date => { + const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return new Date(dateStr); +}; + /** Returns the Czech weekday name with the first letter capitalized. */ export const getCzechWeekday = (dateStr: string): string => { - const wd = new Date(dateStr).toLocaleDateString("cs-CZ", { + const wd = localDayFromString(dateStr).toLocaleDateString("cs-CZ", { weekday: "long", }); return wd.charAt(0).toUpperCase() + wd.slice(1); @@ -213,7 +230,7 @@ export const getCzechWeekday = (dateStr: string): string => { /** True if the date is a Saturday or Sunday. */ export const isWeekendDate = (dateStr: string): boolean => { - const dow = new Date(dateStr).getDay(); + const dow = localDayFromString(dateStr).getDay(); return dow === 0 || dow === 6; }; diff --git a/src/admin/utils/formatters.ts b/src/admin/utils/formatters.ts index b59e755..41e0268 100644 --- a/src/admin/utils/formatters.ts +++ b/src/admin/utils/formatters.ts @@ -33,7 +33,15 @@ export function formatMultiCurrency( export function formatDate(dateStr: string | null | undefined): string { if (!dateStr) return "—"; - const d = new Date(dateStr); + // Date-ONLY strings ("2026-06-01") parse as UTC midnight per the spec, which + // renders as the PREVIOUS day in timezones west of UTC — a US viewer's June + // attendance print started with 31. 5. Parse the parts instead so the + // calendar day is identical in every timezone. Datetime strings keep the + // local-parse behavior (naive wire times are already local). + const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})$/); + const d = m + ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + : new Date(dateStr); return d.toLocaleDateString("cs-CZ"); }