Files
app/src/utils/czech-holidays.ts
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:26 +02:00

110 lines
3.8 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.
/**
* Czech public holidays & work fund calculator.
* Port of PHP CzechHolidays class.
*/
import { localDateStr } from "./date";
const holidayCache = new Map<number, string[]>();
/** Easter Sunday using the Anonymous Gregorian algorithm */
function getEasterSunday(year: number): string {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** All Czech public holidays for a year (11 fixed + 2 Easter-based) */
export function getHolidays(year: number): string[] {
if (holidayCache.has(year)) return holidayCache.get(year)!;
const y = String(year);
const holidays = [
`${y}-01-01`, // New Year's / Restoration of Czech Independence
`${y}-05-01`, // Labour Day
`${y}-05-08`, // Victory Day
`${y}-07-05`, // Saints Cyril and Methodius Day
`${y}-07-06`, // Jan Hus Day
`${y}-09-28`, // Czech Statehood Day
`${y}-10-28`, // Czechoslovak Independence Day
`${y}-11-17`, // Freedom and Democracy Day
`${y}-12-24`, // Christmas Eve
`${y}-12-25`, // Christmas Day
`${y}-12-26`, // St. Stephen's Day
];
// Easter-based.
// TZ NOTE (audit finding addressed): `new Date("YYYY-MM-DD")` parses as UTC
// midnight, but the ±2/±1 day shifts below mutate via the LOCAL-time setters
// (setDate/getDate) and `localDateStr` reads back in local time. This is only
// correct because the app forces `process.env.TZ = "Europe/Prague"` (a
// positive UTC offset) in src/config/env.ts — UTC-midnight resolves to the
// same local calendar day, so Good Friday/Easter Monday land on the right
// dates. Under a NEGATIVE-offset timezone this would read one day early. Safe
// given the forced Prague TZ; do not "fix" by mixing UTC and local accessors.
const easterSunday = getEasterSunday(year);
const easterDate = new Date(easterSunday);
const goodFriday = new Date(easterDate);
goodFriday.setDate(goodFriday.getDate() - 2);
const easterMonday = new Date(easterDate);
easterMonday.setDate(easterMonday.getDate() + 1);
holidays.push(localDateStr(goodFriday)); // Good Friday
holidays.push(localDateStr(easterMonday)); // Easter Monday
holidays.sort();
holidayCache.set(year, holidays);
return holidays;
}
/** Check if a date string (YYYY-MM-DD) is a Czech public holiday */
export function isHoliday(dateStr: string): boolean {
const year = parseInt(dateStr.substring(0, 4), 10);
return getHolidays(year).includes(dateStr);
}
/** Business days in a month (Mon-Fri excluding public holidays) */
export function getBusinessDaysInMonth(
year: number,
month: number,
upToDay?: number,
): number {
const holidays = getHolidays(year);
let count = 0;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const maxDay = upToDay ? Math.min(upToDay, daysInMonth) : daysInMonth;
for (let day = 1; day <= maxDay; day++) {
const date = new Date(year, month, day);
const dow = date.getDay();
if (dow !== 0 && dow !== 6) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
if (!holidays.includes(dateStr)) {
count++;
}
}
}
return count;
}
/** Monthly work fund in hours (business days × 8) */
export function getMonthlyWorkFund(
year: number,
month: number,
upToDay?: number,
): number {
return getBusinessDaysInMonth(year, month, upToDay) * 8;
}