Files
alpenwerk-hr/tests/unit/format.test.ts
Maximilian Stubhan 79f0e19bf8 Org assignment history, mobile support, and a correctness pass
Data model
- employee_assignments records org placement over time (valid_from/valid_to),
  written by a trigger on `employees` rather than inside each RPC: ~70
  `update employees` statements spread over fifteen migrations mean per-call
  bookkeeping would miss paths today and again with every future RPC. A
  partial unique index enforces the one-open-interval invariant the trigger
  relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
  entry/exit/karenz, past placement from the new history, future placement
  projected from pending_org_changes. Placements predating the migration are
  backfilled with today's values and flagged as such in the UI, since
  employee_history only ever stored free text and cannot be reconstructed.

Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
  (db.max_rows); employee_history is already past it at ~800 staff. Every
  whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
  serial straight off getTime(), so a Date built at local midnight lands on
  the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
  are formatted without a Date round-trip. The dashboard's YTD window was
  built by round-tripping a local Date through toISOString(), which shifted
  it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
  casts, so an unknown value reached column headers as `undefined` and the
  Content-Disposition filename. Parsed against the label maps now, with the
  filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
  two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
  a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
  text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
  the whole node and the expand control stops responding.

UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
  content off-screen with no mobile navigation at all. The sidebar is now a
  drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
  iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
  roles called out, expand control moved to the bottom edge carrying the
  child count.
- Pagination is windowed; it previously rendered one link per page (54 for
  the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
  person, reusing the chart's existing search-match highlighting.

Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
  validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.
2026-07-24 23:38:10 +02:00

128 lines
4.5 KiB
TypeScript
Raw Permalink 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 { describe, expect, it } from "vitest";
import { addDaysIso, daysBetweenIso, fmtAge, fmtDate, initials, tenure, toIsoDate, yearsBetweenIso } from "@/lib/format";
describe("fmtDate", () => {
it("formats an ISO date string in de-AT order", () => {
expect(fmtDate("2026-03-05")).toBe("05.03.2026");
});
// A date-only column has no time and no zone. Routing it through a Date
// would anchor it to UTC midnight and render the previous day wherever the
// renderer sits west of UTC — including a server/browser hydration split.
it("never shifts a date-only string across a day boundary", () => {
expect(fmtDate("2026-01-01")).toBe("01.01.2026");
expect(fmtDate("2026-12-31")).toBe("31.12.2026");
});
it("renders a timestamp in Vienna time regardless of the runtime zone", () => {
// 23:30 UTC on 5 March is already 6 March in Vienna (UTC+1 before the
// DST switch); the same instant is still 5 March in UTC.
expect(fmtDate("2026-03-05T23:30:00Z")).toBe("06.03.2026");
});
it("returns an em dash for null/undefined/empty input", () => {
expect(fmtDate(null)).toBe("");
expect(fmtDate(undefined)).toBe("");
expect(fmtDate("")).toBe("");
});
it("returns an em dash for an invalid date string", () => {
expect(fmtDate("not-a-date")).toBe("");
});
});
describe("initials", () => {
it("uppercases the first letter of each name", () => {
expect(initials("maria", "gruber")).toBe("MG");
});
it("trims surrounding whitespace before taking the first letter", () => {
expect(initials(" Anna", "Huber ")).toBe("AH");
});
});
describe("fmtAge", () => {
it("computes age correctly when the birthday has already passed this year", () => {
const today = new Date();
const birthDate = new Date(today.getFullYear() - 30, 0, 1); // Jan 1, definitely passed
expect(fmtAge(birthDate)).toBe(30);
});
it("subtracts one year when the birthday has not yet occurred this year", () => {
const today = new Date();
const future = new Date(today.getFullYear() - 30, 11, 31); // Dec 31, likely not yet passed
if (today.getMonth() === 11 && today.getDate() === 31) return; // skip on the one day this'd be flaky
expect(fmtAge(future)).toBe(29);
});
});
describe("tenure", () => {
it("formats whole years and months since entry", () => {
const start = new Date(2020, 0, 15);
const end = new Date(2023, 2, 15); // exactly 3 years, 2 months later
expect(tenure(start, end)).toBe("3 Jahre, 2 Monate");
});
it("uses singular Jahr/Monat for exactly 1", () => {
const start = new Date(2020, 0, 1);
const end = new Date(2021, 1, 1); // 1 year, 1 month
expect(tenure(start, end)).toBe("1 Jahr, 1 Monat");
});
it("falls back to 'unter 1 Monat' for less than a month", () => {
const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 15);
expect(tenure(start, end)).toBe("unter 1 Monat");
});
it("defaults the end date to today when no end date is given", () => {
const start = new Date();
expect(tenure(start)).toBe("unter 1 Monat");
});
});
describe("daysBetweenIso", () => {
it("computes whole days between two dates", () => {
expect(daysBetweenIso("2026-01-01", "2026-01-11")).toBe(10);
});
it("returns a negative number when the second date precedes the first", () => {
expect(daysBetweenIso("2026-01-11", "2026-01-01")).toBe(-10);
});
// Both ends are anchored at UTC midnight, so the DST switch in between
// cannot turn 30 calendar days into 29.96 and round down.
it("is exact across a DST transition", () => {
expect(daysBetweenIso("2026-03-15", "2026-04-15")).toBe(31);
expect(daysBetweenIso("2026-10-15", "2026-11-15")).toBe(31);
});
});
describe("addDaysIso", () => {
it("rolls over month and year boundaries", () => {
expect(addDaysIso("2026-12-31", 1)).toBe("2027-01-01");
expect(addDaysIso("2026-01-31", 1)).toBe("2026-02-01");
});
it("handles a leap day", () => {
expect(addDaysIso("2028-02-28", 1)).toBe("2028-02-29");
});
});
describe("yearsBetweenIso", () => {
it("counts only completed years", () => {
expect(yearsBetweenIso("1990-05-15", "2026-05-15")).toBe(36);
expect(yearsBetweenIso("1990-05-15", "2026-05-14")).toBe(35);
});
});
describe("toIsoDate", () => {
it("passes a date-only string through untouched", () => {
expect(toIsoDate("2026-01-01")).toBe("2026-01-01");
});
it("resolves a timestamp to the Vienna calendar day", () => {
expect(toIsoDate("2026-03-05T23:30:00Z")).toBe("2026-03-06");
});
});