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"); }