Files
app/src/admin/utils/attendanceHelpers.ts
BOHA 94030230e7 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>
2026-07-07 16:26:23 +02:00

373 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getHolidays } from "../../utils/czech-holidays";
interface AttendanceRecord {
user_id?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
leave_type?: string;
leave_hours?: number;
shift_date?: string;
notes?: string | null;
project_logs?: Array<{
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}>;
}
// formatDate is defined once in ./formatters and re-exported here so existing
// importers of attendanceHelpers keep working without a duplicate definition.
export { formatDate } from "./formatters";
/** Extract time as HH:MM from a datetime string without timezone conversion */
const extractTime = (datetime: string): string => {
// Try ISO format: "2026-03-23T08:00:00.000Z" or "2026-03-23T08:00:00"
const tMatch = datetime.match(/T(\d{2}):(\d{2})/);
if (tMatch) return `${tMatch[1]}:${tMatch[2]}`;
// Try space format: "2026-03-23 08:00:00"
const sMatch = datetime.match(/\s(\d{2}):(\d{2})/);
if (sMatch) return `${sMatch[1]}:${sMatch[2]}`;
// Fallback: try parsing time-only "08:00"
const hMatch = datetime.match(/^(\d{2}):(\d{2})/);
if (hMatch) return `${hMatch[1]}:${hMatch[2]}`;
return datetime;
};
export const formatDatetime = (datetime: string | null | undefined): string => {
if (!datetime) return "—";
// Extract date part without timezone conversion
const dMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
const datePart = dMatch
? `${parseInt(dMatch[3])}.${parseInt(dMatch[2])}.`
: "";
return `${datePart} ${extractTime(datetime)}`;
};
export const formatTime = (datetime: string | null | undefined): string => {
if (!datetime) return "—";
return extractTime(datetime);
};
export const calculateWorkMinutes = (record: AttendanceRecord): number => {
if (!record.arrival_time || !record.departure_time) return 0;
const arrival = new Date(record.arrival_time).getTime();
const departure = new Date(record.departure_time).getTime();
let minutes = (departure - arrival) / 60000;
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime();
const breakEnd = new Date(record.break_end).getTime();
minutes -= (breakEnd - breakStart) / 60000;
}
return Math.max(0, Math.floor(minutes));
};
export const formatMinutes = (minutes: number, withUnit = false): string => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}:${String(m).padStart(2, "0")}${withUnit ? " h" : ""}`;
};
export const getLeaveTypeName = (type: string): string => {
const types: Record<string, string> = {
work: "Práce",
vacation: "Dovolená",
sick: "Nemoc",
unpaid: "Neplacené volno",
};
return types[type] || "Práce";
};
export const getLeaveTypeBadgeClass = (type: string): string => {
const classes: Record<string, string> = {
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
};
return classes[type] || "";
};
export const getDatePart = (datetime: string | null | undefined): string => {
if (!datetime) return "";
if (datetime.includes("T")) {
return datetime.split("T")[0];
}
return datetime.split(" ")[0];
};
export const getTimePart = (datetime: string | null | undefined): string => {
if (!datetime) return "";
// Parse HH:MM directly from the string (no `new Date(...).getHours()`), so
// the displayed time matches the stored wall-clock time regardless of the
// browser timezone — same ethos as the sibling `extractTime` helper.
return extractTime(datetime);
};
/**
* Join a date input ("YYYY-MM-DD") and a time input ("HH:MM") into the
* combined local-datetime wire format the attendance API expects
* ("YYYY-MM-DDTHH:MM:00", validated server-side by `nullableDateTimeString`
* and parsed with `new Date(...)` as local time). Returns null when either
* part is unset — "no value" for the optional datetime fields.
*/
export const combineDatetime = (date: string, time: string): string | null =>
date && time ? `${date}T${time}:00` : null;
export const calcProjectMinutesTotal = (
logs: Array<{
project_id?: number;
hours?: string | number;
minutes?: string | number;
}>,
): number => {
return logs
.filter((l) => l.project_id)
.reduce((sum, l) => {
return (
sum +
(parseInt(String(l.hours)) || 0) * 60 +
(parseInt(String(l.minutes)) || 0)
);
}, 0);
};
interface ShiftForm {
arrival_time?: string;
departure_time?: string;
arrival_date?: string;
departure_date?: string;
break_start_time?: string;
break_end_time?: string;
break_start_date?: string;
break_end_date?: string;
}
export const calcFormWorkMinutes = (form: ShiftForm): number => {
if (!form.arrival_time || !form.departure_time) return 0;
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`;
const departureStr = `${form.departure_date}T${form.departure_time}`;
let mins =
(new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000;
if (form.break_start_time && form.break_end_time) {
const bsStr = `${form.break_start_date}T${form.break_start_time}`;
const beStr = `${form.break_end_date}T${form.break_end_time}`;
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000;
}
return Math.max(0, Math.floor(mins));
};
export const formatTimeOrDatetimePrint = (
datetime: string | null | undefined,
shiftDate: string,
): string => {
if (!datetime) return "—";
// Extract date from the datetime string directly (no timezone conversion)
const dateMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
const timeDate = dateMatch
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
: "";
// 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)}`;
}
return extractTime(datetime);
};
export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
const leaveType = record.leave_type || "work";
if (leaveType !== "work") {
return (Number(record.leave_hours) || 8) * 60;
}
if (!record.arrival_time || !record.departure_time) return 0;
const arrival = new Date(record.arrival_time).getTime();
const departure = new Date(record.departure_time).getTime();
let minutes = (departure - arrival) / 60000;
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime();
const breakEnd = new Date(record.break_end).getTime();
minutes -= (breakEnd - breakStart) / 60000;
}
return Math.max(0, Math.floor(minutes));
};
// ---------------------------------------------------------------------------
// 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 = localDayFromString(dateStr).toLocaleDateString("cs-CZ", {
weekday: "long",
});
return wd.charAt(0).toUpperCase() + wd.slice(1);
};
/** True if the date is a Saturday or Sunday. */
export const isWeekendDate = (dateStr: string): boolean => {
const dow = localDayFromString(dateStr).getDay();
return dow === 0 || dow === 6;
};
/** Number of days in a month (1-12). e.g. May 2026 → 31. */
export const getDaysInMonth = (year: number, month1to12: number): number => {
return new Date(year, month1to12, 0).getDate();
};
/** Build a "YYYY-MM-DD" string for a given year/month/day. */
export const shiftDateForMonth = (
year: number,
month1to12: number,
day: number,
): string => {
return `${year}-${String(month1to12).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
/** Format minutes as decimal hours, comma separator, always 2 decimals. */
export const formatHoursDecimal = (minutes: number): string => {
return ((minutes / 60).toFixed(2) as string).replace(".", ",");
};
/** Format minutes as decimal hours, comma separator, 1 decimal. */
export const formatHoursDecimal1 = (minutes: number): string => {
return ((minutes / 60).toFixed(1) as string).replace(".", ",");
};
/**
* Compute minutes that fall within the 22:00-06:00 night window (Czech law §94).
*
* Algorithm (per Czech spec):
* 1. Subtract all break intervals from the shift interval → list of worked
* sub-intervals.
* 2. For each worked sub-interval, compute its intersection with the
* 22:00-06:00 night window. Iterate from (sub-interval start day - 1)
* to (sub-interval end day + 1) to be safe with cross-midnight shifts.
* 3. Sum all overlaps in minutes.
*/
export const calculateNightMinutes = (record: AttendanceRecord): number => {
if (!record.arrival_time || !record.departure_time) return 0;
const startMs = new Date(record.arrival_time).getTime();
let endMs = new Date(record.departure_time).getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0;
if (endMs <= startMs) endMs += 24 * 60 * 60 * 1000;
// STEP 1: subtract breaks from the shift interval.
let intervals: Array<[number, number]> = [[startMs, endMs]];
if (record.break_start && record.break_end) {
const bStart = new Date(record.break_start).getTime();
const bEnd = new Date(record.break_end).getTime();
if (Number.isFinite(bStart) && Number.isFinite(bEnd) && bEnd > bStart) {
const next: Array<[number, number]> = [];
for (const [s, e] of intervals) {
if (bEnd <= s || bStart >= e) {
next.push([s, e]);
} else {
if (bStart > s) next.push([s, bStart]);
if (bEnd < e) next.push([bEnd, e]);
}
}
intervals = next;
}
}
// Helper: midnight of the local day for a timestamp.
const dayMidnight = (ms: number): number => {
const d = new Date(ms);
return new Date(
d.getFullYear(),
d.getMonth(),
d.getDate(),
0,
0,
0,
0,
).getTime();
};
// STEP 2: for each worked sub-interval, intersect with every relevant
// 22:00-06:00 night window. A 24h shift touches at most 2 windows, but
// we iterate (startDay 1) to (endDay + 1) to be defensive.
let totalMs = 0;
for (const [s, e] of intervals) {
const fromDay = dayMidnight(s) - 24 * 60 * 60 * 1000;
const toDay = dayMidnight(e) + 24 * 60 * 60 * 1000;
for (let day = fromDay; day <= toDay; day += 24 * 60 * 60 * 1000) {
const winStart = day + 22 * 60 * 60 * 1000;
const winEnd = day + 30 * 60 * 60 * 1000; // 06:00 next day
const ov = Math.max(0, Math.min(e, winEnd) - Math.max(s, winStart));
totalMs += ov;
}
}
return Math.floor(totalMs / 60000);
};
/**
* Normalize a date string to "YYYY-MM-DD".
* Accepts both "2026-05-15" and "2026-05-15T00:00:00" / "2026-05-15T12:00:00"
* and returns just the date part.
*/
export const normalizeDateStr = (
dateStr: string | null | undefined,
): string => {
if (!dateStr) return "";
const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? `${m[1]}-${m[2]}-${m[3]}` : "";
};
/** All Czech public holidays in a given month (1-12) as YYYY-MM-DD. */
export const holidaysInMonth = (year: number, month1to12: number): string[] => {
const prefix = `${year}-${String(month1to12).padStart(2, "0")}-`;
return getHolidays(year).filter((d) => d.startsWith(prefix));
};
/**
* Hours the user "got for free" because they didn't work on a holiday.
* Returns 8h for each holiday in the month that has no work record.
* Leave records (vacation, sick, holiday, unpaid) on a holiday date do NOT
* count as work — the user must have an actual work shift to forfeit the
* 100% bonus.
*/
export const calculateFreeHolidayHours = (
records: AttendanceRecord[],
holidayDates: string[],
): number => {
const workedDates = new Set(
records
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let free = 0;
for (const hd of holidayDates) {
if (!workedDates.has(hd)) free += 8;
}
return free;
};