Compare commits
2 Commits
d859b5bbf7
...
89656ac2c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89656ac2c6 | ||
|
|
94030230e7 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.42",
|
"version": "2.4.43",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.42",
|
"version": "2.4.43",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.42",
|
"version": "2.4.43",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
77
src/__tests__/attendance-print-tz.test.ts
Normal file
77
src/__tests__/attendance-print-tz.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -173,7 +173,10 @@ export const formatTimeOrDatetimePrint = (
|
|||||||
const timeDate = dateMatch
|
const timeDate = dateMatch
|
||||||
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
|
? `${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])}.`;
|
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`;
|
||||||
return `${datePart} ${extractTime(datetime)}`;
|
return `${datePart} ${extractTime(datetime)}`;
|
||||||
}
|
}
|
||||||
@@ -203,9 +206,23 @@ export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
|
|||||||
// Print template helpers (mzda-style counting)
|
// 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. */
|
/** Returns the Czech weekday name with the first letter capitalized. */
|
||||||
export const getCzechWeekday = (dateStr: string): string => {
|
export const getCzechWeekday = (dateStr: string): string => {
|
||||||
const wd = new Date(dateStr).toLocaleDateString("cs-CZ", {
|
const wd = localDayFromString(dateStr).toLocaleDateString("cs-CZ", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
});
|
});
|
||||||
return wd.charAt(0).toUpperCase() + wd.slice(1);
|
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. */
|
/** True if the date is a Saturday or Sunday. */
|
||||||
export const isWeekendDate = (dateStr: string): boolean => {
|
export const isWeekendDate = (dateStr: string): boolean => {
|
||||||
const dow = new Date(dateStr).getDay();
|
const dow = localDayFromString(dateStr).getDay();
|
||||||
return dow === 0 || dow === 6;
|
return dow === 0 || dow === 6;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ export function formatMultiCurrency(
|
|||||||
|
|
||||||
export function formatDate(dateStr: string | null | undefined): string {
|
export function formatDate(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return "—";
|
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");
|
return d.toLocaleDateString("cs-CZ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user