fix(attendance): timezone-proof print dates; overnight-only time prefixes

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 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-07-07 16:26:23 +02:00
parent d859b5bbf7
commit 94030230e7
3 changed files with 106 additions and 4 deletions

View File

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

View File

@@ -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;
};

View File

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