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 = { work: "Práce", vacation: "Dovolená", sick: "Nemoc", unpaid: "Neplacené volno", }; return types[type] || "Práce"; }; export const getLeaveTypeBadgeClass = (type: string): string => { const classes: Record = { 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 ""; const d = new Date(datetime); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; }; 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]}` : ""; if (timeDate && timeDate !== 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) // --------------------------------------------------------------------------- /** Returns the Czech weekday name with the first letter capitalized. */ export const getCzechWeekday = (dateStr: string): string => { const wd = new Date(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 = new Date(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(); let 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; };