Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der
Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der
Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei
neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben
beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert
— als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das
Dropdown im Formular schraenkt die Auswahl darunter ein.
Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes
Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite
zuzuschlagen wuerde jede Fluktuationsquote verfaelschen.
Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen.
Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg;
"Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines
zusteht, nicht ob es uebergeben wurde.
Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren
vorher nur ueber den Export erreichbar. Stammdatenaenderung und
Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder
geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das
Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt,
Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export.
Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht
dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet,
hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste
man selbst.
Die Liste filterte ueber die Datumsspalten, beschriftete die Zeilen aber mit
employees.status. Sobald die Spalte nachhaengt, widersprechen sich die
beiden — und sie haengt regelmaessig nach: terminate_employee setzt sie nur,
wenn das Austrittsdatum nicht in der Zukunft liegt, und es gibt keinen Lauf,
der das spaeter nachzieht (Migration 20260814100000 sagt das selbst).
Beim Kunden waren beide Richtungen zu sehen. Der Filter "Ausgetreten" fand
48 Personen, von denen mehrere als "Aktiv" beschriftet waren; der Filter
"Geplant" zeigte Nichtantritte, deren Spalte laengst "Ausgetreten" trug.
StatusChip nimmt deshalb jetzt die Zeile und den Stichtag und leitet selbst
ab. Die Spalte laesst sich nicht mehr hineinreichen — die zweite Quelle ist
nicht bloss ungenutzt, es gibt sie an dieser Stelle nicht mehr.
Dazu drei Stellen, die an derselben Spalte hingen:
* Die Akte entschied mit ihr ueber die Knoepfe. An einer Person, die seit
zwei Wochen ausgetreten ist, stand "Austritt" weiter zur Verfuegung.
* Die Sortierung nach Status ordnete nach einem Wert, der nirgends auf der
Seite steht.
* Die Karte "Anstehend" zaehlte kuenftige Eintritte und Rueckkehren ueber
die Spalte und damit anders als die Liste, auf die sie verlinkt.
Und eine Klausel, die in der Ableitung fehlte: ein Nichtantritt traegt als
Austrittsdatum den Eintrittstag. Liegt der in der Zukunft, ist auch der
Austritt groesser als der Stichtag — die vorige Korrektur verglich nur gegen
den Stichtag und blieb damit wirkungslos. Endet ein Verhaeltnis nicht
spaeter, als es beginnt, gab es keinen Tag Beschaeftigung, zu keinem
Stichtag.
In "Letzte Aktivitaeten" stand "Eintritt" weiter auf Rosa, waehrend die Karte
daneben ihn laengst gruen zeigte. Dieselbe Ursache wie bei den
Anstehend-Chips, nur eine Ecke weiter: die Uebersicht zeigt Ereignisse aus
employee_history, holte ihre Farbe aber aus ACTION_CATEGORY — und das ist die
Sprache des Protokolls. Dort heisst es "Neueinstellung" und
"Wiedereinstellung", in der Historie "Eintritt" und "Wiedereintritt". Genau
diese zwei von elf standen nicht darin und fielen auf den neutralen Chip
zurueck; die uebrigen neun trafen zufaellig.
EVENT_CATEGORY ist jetzt die Zuordnung fuer die Historie, als
Record<HistoryEventType, …> und damit vollzaehlig: ein zwoelftes Ereignis
laesst der Typpruefer nicht durch, ohne dass jemand eine Farbe dafuer
bestimmt. Ein Nachschlagen mit Rueckfall haette auch dann wieder still etwas
Plausibles geliefert.
Betroffen war nicht nur die Uebersicht — der Historie-Reiter in der
Personalakte faerbte seine Chips und seine Filterknoepfe aus derselben
falschen Tabelle. Auch die sind umgestellt.
Die Punkte vor den Zeilen lagen in einer zweiten Tabelle in page.tsx und
sagten fuer "Eintritt" bereits gruen — Punkt und Chip derselben Zeile kamen
also aus zwei Verzeichnissen, von denen eines das falsche war. Beide leiten
jetzt aus EVENT_CATEGORY ab.
Die Rueckkehr ist dabei violett geworden, auch in der Historie: auf der
Uebersicht steht sie neben dem Eintritt, und zwei Gruentoene nebeneinander
sind keine zwei Dinge. ANSTEHEND_STYLES leitet fuer Eintritt, Austritt und
Rueckkehr aus derselben Tabelle ab — die beiden Karten koennen nicht mehr
auseinanderlaufen.
ACTION_CATEGORY behaelt seinen Rueckfall, und das bleibt richtig: die
Aktionen schreiben die SQL-Funktionen als freien Text, eine neue kann
jederzeit dazukommen, und ihr neutraler Chip ist dann eine ehrliche Aussage.
Fuer eine geschlossene Aufzaehlung war derselbe Rueckfall ein Fehler.
Acht Tests, aus EVENT_TYPE_LABELS abgeleitet statt abgeschrieben: dass jedes
Ereignis eine Farbe hat, dass keines den neutralen Chip bekommt, dass Punkt
und Chip derselben Zeile zusammenpassen und dass die beiden Karten der
Uebersicht dasselbe meinen.
Lint, Typen, Schemaabgleich, 575 Tests und der Build sind sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`curl -sI` auf /brand/manner-logo.png antwortete mit **307**. Die Datei lag
die ganze Zeit da, ausgeliefert wurde sie nie: der Abgleich in proxy.ts
nimmt alles ausser _next/static, _next/image und favicon.ico, und public/
stand nicht darin. Eine Anfrage nach dem Logo lief also in den Gate, fand
keine Sitzung und bekam eine Umleitung nach /login. Auf ein <img> antwortet
der Server damit mit HTML, und der Browser zeigt den Ersatztext.
Das erklaert auch, warum es so sprunghaft aussah. Auffallen konnte es nur an
einer einzigen Stelle: die Anmeldeseite ist die einzige Seite, die jemand
ohne Sitzung zu sehen bekommt — und ausgerechnet dort steht das Logo.
Ueberall sonst ist man angemeldet, der Gate laesst das Bild durch. Einmal so
geladen liegt es im Zwischenspeicher und erscheint auf /login weiter, bis
jemand hart neu laedt.
Und es erklaert, warum keine der Aenderungen am Bild geholfen hat: weder das
Ausmisten des SVG noch der Wechsel auf ein PNG konnte etwas ausrichten, weil
die Datei den Browser gar nicht erreichte. Dass es „seit 8016d31" auftrat,
war eine Verwechslung von Ursache und Gelegenheit — dort bekam die
Anmeldeseite ihre rosa Flaeche und damit ueberhaupt erst ein sichtbares Logo
an einer Stelle ohne Sitzung.
Aufgenommen wird jetzt, was eine nicht angemeldete Person auf der
Anmeldeseite braucht: brand/ sowie icon.svg, apple-icon.png und
manifest.webmanifest, die der Browser von sich aus holt. Einzeln aufgezaehlt
und nicht als Regel ueber Dateiendungen — „alles mit einem Punkt darin"
haette auch Routen durchgelassen, die keine Datei sind. In public/ liegt
nichts Personenbezogenes und darf auch nie etwas liegen; alles, was Daten
fuehrt, geht durch withUser() und die Policies.
Fuenf Tests auf den Abgleich selbst. Die vorhandenen rufen proxy()
unmittelbar auf und gehen damit am matcher vorbei — ein Fehler dort war von
ihnen nicht zu sehen. Geprueft wird beides: dass die Dateien vorbeikommen,
und dass sonst nichts aufgeht, auch nicht ueber einen aehnlich aussehenden
Pfad wie /brandneu.
Lint, Typen, Schemaabgleich, 567 Tests und der Build sind sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Handaenderungen hatten das Bauteil halb umgebaut: `aufRosa` war aus der
Signatur von MannerLogo verschwunden, wurde im Rumpf aber weiter benutzt
(erster Build: "Cannot find name"), und nach dem Nachbessern reichte
AppWortmarke die Eigenschaft weiter, die es nicht mehr gab (zweiter Build:
"Property 'aufRosa' does not exist"). Beide Male scheiterte der Typcheck im
Container, und damit blieb der Stand von d2d5e1d unausgeliefert.
Wiederhergestellt ist genau dieser Stand. `aufRosa` wird gebraucht: auf einer
rosa Flaeche darf das Logo kein eigenes Feld bekommen, sonst liegt wieder ein
Rechteck darauf — auf dem Panel der Anmeldeseite waere es das Raster, das
unter dem Feld endet.
Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Das Logo auf der Anmeldeseite.** Statt des Schriftzugs stand dort der
Ersatztext "Manner". Die SVG-Fassung war gueltiges XML, lag im Repository und
war committet — warum sie im Betrieb nicht geladen wurde, laesst sich von hier
aus nicht feststellen; dafuer braucht es die Antwort des Servers auf die URL.
Statt das weiter zu raten, faellt die Angriffsflaeche weg: es gibt jetzt eine
Datei, manner-logo.png, der Schriftzug mit durchsichtigem Grund. Kein XML, kein
Beschnitt, kein eingebackenes Feld. Das Blau darin ist #164194, also der Wert
aus §1.1.
Damit verschwinden auch die zwei Fassungen. §1.2 laesst den Schriftzug nur zur
Gaenze auf Rosa zu, und das Manual kennt dafuer zwei Lagen: auf Weiss gehoert
er nach §4.1 in ein rechteckiges rosa Feld, in einem grossflaechigen rosa
Umfeld nach §3.1 nur mit Freiraum. Beides entsteht jetzt aus derselben Datei —
das Feld zeichnet das Bauteil, aus derselben Polsterung wie den Freiraum.
**Der Filter "Geplant" fand Ausgetretene.** Die Ableitung pruefte den Eintritt
vor dem Austritt, und wer einen Eintritt in der Zukunft hatte, galt als
geplant — auch wenn der Austritt laengst verbucht war. Das trifft genau den
No-Show (Migration 20260814100000): eingestellt, nie erschienen, Austritt vor
dem Eintrittstag. Im Bestand sind das Zeilen mit Eintritt 01.10.2026, die der
Filter mitzaehlte, waehrend die Liste daneben "Ausgetreten" anzeigte.
employees.status, das die SQL-Funktion beim Austritt setzt, sagte von Anfang
an das Richtige; falsch war die Ableitung in der Anwendung.
Ein abgeschlossener Austritt wird jetzt zuerst geprueft: er beendet das
Verhaeltnis, gleichgueltig ob der Eintritt schon war oder noch kommt. Ein
Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht zurueck — wer
am 01.10. anfaengt und am 31.12. aufhoert, ist heute geplant. Die SQL-Fassung
in lib/employee-status-filter.ts bildet dieselbe Reihenfolge ab.
Keine Migration: beide Fassungen der Regel liegen in TypeScript. In SQL wird
nur der Karenz-Teil wiederholt, fuer die Fuehrungslinie, und der ist nicht
betroffen.
**Die vier Anstehend-Chips.** Zwei davon standen in der Markenfarbe, weil die
Farbe ueber den Beschriftungstext aus der Tabelle der Protokoll-Aktionen
geholt wurde — und die kennt eine andere Sprache: "Neueinstellung", nicht
"Eintritt". Wer dort nicht steht, bekam den neutralen Chip. Ein Nachschlagen,
das bei einem Fehlschlag still etwas Plausibles liefert, faellt eben nicht auf.
Die vier haben jetzt eine eigene Zuordnung, nach dem Wert verschluesselt und
nicht nach der Beschriftung: Eintritt gruen, Austritt rot, Wiedervorlage gelb,
Rueckkehr violett. Tuerkis waere fuer die Rueckkehr die naheliegendere Lesart
gewesen, kam gegen das Gruen des Eintritts aber nur auf dE 13.0; Violett steht
mit 30.8 eindeutig daneben. Schwaechstes Paar der vier: 14.2, schwaechster
Kontrast 5.49:1.
Zehn Tests dazu, darunter die drei Faelle, an denen der Filter gescheitert war.
Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber. Im Browser
nicht gesehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Dinge, die beim Ansehen aufgefallen sind.
**Das Rechteck auf dem Panel.** Das Logo bringt sein rosa Feld selbst mit, und
auf der rosa Flaeche stand es als Rechteck in leicht anderem Ton darauf. Der
Ton war dabei derselbe — verschoben hat ihn die Lichtblende, die ueber der
Panelflaeche liegt und unter dem Logo endete.
Das Manual kennt fuer den Schriftzug zwei Faelle, und sie brauchen
verschiedene Dateien: auf Weiss gehoert er nach §4.1 in ein rechteckiges rosa
Feld, in einem grossflaechigen rosa Umfeld dagegen wird er nach §3.1 nur mit
Freiraum integriert — die Flaeche ist ja schon da. Es gibt deshalb jetzt
manner-logo-auf-rosa.svg ohne eigenes Feld, und die rosa Flaechen benutzen es.
Damit bleibt nichts mehr uebrig, wo die Blende enden koennte.
Die Blende selbst ist weg. Sie war Weiss auf Rosa und hellte die Markenfarbe
so weit auf, dass sie kaum noch die Markenfarbe war; geblieben ist ein feines
Raster und eine leichte Tiefe zur unteren Ecke, beides aus ink.
**Schwarz auf der Marke.** "Uebersicht", "Berichte" und der Rest der Kopfzeile
standen in ink. Das traegt zwar (7.47:1), sieht aber aus wie Text, der aus
Versehen auf der Marke gelandet ist. Alles Geschriebene auf Rosa steht jetzt
in brand-700: 6.60:1, und es ist dieselbe Paarung, aus der der Schriftzug
selbst besteht — blaue Lettern auf rosa Feld.
Dabei noch eine Schwaeche gefunden: das Abzeichen an der Glocke kam als
danger-solid auf dem rosa Band nur auf 2.78:1 und blieb unter den 3:1, die
WCAG 1.4.11 fuer ein bedeutungstragendes Element verlangt. Mit danger-text
sind es 3.26:1, und die Ziffer darin steht mit 7.12:1 besser als vorher.
**Und ausgemistet.** Die Logodatei wog 74 kB, weil die Vorlage aus Seite 3 des
Manuals gezogen wurde und die ganze Seite mitgenommen hat: 219 Elemente, die
den Fliesstext jener Seite zeichnen ("Der Manner Schriftzug ... darf nicht
veraendert werden"), unsichtbar rosa auf Rosa oder ausserhalb des Beschnitts.
Das eigentliche Logo sind vier Pfade. Beide Fassungen wiegen jetzt 18,7 kB.
Lint, Typen, Schemaabgleich, 552 Tests und der Build sind sauber. Im Browser
nicht gesehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Flaechenverteilung war verkehrt herum. Blau lag auf dem Groessten, was die
Anmeldeseite zu vergeben hat, und Rosa nur in Andeutungen daneben — bei einer
Marke, deren Schriftzug auf Rosa steht und deren Manual Rosa zuerst nennt.
Sichtbar wurde es am Logo. Es bringt sein rosa Feld selbst mit, weil §1.2 den
Schriftzug nur zur Gaenze auf Rosa zulaesst. Auf einer blauen Flaeche steht
dieses Feld als ausgeschnittenes Rechteck darauf. Auf Weiss ist es dagegen
richtig — §4.1 verlangt fuer weissen Untergrund genau das, ein rechteckiges
rosa Feld. Falsch war also nicht das Logo, sondern die blaue Flaeche darunter.
Rosa bekommen jetzt: das Panel der Anmeldeseite und das Band ueber der
Anwendung aus Kopfzeile und Logokopf der Seitenleiste. Beide zusammen sind ein
durchgehender Streifen, in dem das Logo aufgeht statt darauf zu liegen — was
§3.1 mit "in einem grossflaechigen rosa Umfeld eingebettet" meint.
Blau bleibt, was es ist: Knopf, Link, Fokusring, ausgewaehlter Eintrag. Das ist
dasselbe Verhaeltnis, aus dem der Schriftzug besteht — blaue Lettern auf rosa
Feld — und es ist das einzige, das traegt. Weiss auf Rosa sind 2.18:1.
Auf den rosa Flaechen steht deshalb nichts Weisses und nichts Gedaempftes mehr.
ink kommt auf 7.47:1, brand-700 auf 6.60:1; ink-muted lag bei 2.33:1 und ist
aus Kopfzeile, Glocke und Logokopf verschwunden. Gestuft wird ueber Groesse und
Gewicht statt ueber Transparenz — eine aufgehellte Schrift ist genau das, was
auf dieser Flaeche durchfaellt.
Das Raster auf dem Panel ist von Weiss auf ink gewechselt: auf Rosa war es
nicht mehr zu sehen.
Lint, Typen, Schemaabgleich, 552 Tests und der Build sind sauber. Im Browser
nicht gesehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Farben, Schrift, Logo und Symbole folgen jetzt dem CD Manual 2022. An der
Logik aendert sich nichts: keine Migration, keine Abfrage, keine Berechtigung.
Die zwei Markenfarben stehen in §1.1 als #F69686 (Rosa) und #164194 (Blau).
Welche davon was traegt, ist nicht gewaehlt, sondern nachgerechnet: Weiss auf
Rosa kommt auf 2.18:1 und faellt damit auch fuer Grossschrift durch, Blau auf
Rosa auf 4.34:1 und reicht fuer das Logo, nicht fuer Text. Schwarz auf Rosa
sind 7.47:1, Weiss auf Blau 9.47:1. Rosa ist deshalb Flaeche, Blau ist
Interaktion — zwei Skalen, brand-* und accent-*, statt einer geteilten.
Von den 26 Stellen mit brand-50/100/200 sind nur sieben auf accent gewandert.
Der Rest meint einen Zustand und keinen Markenton: die Zeile unter dem Zeiger,
der gewaehlte Listeneintrag, der aktive Menuepunkt. Gegen die warme
Seitenflaeche ist ein kuehler Ton dort deutlicher, und Blau heisst in dieser
Oberflaeche ab jetzt "reagiert auf dich". Nach accent gingen die Faelle ohne
eigene Bedeutung — "Geplant", "Offen", der neutrale Protokoll-Chip — und die
offene Planstelle im Organigramm, die vorher ein blasses Blau war und damit
wie eine schwaechere Person aussah.
Die Funktionsfarben bleiben, was sie sind. Das Manual regelt die Identitaet,
nicht die Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt.
`info` bleibt bewusst tuerkis — Blau saehe ab jetzt bedienbar aus, und gemessen
kaeme ein blaues info dem violetten Chip auf dE 6.8 nahe, also nicht
unterscheidbar. Violett ist dabei nachgezogen: gegen den Fehler-Chip stand es
bei dE 8.0, "Austritt" und "Befoerderung" waren im Vorbeigehen dieselbe blasse
Flaeche. Jetzt dE 15.0.
Die Schrift ist Barlow. Nachgezaehlt ist das Manual zu 95 % in DIN gesetzt
(Regular 79 %, Bold 16 %); Helvetica Neue steht nur in den Visitenkarten und im
Claim. DIN laesst sich nicht ausliefern — eine Drucklizenz deckt keinen Webfont
—, und die freien Nachbauten der DIN 1451 sind Schilderschriften, bei 14 px in
einer langen Tabelle schlechter lesbar als das, was sie ersetzen. Die Variable
heisst --font-din und nicht --font-barlow: liegt eines Tages eine Web-Lizenz
vor, ist der Wechsel diese eine Deklaration.
Nebenbei zwei Dinge repariert, die vorher schon falsch waren. Die Umrandung von
Eingabefeldern, Knoepfen und Suchfeldern lag bei 1.30:1 und damit unter den 3:1,
die WCAG 1.4.11 fuer Bedienelemente verlangt; border-strong bringt 3.56:1. Und
das mitgelieferte favicon.ico liess sich gar nicht bauen: eingebettet waren
24-Bit-RGB-PNG, waehrend der Kopf 32 bpp behauptete. Alle Rastersymbole liegen
jetzt als RGBA vor, und ihr Blau ist auf den CD-Wert gezogen — samt der
kantengeglaetteten Raender, indem je Pixel der Blauanteil bestimmt und neu
gemischt wurde.
Das Logo ist das Markenlogo (§4.1), nicht das Unternehmenslogo, das §3.2 fuer
eine Anwendung mit der AG als Absender vorsaehe — es liegt nicht vor. Der
Freiraum X/3 steckt im Bauteil selbst und nicht in den Aufrufstellen, sonst
haengt seine Einhaltung daran, dass jede einzelne daran denkt.
docs/farbschema.html zeigt Token, Kontraste und Bauteile nebeneinander und
laesst sich ohne Server oeffnen.
Nicht im Browser gesehen: Anmeldung laeuft ueber das Firmenkonto und die
Datenbank ist von hier nicht erreichbar. Lint, Typen, Schemaabgleich, 524 Tests
und der Build sind sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seeing a colleague's draft turned out to be half a feature: the point of
sharing it is to finish it while they are away. So writing is allowed
now -- but never by two people at once.
A draft is a single JSONB field. Whoever saves writes the whole state,
not the changed field, so two open wizards overwrite each other
completely and the second person sees nothing wrong: their own state is
right there on screen. That is why writing stayed with the owner until
now, and a lock is what makes giving that up safe.
The lock lives in the row (locked_by, locked_at) and is enforced by the
update and delete policies, not by the application. It expires, and that
is the important half: releasing happens when the wizard closes, and a
closed laptop never closes a wizard. Without expiry one crashed tab
would take a draft away for good -- worse than the problem being solved.
The wizard refreshes its lock while open so a long form does not lose it
mid-way.
Delete had to widen too, which reads like more than was asked for: the
wizard deletes the draft once the person is hired. Without it the hire
would go through and the draft would sit there forever. The card still
only offers delete on your own drafts.
Four of five mutations against the lock go red. The fifth -- dropping
`!open` from the refresh guard -- does not, because freigeben() already
nulls the ref the interval checks. The condition stays as the readable
statement of intent, now with a comment saying so.
Not run against a live database here; the CI migration job is the first
real execution.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
worker_type had two values because the field was named after them:
"Angestellte:r / Arbeiter:in". Lehrlinge are the third social-insurance
category in Austria; until now they were filed as one of the other two,
which they are not -- and which skewed every report grouped by this
column by exactly those people.
Adding the value is one line. The label was the work: the field was
called after its two values in six places, and each of them becomes
wrong with a third. They now read "Beschaeftigtengruppe", the name the
import has used all along.
One label deliberately keeps the old wording: app_feld_karte() in the
database. That string is not a caption there but a key -- stored rows in
employee_history and pending_changes carry it, and the map is how
reverting or correcting a history entry finds the field again. Renaming
it without rewriting those rows would make every older entry for this
field unrevertable, and nobody would notice until they tried.
The migration's assertion reads pg_enum rather than comparing against
'Lehrling'::worker_type: migrations run inside a transaction, and
Postgres refuses to use a freshly added enum value in the transaction
that added it. This has not been run against a live database here -- the
CI migration job is the first real execution.
Three hand-kept lists of the same enum (reports, import, the form) now
have a test holding them to one another, each mutation-checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Name every draft, and put Vertrag before Angehoerige
Two things the dashboard and the hire wizard were getting wrong.
The drafts card named the author only on other people's drafts. With
foreign and own rows side by side that reads as an inconsistency, not as
information: the eye has to work out that a missing name means "mine".
Now every row says it, "von mir" on the own ones -- the same wording the
notes in the bell already use.
In the wizard, Angehoerige stood before Vertrag. What a contract is made
of -- entry date, working days, a fixed term -- is on paper before the
conversation happens; relatives the person brings along, often on the
first day. The optional step came before the one the hire rests on.
Swapping them meant touching the part that would have broken silently:
the per-step validation was a positional list that had to line up with
STEP_LABELS by hand. Reordered labels alone would have left the checks
where they were -- "Weiter" on Vertrag would have validated the
relatives and waved an empty entry date through, until the database
refused it at the end. The checks are keyed by step name now, so they
travel with the step.
Drafts saved before this land on the step number they stored, which now
points at a different page. Nothing is lost -- the payload carries every
field -- but somebody resuming an older draft may open on Vertrag where
they left Angehoerige.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@
The picker in the bell now governs both lists, so note_subscriptions is
renamed to colleague_subscriptions -- a name that only mentions notes would
mislead the next reader.
Reading and writing a draft now reach differently far. hire_drafts_owner
(for all) is split into four policies: select lets in your own drafts and
those of the people you added, while insert/update/delete stay with the
owner. A draft is unfinished work with no lock and no history; two people
writing into the same row would overwrite each other silently.
That split forces a change in the actions: a policy does not reject a write,
it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row
count instead of reporting success over a row that never changed.
The card shows a foreign draft with its author and without Fortsetzen or
Loeschen -- offering a button that reliably ends in a database error is a
promise without cover.
check-schema-types.mjs learns `alter table ... rename to`; without it the
drift check reports one rename as two errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yesterday's version had it the other way — everyone visible, untick to
hide. The decision from the business side is the opposite: you see your
own notes, and you tick the colleagues you also want. So note_mutes
becomes note_subscriptions and the predicate flips from `not exists` to
`exists`.
The existing rows are not carried over. The meaning inverts rather than
the sign: converting faithfully ("everyone except the muted") would write
almost the whole roster into the new table and reproduce exactly the state
the change is meant to end. Anyone opening the setting tomorrow would
think it had not taken effect. The table is a day old; what is lost is a
few ticks from trying it out.
What this costs is worth saying plainly: the silent case that could not
happen under exceptions can happen now. Do not tick a colleague and you
will not see her follow-ups — not while she is on holiday either. That is
the flip side of the decision, and it is written down in the migration
rather than discovered later.
Each note now says who wrote it. Own notes read "von mir" rather than
repeating your own name, which would sit on every second line and tell
nobody anything. The flag is computed on the server: the user id is
already there, and threading it through four components for one word is a
poor trade. The counter on the button follows the same turn — "+2" for
what you added, nothing when you added nothing.
Verified: 21 tests, five mutation-checked (restoring `not exists`,
dropping the own-notes clause, inverting the default, hiding the author,
and printing your own name instead of "von mir" each turn them red). 489
tests, typecheck, lint, schema drift and build clean. The migration is
reviewed but not run — no reachable database here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"… und 5 weitere Ereignisse in diesem Zeitraum" was a sentence to read.
It is now a button: click it and the rest unfolds in place, click again
and the card goes back to eight rows.
Until now the only way to see the remaining entries was to narrow the
period or the kinds — which changes the question rather than the answer.
This happens in the browser, not through the URL, unlike the period and
kind filters. Those go through the address because a longer period brings
rows into play that were never loaded; here every entry in the period is
already on the page and the eight was purely presentational. A round trip
would mean waiting for data that is already there, plus a history entry
for something nobody wants to go back to.
The list moved into its own component so the state has somewhere to live.
KIND_LABEL and the item type moved with it, since they only describe this
list.
The button only appears when there is something to unfold, and once open
it offers the way back — otherwise the card stays long for the rest of the
session because somebody looked once.
Verified: 10 tests, five mutation-checked (ignoring the state, dropping
the way back, showing the button with nothing to unfold, losing the
singular, dropping aria-expanded each turn them red). 487 tests,
typecheck and build clean. Not seen in a browser — login goes through the
company account and the database is unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bell is a shared pile: every active HR person sees every open note,
regardless of who wrote it. That was agreed and it stays the default —
this narrows it, it never widens it. You can now untick colleagues whose
notes you do not want to see.
What gets stored is the *exceptions*, not the selection. The difference
shows the day someone new joins HR: had the selection been stored, she
would be invisible to everyone until each person ticked her, and nobody
would notice her follow-ups piling up. This way she is visible from day
one and hiding her is a deliberate act. Same reasoning that made notes a
shared inbox in the first place — the silent gap is worse than a row too
many.
Own notes always come through: `note_mutes` rejects a self-reference, and
the predicate says so again rather than depending on a check constraint
staying put. Notes with no author come through too — hiding one because
nobody knows who wrote it is exactly the loss this list exists to prevent.
The rule lives in lib/notes.ts as one SQL expression because two places
need it: the bell in the header and the "Anstehend" card on the dashboard.
Two copies drift, and then the card counts something the bell does not
show.
No SQL function and no audit row, unlike anything that touches employee
data — this is a personal display preference, and an audit trail recording
every tick would make finding real changes harder. Same pattern as saved
reports and hire drafts, and the owner policy on note_mutes means a row
for someone else cannot be written even with invented values.
The checkbox flips immediately and flips back if saving fails; the list
gets clicked through several at a time and a round trip per tick feels
like hesitation.
Verified: 19 tests, five mutation-checked (or→and, dropping the own-notes
clause, inverting `not exists`, inverting the default, and losing the
email fallback each turn them red). Typecheck, lint, schema drift, 477
tests and the build are clean. Not seen in a browser: login goes through
the company account and the database is unreachable — the migration is
reviewed but has not been run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beim Neuaufbau der Datenbank aus den Migrationen am 26.08. blieben
cost_centers, position_cost_centers, onboarding_tasks und
offboarding_tasks ohne Row Level Security: sie verlassen sich auf
den Ereignis-Trigger ensure_rls, den eine spaetere Migration erst
anlegt. Ein Ereignis-Trigger wirkt nur nach vorne.
Die Policies auf diesen Tabellen existieren und wurden nie
ausgewertet — PostgreSQL befragt sie nur bei eingeschaltetem RLS.
In jeder Aufstellung der Policies sah es richtig aus.
Der CI-Job prueft ab jetzt den Zustand nach dem Lauf, nicht nur
dass die Dateien durchlaufen. Genau in diesem Zwischenraum ist der
Fehler durchgekommen.
The dropdown is gone; each column header is now a link that sorts by that
column, with an arrow showing the direction. Clicking the column already
sorted reverses it; clicking a different one starts ascending again — going
from "Eintritt, newest first" to "Name" should give you names from A, not
inherit the previous direction.
Names sort by surname and then forename, as asked. Both parts reverse
together: turning only the surname would give Z-A across surnames but A-Z
within each one, which is visible immediately among the fifteen Aigner.
Three of the seven columns are not on the employee row. Bereich and Team
hang off the position, Standort off a lookup table, so they are fetched as
correlated subqueries rather than joins. That is not a style preference: the
same filter chain produces the page *and* the count, and a join onto
position_assignments would double every person who has held more than one
position over time — the line above the list would read 1,203 for 867 people.
Bereich is the level below the company, so it needs to walk up from the unit.
No recursion: org_unit_type has exactly four levels, so two hops up cover it.
Everything sorts `nulls last`, otherwise reversing the direction floats every
person without a position or location to the top.
The expressions live in lib/employee-sort.ts rather than in the page so the
generated SQL can be read in a test — the failure mode here is silent, the
list still shows fifteen rows, just the wrong ones. Eighteen tests, and the
rules are mutation-checked: dropping the forename, dropping the id tiebreaker,
dropping `nulls last`, sorting the location by its uuid, and shortening the
Bereich walk each turn them red.
Not seen in a browser: login goes through the company account and the database
is unreachable. Typecheck, lint, 458 tests and the build are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bei GitHub laeuft der Job auf dem Host und erreicht einen Dienst
ueber den durchgereichten Port. Der Gitea-Runner setzt den Job
selbst in einen Container: dort ist 127.0.0.1 der Container selbst,
und dort horcht kein Postgres — 'Connection refused'.
Beide Container haengen im selben Netz, also gilt derselbe Weg wie
in docker-compose.yml: der Dienst wird ueber seinen Namen erreicht.
Der Job 'Migrationen auf leerer Datenbank' brach beim ersten Schritt
ab: 'psql: command not found'. Das Abbild ubuntu-latest bringt keinen
Postgres-Client mit, die Vorbereitung der Datenbank ruft ihn aber —
deshalb lief der Job nie bis zu dem, wofuer es ihn gibt.
Genau diese Pruefung haette die fehlenden Rechte der Anwendungsrolle
gefunden, bevor sie beim Umzug auftraten: auf einer leeren Datenbank
scheiterte die Anwendung mit 'permission denied for table profiles'.
Beim Aufraeumen fielen zwei Regeln mit heraus, die Abzuege mit
Personendaten ausschlossen. Die Dateien liegen weiterhin auf dem
Server, ein 'git add .' haette sie mitgenommen — 857 Personalakten
mit SV-Nummern, Adressen und Angehoerigen.
Jetzt nach Ort statt nach Namen: /*.sql trifft die Abzuege im
Wurzelverzeichnis, laesst db/migrations aber unberuehrt (fuehrender
Schraegstrich). Damit greift die Regel auch fuer kuenftige Namen.
The schema/type drift check has been red in CI. It reported seven
discrepancies, and all seven were the checker's own fault.
The migrations put several clauses in one statement:
alter table employees
drop column if exists division_id,
drop column if exists team_id,
drop column if exists manager_id,
drop column if exists org_level,
drop column if exists is_lead;
The old pattern matched `alter table (\w+)\s+drop column (\w+)` as a single
regex, which finds exactly the first clause. So division_id was dropped from
the model and the other four stayed in it — the checker insisted four columns
existed that the OM cutover removed a year ago. The same cut the other way for
`add column`: kuendigungsschutz_bis, teilzeit_bis and aufenthaltstitel_bis are
each the second clause of their statement, so the checker never saw them and
called them typed-but-absent.
Now the statement is collected up to its terminating semicolon — with paren
depth tracked, so a semicolon inside a check constraint does not end it early
— and every clause inside is applied.
Verified by mutation, not by the green result alone: putting an invented
column into types.ts is caught, and reverting the parser to read only the
first clause brings back exactly those seven messages. That is the diagnosis
confirmed, not merely a passing run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.
Moved, not deleted:
supabase/migrations/ -> db/migrations/ the schema's source of truth
supabase/build-org.ts -> scripts/build-org.ts
lib/supabase/types.ts -> lib/types.ts 52 import sites repointed
The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.
Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.
Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).
CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.
Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.
Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sorting lives in the URL, not the browser. The list is built on the
server and fetched a page at a time, so a client-side sort would only
reorder the fifteen rows on screen — with 867 people that promises an
alphabetical list and delivers something else on page 2.
The sort key is the surname, because that is how the column reads:
"Aigner, Manuel", and whoever looks for someone looks under A. Postgres
runs with the Austrian collation, so Ö sorts with O rather than at the
end of the alphabet.
Only the surname reverses. The id stays ascending: it decides nothing
except ties, and it exists to keep the order total across page
boundaries. Reversing it too would still be deterministic but would flip
the fourteen Winklers relative to each other for no reason anyone asked
for.
The select sits in the filter bar rather than in a clickable column
header — a header would suggest it sorts what is on screen.
Verified: compiled SQL is `order by last_name desc, id` for Z-A; the
parse and direction rules are covered by tests that were mutation-checked
(breaking each rule turns them red). Not verified in the browser — the
login goes through the company account, and the Supabase instance no
longer resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Umzug in einen eigenen Container hat zwei Luecken aufgedeckt:
1. Die Rechte von alpenwerk_app standen in keiner Migration — sie waren
auf Supabase von Hand im Dashboard vergeben worden. Auf einer leeren
Datenbank scheiterte die Anwendung deshalb mit 'permission denied for
table profiles', bevor die Anmeldeseite erschien.
2. docker-compose.traefik.yml lag nur auf dem Server. Ein frischer Clone
haette die Anwendung ohne Routing hochgefahren. Zusaetzlich haengt app
jetzt im Netz 'default', sonst findet es den db-Container nicht.
It was never used. The repository lives on a self-hosted Gitea, which
Vercel's git integration cannot connect to at all — so the documented
route amounted to "mirror to GitHub first", and nobody did.
vercel.json is gone, and with it the branch in next.config.ts that
switched off `output: "standalone"` when the VERCEL variable was
present. That branch was the only functional trace; everything else was
documentation and comments describing a second deployment path that did
not exist.
DEPLOYMENT.md loses its "two supported ways" framing and the whole
Vercel section — about fifty lines. Several statements next to it were
stale for a different reason and are corrected in the same pass: the
outbound-firewall table still listed Supabase's pooler (the database is
a container now, nothing leaves the server), the prerequisites still
demanded an existing Supabase project, and the .env table still asked
for a pooler connection string instead of the two new passwords.
The nightly job is described as what it is — a container in
docker-compose.yml — rather than as a replacement for Vercel Cron.
Migrations keep their references: two comments from July mention Vercel
Cron, and they describe what was true when they were written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Supabase was only ever the host: the application has talked to PostgreSQL
directly through pg/Kysely for a while. So the move is mostly about
supplying what the platform used to supply.
Proved before building anything. All 65 migrations replay onto an empty
database, and the result matches production exactly — 183 columns, 25
policies, 68 indexes, 84 constraints, identical sets, no diff. The only
function missing from the rebuild turned out to matter, see below.
What the platform supplied, deploy/db-init now does:
- alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21
RLS policies; a role that bypasses them would leave everything
working while showing too much, and nobody would notice.
- pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is
used nowhere — no column default, no function calls uuid_generate_*.
- anon, authenticated and service_role as NOLOGIN placeholders. No
policy names them; they only carry grants the platform handed out,
and a data dump referencing them would fail to restore without them.
- A stub `auth` schema. The end state needs none of it — checked: no
foreign key, no policy, no column default refers to it. The June
2026 migrations do, and rewriting those would be falsifying history;
they describe what was true then.
The gap the comparison found: rls_auto_enable() and the ensure_rls event
trigger existed only in the running database, created by hand, in no
migration. That is the net which forces RLS on every newly created
table — the reason a forgotten policy yields an empty table instead of
an open one. A rebuild from migrations would silently not have had it:
everything works, and the next new table is unprotected. Now a migration
(20260819100000), verified by creating a table on the rebuild and
confirming RLS came on by itself.
Data moves separately, via scripts/umzug-von-supabase.sh: schema from
the migrations, then pg_dump --data-only --disable-triggers for the rows.
Without --disable-triggers every foreign key trips over load order. RLS
does not interfere — none of the 19 tables uses FORCE ROW LEVEL
SECURITY, so the owner writes through. The dump is deliberately left on
disk afterwards.
psql and node come from two `tools`-profile services rather than being
installed on the host, so the server needs nothing but Docker. The db
service publishes no port at all — reachable only inside the compose
network.
SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it
describes something else entirely; the old name still works so existing
.env files keep running. Both were exercised, as was the error when
neither is set.
The deploy workflow is set to manual-only. Its preconditions were never
met — no secrets, and whether the job container can reach the host's
Docker daemon is untested — and failing on every push teaches people to
ignore red runs. It also needs updating for the new database service
before it could work at all.
Not verified: none of this has run in an actual container. There is no
Docker daemon on this machine. What is verified is the part that
decides whether it can work — the schema, on a real empty database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workflow asked for a `self-hosted` label. No runner on the instance
offers one, so the run would have sat in "Waiting" forever — no error, no
message, nothing to notice. The existing global runner elycon-runner-01
offers `docker` and `ubuntu-latest`, so both workflows now ask for
`ubuntu-latest`, the same label ci.yml already used.
That correction exposed a second thing the first version glossed over.
act_runner starts a container per job; mounting the Docker socket into
the *runner* does not put it in the *job*. Whether this job can reach the
host's daemon depends on the runner's config.yaml, which is not visible
from here — and the runner is global, so changing it affects every
repository on the instance, not just this one.
Rather than guess, the workflow now measures it in its first step and
fails with the fix if it cannot: which config lines to add for the
socket, or that SSH is the other way. Without that, the run would have
died three steps later on a message nobody could act on.
Both branches of the check were exercised: docker absent prints the
first message, docker present with no reachable daemon the second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A push to master now builds and restarts the application on the server:
a Gitea Actions workflow on a self-hosted runner writes .env from the
repository secrets, applies pending migrations, rebuilds the compose
stack against the host's Docker daemon, and waits for the container's
healthcheck before calling the run green. Without that last step a
deploy counts as successful the moment the container *starts*, even if
the app inside it dies immediately.
Switching migrations on automatically turned up something that had to be
fixed first: supabase_migrations.schema_migrations did not exist at all.
Every one of the 65 migrations was unrecorded, because they have been
applied by hand all along. An automatic `db push` would therefore have
replayed all 65 against the live database — initial_schema and the OM
cutover included. The database was checked against a spread of
migrations first (it is at head), then baselined: all 65 recorded as
applied without executing them.
The runner is scripts/migrate.mjs rather than the Supabase CLI. It needs
only `pg`, which the project already ships, instead of downloading a CLI
whose version drifts independently of this repository; and it does one
thing — the missing files, in order, each in its own transaction — where
`db push` also diffs schemas and may do more than that. Bookkeeping goes
in the same table in the same shape the CLI uses, so `supabase db push`
from a workstation still works and still skips what already ran.
The workflow lives in .github/workflows, not .gitea/. Gitea reads
.gitea/workflows and falls back to .github/workflows only when the
former is absent — creating .gitea/ would have silently switched off
ci.yml, with the run simply never appearing.
Verified: both workflow files parse; the secret check names what is
missing and refuses; values starting with "-" or containing "=" survive
being written to .env; and the runner was exercised against the real
database with a throwaway migration — applied once, skipped on a second
run, and on a deliberate syntax error rolled back whole, recording
nothing. Both probes were removed; the count is back to 65.
Not verified: nothing has run on an actual Gitea runner — none is
registered yet. DEPLOYMENT.md §5 covers registering one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The checklist gets a print view: one A4 page, header carrying personnel
number, name, date of extract, entry date and position, the items in two
flowing columns so twenty-five fit, and two signature lines at the foot.
It prints the *state*, dated — not a blank form.
The default filename in the save dialog comes from the document title,
which is set to the agreed convention:
20260818_2884_Aigner-Manuel_Onboarding-Checklist
Surname first, like everywhere else in the application, so a folder of
these sorts by person and within a person by date. Umlauts are resolved
rather than stripped: the naive route (NFKD, then every non-ASCII to a
dash) turns "Müller" into "Mu-ller", because decomposition splits the
umlaut and the diaeresis becomes the dash. "Weiß" needs its own rule —
it has no decomposition and would otherwise vanish.
The reported defect: the printout carried the application's own top bar
— hamburger, bell, "Neueinstellung", sign-out. Those are controls; on
paper they are decoration, and on a checklist filed in a personnel
record, misleading. The rule now sits on AppShell rather than on this
one page, so the org-chart print view — which had the same problem —
gets it too, along with anything printed later. The shell's padding goes
with it: the type area is set by @page on the print page itself, and the
shell's would have been added on top.
The browser's own header line (date, title, URL) is separate — that is a
checkbox in the print dialog, not something CSS can reach.
440 tests pass, including 13 new ones pinning the filename convention.
Not yet seen in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab was keyed off employee.status === "Ausgetreten", and that stays
wrong for weeks: terminate_employee writes exit_date immediately no
matter how far out the date is, but only flips status once the date
itself arrives. A termination entered today for four weeks out left the
tab invisible for the entire notice period — exactly the stretch in
which IT access, hardware and deregistration actually get worked
through, and exactly where the checklist was supposed to live "next to
Onboarding," per the report that caught this.
The rule now reads exit_date instead: not null, and not a No Show
(which sets exit_date too, to the entry day, but never worked a day and
gets no checklist). rehire_employee resets both exit_date and
exit_reason to null, so a rehired person's tab still disappears the
same way it did before — nothing about that case changed, only the
signal the check reads.
Verified against the real database with the exact shape from the
report: a termination dated 30 days out. Status stays "Aktiv", the
checklist exists immediately, and the tab's own predicate says yes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The offboarding list was a checkbox fieldset inside the exit panel — four
items, never sent anywhere. Nothing in terminateEmployee's payload
carried them; ticking a box there recorded exactly nothing.
It's replaced with the same kind of list the entries got: its own tab,
appearing the moment an exit is recorded, with one item per row, a
comment on each, and — unlike the fieldset — a record of who touched it
and when.
The eleven items come from the same printed sheet as the entry list.
Nine are plain checkboxes. Two are text fields under "Vermerke":
remaining vacation and the balance transferred for payout — the sheet
names "Überleitung Salden für Auszahlung" twice, once as a task to do
and once as the actual figure, and those are genuinely two different
questions, kept as two items. Where the sheet still says "GKK" rather
than today's "ÖGK", it's left as written — that's the name the process
runs under internally, not a typo.
No Show gets no list. Never having worked a single day, there's no IT
access to revoke, no GKK registration to undo, no Dienstzettel to
collect — an empty checklist there would be a label with nothing behind
it. Both the tab and the auto-creation on exit check for this
specifically, not just the "Ausgetreten" status that No Show shares with
a real exit. Rehiring the same person hides the tab again — the data
stays, since it happened, but a checklist for someone currently working
has nothing to point at.
The engine (what counts as done, how progress is computed) moved into
lib/checklist.ts so onboarding and offboarding can't drift into two
different ideas of "done" the way two independent copies eventually do;
lib/onboarding.ts and lib/offboarding.ts bind it to their own item list,
and the tab UI is a single ChecklistPanel bound the same way.
Checked against the real database: a real exit creates all eleven items
in the same transaction as the exit itself; a No Show creates none;
rehiring flips the tab off while the old answers stay queryable. One
false alarm during that check turned out to be the user's own clicks on
a real employee's onboarding list, made in the browser while trying the
earlier feature — left untouched, not test debris.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.
Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:
Haken — the normal case. The Meldezettel is there or it is not.
Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
not decoration: "nein" is a finding, "not asked yet" is not.
One checkbox cannot say both.
Text — shoe, shirt and trouser size. The value is the point;
ticked off it would be worthless.
Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.
Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.
The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.
A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.
Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was none anywhere in the model, so no personnel-cost figure could
be produced at all, and an open position could not say whose budget it
would charge — which is the first question asked about a vacancy.
It hangs on the position, not on the person: the seat costs money even
when nobody sits on it. That is exactly the vacancy case. And not on the
org unit either, although it usually follows from one — a single seat
can be charged elsewhere (project, shared function) without the unit
moving.
As its own dated assignment table rather than a column, because
reassigning is an event with a date. Last year's costs have to stay
where they were incurred; as a column, every change would silently
rewrite every past report. Half-open [valid_from, valid_to), like
position_assignments and om_positions — in SAP OM this is A011.
25 cost centres seeded from the org tree: one per company, division and
department, with teams charging to their department, because a team is a
span of control and not a budget. All 823 positions were assigned from
their own start date, none left over. The number is the first five digits
of the org number, so it can be traced rather than looked up.
Reassignment refuses three things, each checked: the same cost centre
again, a switch on the day the current one started (that period would
never have been in force, and the range constraint says so), and a date
before the position exists.
Verified against the real data, which turned up a defect worth keeping:
a position that starts in the future is charged only from its start, so
asked about today it had no cost centre — and future positions are
exactly what the vacancy list is for. It is now read at the position's
own start date.
Two audit entries from the probe could not be deleted through the
application (the log has no delete policy — correctly), so I removed
them with the admin connection.
Still open, and the reason this is only the first of the three fields I
proposed: location and planned FTE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding the follow-up kind turned one of these tests green-for-the-wrong-
reason and one red: both had the three kinds written out by hand, so
"all of them are selected" no longer meant what the name said. That is
the failure mode a hand-copied list has — it does not break loudly, it
drifts.
The list now comes from ANSTEHEND_ARTEN, and the two cases that depend
on completeness build their input from it.
I committed the previous change with this test red. That was wrong; it
should have blocked the commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A note with a follow-up date is a task, and the overview is where tasks
are looked for. Until now it lived only in the employee file, which is
the one place you go when you already know who you are looking for.
Follow-ups behave differently from everything else on that card, and the
difference is the point: an entry on Monday is over on Tuesday, an
unfinished task is not. So there is no lower bound on the date — what
was due and never ticked off stays, marked overdue in red, sorted to the
top because it is sorted by date. A task that drops out of the list by
itself is a forgotten task.
Only "Erledigt" removes it. A note without a follow-up date never
appears: it is a record, not a task.
Checked against the live database — an overdue one and an upcoming one
appear, one without a date and one beyond the chosen period do not, and
ticking the overdue one off removes exactly it. The probe notes were
deleted again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app got slower as pages grew, and the reason was not the queries. It
was their number.
A transaction is pinned to one connection, and a connection runs queries
one after another. Every Promise.all in a withUser block looked like
concurrency and was a queue. Measured against the real database: the
round trip is ~36 ms, ten trivial `select 1` over one connection take
343 ms, over ten connections 39 ms. Nothing here is slow — the whole
dashboard payload is under 200 kB, and every table is around a thousand
rows.
More connections is the wrong answer: the RLS session context is per
transaction, so parallel reads mean parallel transactions, and those
multiply the connections the database will grant. Fewer round trips
instead. Postgres will return each sub-select as its own JSON column of
one result.
Per page view, counting the transaction frame:
shell (paid by every page) 10 → 4
overview 14 → 5
employee file 14 → 7
employee list 8 → 6
The overview plus its shell went from 24 round trips to 9 — about 860 ms
of pure waiting down to about 320 ms.
The one trap is documented where it bites: inside json_agg, Postgres
formats values itself and the driver's parsers (lib/db/pool.ts) never
see them. Dates, numerics and uuids come out identical; timestamptz does
not — "+00:00" where the driver gives "…Z". Timestamps are compared as
strings in lib/history.ts to decide what happened later, and those two
forms sort against each other wrongly. Every timestamptz in a bundled
query therefore goes through zeitstempel(), which was checked
character-for-character against the driver.
Four loaders moved out of their pages into lib/ so the number of round
trips can be measured without building a React tree, and so the new path
could be held against the old one field by field: same rows, same order,
same strings, for the overview and for four employee files chosen to
differ (with history, a chief, a planned entry, one with dependents).
withUser now counts the queries in each transaction and says so in
development past a threshold. Without that, this grows back: each new
tile brings its own query, and nobody notices until everybody does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sixty days and all three kinds was a guess, and it was the only one on
offer. Payroll cares about next month; the person filling a vacancy
cares about entries and nothing else. The card now takes a period and a
set of kinds.
The choice lives in the address rather than in the browser, because it
has to: the page is built on the server, and ninety days pulls in rows
that were never loaded at sixty. Filtering client-side would silently
cap the answer at whatever the first query happened to fetch. It also
means a filtered overview can be sent to someone and opened again the
same way.
Deselecting every kind returns to all of them. An empty card is not an
answer to a question nobody asked, and the way back would otherwise be
one click further than the way in. The default period and the full set
are absent from the URL instead of written into it, so a shared link
carries only what was actually chosen.
Anything the address cannot be trusted to hold is rejected: an unknown
period falls back to sixty rather than reaching the query, which would
otherwise be an invitation to ask for ten years of rows through a link.
Eight rows still, with a count of what did not fit underneath — this is
an overview, and the employee list is where lists belong.
Not verified in a browser: the built-in preview has no company sign-in,
so the page redirects to the login before it renders. Types, lint and
386 tests pass, and the filter's behaviour is covered directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old refusal read: "Diese Abwesenheit ist noch nicht wirksam. Sie muss
über den Vorgang selbst abgebrochen werden." There was no such way. The
row sat in the file, the scheduled change kept running toward its date,
and nothing could stop either one.
That is not hypothetical. One person went absent in July, came back in
August, and still has a second return booked for the first of September
— recorded while they were already working again. The guard added
yesterday stops a third from being written; it does not remove the one
that exists.
Absences are called off whole, not field by field. For a planned
contract change the scheduled payload gets the affected fields lifted
out of it and runs on with the rest; an absence has no fields in that
map, and half an absence is not a thing anyone means. So the whole
scheduled change is cancelled, and what it had already noted on the
person goes with it: the date they were to be away from, the date they
were to come back on. Left behind, the profile would show an absence
with no event behind it. If the absence is still running, the return
date planned when it began applies again.
The link between the row and the scheduled change had to exist first —
start_karenz and record_karenz_return now record it. Existing rows get
it backfilled, but only where one running change of that kind falls on
that person and that day. Where two would match, the row keeps refusing:
guessing which process to cancel is worse than refusing to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all from the same screenshot.
The entry date can now be corrected. The Eintritt entry gets an edit
button — date only, no delete, because it is the start of the timeline
and a person without one has no beginning. Unlike every other entry it
needs no recorded before-values: the old date is on the employee row, so
this works on rows written long before any of this existed, which is
exactly the case that matters.
What hangs off that date is checked: no other event may precede it, exit
and absence start may not fall before it, and the first position
assignment moves with it — left behind it would leave days of employment
with no post, or a post with nobody in it. Someone already working
cannot be given a future entry date either; without that check a person
who has been here for years could be turned into a planned entry, and
the status derivation would agree.
That last rule came out of the rehearsal finding a hole: my first probe
picked a person with no other history rows, so the "nothing may precede
it" check had nothing to compare against and a date in 2099 sailed
through.
Second, the screenshot showed two returns from one absence, and the data
confirmed it: one person with two Rückkehr entries and a third still
scheduled, recorded while they were long since active. record_karenz_
return never checked that there was an absence to return from. Now it
does, and it refuses a second scheduled return — which would have
silently overwritten the first on its effective date.
Third, the history is filterable: upcoming versus done, a date range,
and the event types that actually occur in that file. The count of
upcoming items shows without filtering, because "what is coming" is the
usual reason to open the tab at all.
Still not deletable: Versetzung, Beförderung, Austritt, Wiedereintritt,
Reorganisation. Undoing those means restoring position assignments, and
that deserves its own step rather than being tacked onto this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Employees from outside the EU, the EEA and Switzerland need a residence
permit, and HR needs to know when it expires — about eighty people in
the current data, across Türkei, Serbien and Bosnien. Two columns, the
same shape as the dismissal protection: a flag and a date that only
means anything with it. The date is optional, because an open-ended
permit has none and a mandatory field would force an invented one.
The nationality coupling deliberately stays out of the database. Putting
it there would mean keeping the country list in two places — SQL and
lib/countries.ts, where the picker needs it anyway — so an EU accession
would become a migration instead of a line in a list. Worse, correcting
somebody's nationality would fail the constraint while the old permit
was still attached, which is exactly the moment someone is fixing a
mistake. The UI decides whether the fields appear, and clears them when
the nationality moves into the free-movement area.
So the list is the load-bearing part, and it is tested: 31 entries, all
of them values the picker can actually produce, no duplicates, no third
countries. A missing nationality reads as "no permit required" — an
unanswered question is a reason to record it, not to demand papers.
The permit shows on the Stammdaten tab only for the nationalities it
applies to. A line reading "Aufenthaltstitel: Nein" under an Austrian
citizenship would look like information rather than a question that does
not arise.
Filter by it and by when it expires — the question behind that being
"whose permit runs out next quarter" — plus columns in the export.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A long-term absence recorded by mistake could only be undone by booking
a second event on top of it — leaving two entries in the file, the first
of which never happened. Karenz and Rückkehr can now be deleted and
corrected like the other entries.
For that to restore anything, the two operations first had to start
recording what they overwrote. start_karenz and record_karenz_return now
keep before/after the way change_employee_data does: status, kind of
absence, start, planned return — and for a return also employment type,
hours and the part-time variant. Without that there is nothing to revert
to, only a sentence.
The ordering rule HR asked for is enforced in the database, not just in
the UI: an absence cannot be deleted while a later return exists. A
return standing on its own would be a return from nothing, and the
person's status would derive from an entry whose starting point had been
deleted. Delete the return first and the absence frees up.
Rehearsed end to end on real data: absence recorded, return recorded on
reduced hours; deleting the absence refused; deleting the return put the
person back on Karenz with the original hours and the part-time variant
cleared; deleting the absence then put them back to Aktiv with no trace.
Rows written before today carry no before/after and stay untouchable,
with the reason they already gave. Planned absences are refused too —
they have their own operation, and their fields have no place in a
pending payload, which is why app_feld_karte carries a null group for
them rather than a plausible-looking wrong one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>