Files
app/src/admin/utils/attendanceHelpers.ts
BOHA 1826fc7976 fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup
Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md

- attendance: schemas accept the combined local datetimes the forms/service
  use (new dateTimeString helpers in schemas/common.ts), breaks persist on
  create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
  enrollment QR generated locally via qrcode (CSP-blocked external service
  also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
  coercion in formatters.ts), billing_text persists on update, issued-order
  status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
  (shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
  PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
  escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
  downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
  longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
  works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
  @hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
  the Prisma 6 downgrade), drop deprecated @types stubs

Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:59:47 +02:00

356 lines
12 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]}`
: "";
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();
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;
};