Skip to content
AlitemuPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

589 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Dienstrooster

Een dienstroosterapplicatie voor verpleegafdelingen (20-40 medewerkers) met automatische roostergeneratie, verdeling van diensten en voorkeursbeheer.

Alle fasen uit het implementatieplan zijn gebouwd en getest: authenticatie, datamodel, feestdagberekening, capaciteitscontrole, voorkeuren, de CP-SAT- solver voor roostergeneratie, publicatie, ruilverzoeken en correcties.

Stack

  • Frontend/Backend: Next.js 16 + TypeScript (App Router, strict mode)
  • Styling: Tailwind CSS + shadcn/ui
  • Database: SQLite (WAL-mode) + Drizzle ORM
  • Auth: eigen implementatie (persoonlijke links, bcrypt-wachtwoorden, TOTP)
  • Solver: Python + FastAPI + OR-Tools CP-SAT
  • Testen: Vitest + fast-check (unit/integratie), Playwright (e2e)
  • Container: Docker Compose (3 services: caddy, web, solver)

Snel starten

Vereisten

  • Node.js 20+
  • Python 3.11+ (alleen nodig als je de solver buiten Docker draait)
  • Docker & Docker Compose
  • npm

Installatie

# Dependencies installeren
npm install

# Database aanmaken en vullen met voorbeelddata
npm run seed

# Ontwikkelomgeving starten (Docker Compose: caddy + web + solver)
npm run dev

# Tests draaien
npm run test

Toegang

Testgebruikers

Na npm run seed:

  • Planner en beheerder: codenamen planner en admin (hoofdletters maken bij het inloggen niet uit), wachtwoord zoals gezet in DEFAULT_TEST_PASSWORD (lib/seedPassword.ts) - wijzig dit voordat de app voor een echte afdeling gebruikt wordt. De server waarschuwt bij elke start zolang een account dit standaardwachtwoord nog heeft.
  • Medewerkers: Persoon-01 t/m Persoon-31 (persoonlijke toegangslinks, geen echte namen of e-mailadressen)

Ontwikkeling

Projectstructuur

├── app/                     # Next.js App Router (routes, pagina's, API)
│   ├── api/                 # API-routes (planner + deelnemer)
│   ├── planner/             # Schermen voor planner/beheerder
│   └── person/[token]/      # Persoonlijke pagina per medewerker
├── components/              # React-componenten
├── lib/                     # Logica: holidays, auth, capaciteit, solver-client, ...
├── db/                      # Drizzle-schema & migraties
├── types/                   # TypeScript-types
├── scripts/                 # seed.ts, claim-password.ts, full-check.mjs, ui-check.mjs, schema-drift.mjs
├── solver/                  # Python-solverservice (CP-SAT) van de achterwacht
├── aios-solver/             # De eigen solver van de AIOS (vanaf 0.74.0)
├── tests/                   # Testfixtures en de Playwright e2e-suite
├── CLAUDE.md                # Ontwerpregels & conventies (verplichte leesstof)
└── docker-compose.yml       # Services: caddy, web, solver, aios-solver

Codeconventies

Belangrijk: lees CLAUDE.md voordat je code schrijft.

Kernpunten:

  • Geen echte namen/e-mailadressen - uitsluitend pseudonieme codenamen
  • Tekenconventie: delta < 0 = minder diensten, delta > 0 = meer diensten (nooit een los getal tonen aan de gebruiker, altijd in woorden)
  • Taal van de interface: alles wat een deelnemer of planner ziet is Nederlands; Engels blijft beperkt tot code, databasevelden en logs
  • Tests: één test per harde regel, die bewijst dat de regel niet doorbroken kan worden
  • Geen mockdata: alleen via scripts/seed.ts en /tests/fixtures/

Tests draaien

# Unit- en integratietests (Vitest)
npm run test

# Watch-modus
npm run test -- --watch

# UI-modus
npm run test:ui

# Type-check
npx tsc --noEmit

# Lint
npm run lint

De browser-/livechecks hebben een gestarte solver, een geseede database en een gestarte app nodig (niets hiervan start zichzelf op):

# Solver starten (in ./solver)
uvicorn main:app --host 127.0.0.1 --port 8000

# Database seeden
npm run seed

# App starten
ALLOW_SEED_PASSWORD=true SESSION_SECRET=... SOLVER_URL=http://localhost:8000 npm start

Daarna:

# Volledige API-levenscyclus tegen echte data, inclusief de eigen harde
# regels van de solver op zijn onbewerkte output (geen ABSOLUUT-overtreding,
# niemand twee keer in dezelfde ISO-week, niemand's vensterregel geschonden,
# niemand voorbij zijn streefbereik)
node scripts/full-check.mjs

# De twee meest gebruikte schermen, in een echte browser
node scripts/ui-check.mjs

# Playwright e2e-suite (bouwt zijn eigen periode/toewijzingen-fixture per
# testbestand, leest dus niet de geseede periode)
npm run test:e2e

# Vergelijkt het schema dat de seed bouwt met het schema dat de migraties
# bouwen, kolom voor kolom, index voor index, foreign key voor foreign key
# - nodig omdat scripts/seed.ts (niet db/migrations) in productie het
# schema bouwt zodra SEED_ON_START=planner of true staat (de standaard)
node scripts/schema-drift.mjs

Database

De migraties in db/migrations voert de app bij het starten zelf uit (db/client.ts). Een geseede database krijgt nieuwe tabellen en kolommen via scripts/seed.ts (zie Bijwerken). Er is geen apart migratiecommando.

# Database (opnieuw) seeden
npm run seed
npm run seed -- --reset

Let op: een kolom die aan db/schema.ts wordt toegevoegd heeft drie dingen nodig, niet één: het schema, een migratie onder db/migrations, en een vermelding in scripts/seed.ts (in de CREATE TABLE, of in LATER_COLUMNS zodat een langer geleden geseede database hem ook oppikt) - anders draait een verse installatie op een ander schema dan een gemigreerde. node scripts/schema-drift.mjs bewaakt dit.

Bouwen

# Next.js production build
npm run build

# Productieserver starten
npm start

Architectuur in het kort

Databaseschema

Belangrijkste tabellen:

  • person - medewerkers (codenaam, rol, wachtwoord_hash, totp_secret, sessie_versie)
  • pool / pool_membership - dienstenpool en wie daar wanneer in zit
  • schedule_period - periodes (CONCEPT, OPEN, CLOSED, GEGENEREERD, GEPUBLICEERD)
  • shift_slot / assignment - te vullen diensten en de uiteindelijke toewijzingen
  • availability - blokkeringsvoorkeuren (ABSOLUUT, LIEVER_NIET, VOORKEUR)
  • ledger_entry - saldo-aanpassingen (append-only, nooit overschreven)
  • holiday_history - welke feestdag iemand in welk jaar had (oude import, wordt niet meer gebruikt)
  • swap_request - dienstruilverzoeken
  • audit_log - alle acties van planners/beheerders

Feestdagberekening

Alle Nederlandse feestdagen berekend met het Meeus-algoritme (lib/holidays.ts; de solver rekent zelf geen feestdagen uit, hij krijgt ze mee met de diensten): Nieuwjaarsdag, Pasen (en gerelateerde dagen), Koningsdag (met zondagcorrectie), Bevrijdingsdag (elke 5 jaar), Hemelvaart, Pinksteren, Kerst.

Over de jaren heen telt het aantal feestdagdiensten, niet welke feestdag. De app houdt per persoon bij hoeveel feestdagdiensten iemand in de eerdere gepubliceerde periodes naar verhouding had moeten krijgen (naar de tijd dat iemand meedeed) en hoeveel het er werden (lib/feestdagHistory.ts). Wie achterloopt krijgt bij het genereren voorrang, binnen het streefbereik en nooit op een geblokkeerde dag. Nieuwkomers beginnen gelijk, net als iedereen bij de eerste periode in de app.

Authenticatie

Voor medewerkers (deelnemers):

  • Persoonlijke toegangslinks (lange willekeurige token, SHA256-hash in de database)
  • Geen e-mailadressen opgeslagen (pseudoniem)
  • Alleen-lezen toegang na de deadline

Voor planner/beheerder:

  • Wachtwoord (bcrypt) + optionele TOTP-tweestapsverificatie (speakeasy)
  • Geen e-mailadres vereist
  • Sessies zijn ondertekende tokens; person.sessie_versie maakt het mogelijk om alle sessies van een account in één keer ongeldig te maken (bijv. bij uitloggen op alle apparaten of een wachtwoordwijziging)

Capaciteitscontrole

Twee formules, beide live zichtbaar in het instellingenscherm:

  1. Totale capaciteit: floor(weken / windowWeeks) * actieve_deelnemers ≥ aantal_diensten
  2. Aantal unieke mensen per venster: actieve_deelnemers ≥ 7 * windowWeeks

Formule 2 is meestal het beperkendst.

Roostergeneratie (solver)

De Python-solver (CP-SAT via OR-Tools) genereert een rooster dat nooit een harde regel schendt (ABSOLUUT-blokkade, de vensterregel, het streefbereik) en levert bij schaarste een gedeeltelijk rooster op in plaats van alles-of-niets - de planner vult de resterende gaten handmatig aan, met duidelijke aanduiding wie een gat wél of niet mag invullen.

Een venster van N weken betekent: minstens 7 × N dagen tussen twee diensten van dezelfde persoon (sinds 0.22.0). Bij 2 weken zitten er dus minstens 14 dagen tussen. Een zondag en de maandag twee weken later liggen 8 dagen uit elkaar en mogen dan niet. Bij 1 week is het minstens 7 dagen, dus nooit twee diensten in dezelfde ISO-week. Bij 0 geldt geen vensterregel. Met een apart venster voor avonddiensten en voor weekend- en feestdagdiensten geldt het kleinste van de twee ook tussen een avonddienst en een weekend- of feestdagdienst.

Conventies

Zie CLAUDE.md voor de bijgehouden conventies en ontwerpbeslissingen.

Implementatie

Voor deze fase gebouwd:

  • Projectopzet en Docker Compose-configuratie
  • Datamodel (Drizzle ORM + SQLite)
  • Authenticatie (persoonlijke links + wachtwoord + TOTP)
  • Feestdagberekening voor alle Nederlandse feestdagen
  • Capaciteitscontrole
  • Periodebeheer (venster openen/sluiten, ruleset bevriezen bij openen)
  • Voorkeuren en deeltijdpatronen
  • Roostergeneratie via de CP-SAT-solver, met diagnostiek bij gaten
  • Handmatig gaten vullen, met overrides en waarschuwingen
  • Publicatie/depublicatie van een rooster
  • Dienstruilverzoeken
  • Audit-trail van alle planner-/beheerdersacties

Implementatiedetails

Deployment

Docker Compose

Drie containers met intern netwerk:

caddy:8010 ──→ web:3000 ──→ solver:8000
  (TLS)        (Next.js)    (Python/FastAPI)
  • Caddy: TLS-terminatie met interne certificaten
  • Web: Next.js + SQLite (WAL-mode)
  • Solver: Python FastAPI (geen publieke poort, alleen bereikbaar vanuit web)

De database en de back-ups van de voorkeuren-CSV (lib/preferencesBackup.ts) staan in DATA_DIR (standaard ./data, naast docker-compose.yml), bind-mounted in de container - een gewone map in plaats van een door Docker beheerd volume, zodat deze ook direct met een bestandsbeheerder (bijv. Synology File Station) te bekijken is. De standaard ./data bestaat al bij een verse clone (leeg, via het git-getrackte data/.gitkeep) - een bind mount heeft namelijk een al bestaande hostmap nodig, in tegenstelling tot een named volume. Bij een eigen DATA_DIR moet die map zelf van tevoren worden aangemaakt.

SEED_ON_START staat standaard op planner: de eerste keer dat DATA_DIR (zie .env.example) leeg is, maakt de app het beheerdersaccount (codenaam admin), de pool, de soorten diensten, de berichtteksten en 30 deelnemers (staf01 tot en met staf30) aan. Die deelnemers hebben nog niets ingevuld. De beheerder maakt daarna zelf de planners aan. Er komt geen voorbeeldperiode bij. Veilig om aan te laten staan bij herstarts en herinstallaties, want het gebeurt maar één keer, op een echt verse database. Zet SEED_ON_START=true in .env voor een testinstallatie met voorbeelddata.

Bijwerken naar een nieuwe versie

De images worden kant-en-klaar gebouwd door GitHub. De server hoeft zelf niets te bouwen. Jij bepaalt wanneer er gebouwd wordt en wanneer productie een nieuwe versie krijgt. Dat gaat in drie stappen, allemaal op github.com/Alitemu/rooster onder Actions:

  1. Bouwen (test): kies links Bouwen (test) en daarna Run workflow. GitHub draait eerst alle tests en bouwt pas als die slagen. Daarna bouwt het de versie uit package.json en noemt die test. Dat duurt meestal een kwartier of minder. Productie merkt er niets van. Elk versienummer wordt maar één keer gebouwd. Bestaat het al, dan stopt de build met een melding: verhoog dan eerst het versienummer in package.json. Zo kan een geteste versie nooit ongemerkt vervangen worden door een nieuwere build met hetzelfde nummer.
  2. Testen: werk de testinstallatie bij (zie hieronder) en probeer de nieuwe versie uit.
  3. Vrijgeven (productie): tevreden? Kies Vrijgeven (productie), dan Run workflow en vul het versienummer in (bijvoorbeeld 0.2.3). Die versie heet dan stable. Er wordt niets opnieuw gebouwd: productie krijgt precies de versie die je getest hebt. Op GitHub komt er ook een release bij (bij Releases), met de wijzigingen sinds de vorige. De release die als laatste is gemarkeerd, is de versie in productie.

Daarna op de productieserver, in de map met docker-compose.yml:

docker compose pull
docker compose up -d

De database in DATA_DIR blijft staan. Nieuwe kolommen en tabellen worden bij het starten automatisch toegevoegd. Maak voor de zekerheid eerst een kopie van die map.

Vanaf versie 0.25.0 heten de deelnemers van de achterwacht staf01, staf02 enzovoort. Die van de AIOS heten aios01, aios02 enzovoort. Bij de eerste start van 0.25.0 krijgen Persoon-01 tot en met Persoon-30 eenmalig de codenaam staf01 tot en met staf30. Hun voorkeuren, diensten en saldi blijven gewoon bij hen. Pas dezelfde codenamen ook aan in de Excel-lijst van de Power Automate-stroom (zie docs/verzendlijst-power-automate.md), anders vindt de stroom hun e-mailadres niet en krijgen ze geen mail. Doe dat op hetzelfde moment als het bijwerken.

Vanaf versie 0.26.0 staat naast de pool van de achterwacht ook de pool AIOS, zonder deelnemers. Zolang het rooster voor de AIOS niet in productie is, ziet alleen de beheerder die pool. Een periode van de AIOS is altijd een kwartaal van woensdag tot en met dinsdag: bij een nieuwe periode in die pool kies je het kwartaal. De pagina van zo'n periode toont de diensten per week, de blokken en de punten van het kwartaal. Openen kan nog niet: het voorkeurenscherm en het maken van het rooster voor de AIOS komen in een volgende versie.

Vanaf versie 0.72.0 kan een periode van de AIOS open. De AIOS hebben daarvoor een eigen deel van de app, los van de achterwacht: alleen de inlogpagina, de database en het Gmail-account zijn gedeeld. Aan de achterwacht is niets veranderd. Op de pagina van een AIOS-periode kies je de deadline en klik je op Periode openen. Daarna verstuur je de uitnodigingen en herinneringen vanaf diezelfde pagina. De AIOS krijgen een eigen persoonlijke link (die eindigt op /aios/ met een code) naar een eigen scherm: eerst hun deeltijd en afwezigheid, dan hun verzoeken voor het kwartaal en per week alle diensten, met een avond- of nachtblok als één vak. Onder Status voorkeuren zie je per AIOS wat er is ingevuld en of iemand meer dan de helft heeft geblokkeerd. De AIOS hebben een eigen Power Automate-stroom: de beheerder vult het adres daarvan in op de pagina van een AIOS-periode, bij Mail naar de AIOS. Zolang dat leeg is, gaat er geen mail naar de AIOS. Zie docs/verzendlijst-power-automate.md. Het maken van het rooster voor de AIOS komt in een volgende versie, met een eigen solver.

Vanaf versie 0.73.0 krijgen de AIOS vanzelf een herinnering, een week en een dag voor de deadline, net als bij de achterwacht. Je pauzeert ze per periode bij Automatische herinneringen op de pagina van de AIOS-periode. Onder Status voorkeuren download je twee Excel-bestanden: Voorkeuren per dienst en Verzoeken per AIOS, met de blokken van vier, inwerken, onderbreking, parttimedagen, afwezigheid en de tellers. Blokkeert een AIOS meer dan de helft van een soort, dan staat er een waarschuwing dat het rooster het deel boven de helft alsnog kan inplannen. Wat een feestdag betekent, staat in een eigen kader.

Vanaf versie 0.74.0 is er een eigen solver voor de AIOS, in een eigen container aios-solver, los van die van de achterwacht. De solver van de achterwacht is niet veranderd. De app gebruikt de nieuwe solver pas in een volgende versie, bij het maken van het rooster voor de AIOS. Neem dan ook het nieuwe docker-compose.yml over, met de dienst aios-solver erin. Daarna gaat bijwerken weer met docker compose pull en docker compose up -d. Het geheugen van de nieuwe container zet je met AIOS_SOLVER_MEM_LIMIT in .env (standaard 2g).

Vanaf versie 0.75.0 maak je het rooster voor de AIOS in de app. Sluit de periode van de AIOS, kies op de pagina van die periode bij Rooster hoe lang de solver mag zoeken en klik op Rooster maken. Je ziet daarna het rooster per week, met een blok als één vak, en per AIOS de punten en het dienstpercentage: groen tot 25%, rood daarboven. Een dienst die leeg bleef, staat erbij met de reden. Opnieuw maken maakt het hele rooster opnieuw. Rooster verwijderen maakt alles weer leeg. Dit heeft de container aios-solver nodig: neem bij deze versie het nieuwe docker-compose.yml over (zie 0.74.0). Met de hand aanpassen en publiceren komen in een volgende versie.

Vanaf versie 0.76.0 vraagt de app eerst wat er gebeurt met de blokken rond feestdagen. Bij Rooster staat dan het kader Blokken rond feestdagen. Bij feestdagen achter elkaar (Pasen, Pinksteren, kerst) kies je per avond- en nachtblok: heel laten (één AIOS doet het hele blok) of opknippen (elke dag een losse dienst). Daar staat nog niets ingevuld: het rooster kan pas gemaakt worden als elk blok een keuze heeft. Een blok met één feestdag erin blijft heel, maar je kunt het ook opknippen. Valt 31 december in de periode, dan kies je of er op oudjaarsnacht één of twee AIOS zijn. Twee staat al ingevuld. Klik op Keuzes bewaren. De keuzes blijven bij de periode en gelden bij elke volgende keer dat je het rooster maakt. In het rooster staat een opgeknipt blok per dag en de tweede AIOS op oudjaarsnacht in de rij Nacht 2.

Vanaf versie 0.77.0 pas je het rooster van de AIOS met de hand aan. Tik in Het rooster per week op een blok of dienst. Je ziet per AIOS wat het zou kosten: een harde regel die erbij komt (zoals een compensatiedag of te weinig rust), wat die AIOS markeerde, of het blok niet meer heel is en het dienstpercentage daarna. Kies een AIOS. Met een waarschuwing vraagt de app eerst Ja, toch indelen. Een blok geef je heel of alleen één dag ervan. Leeg laten maakt de dienst leeg. Ongedaan maken zet je laatste wijziging terug. Onder het rooster staat welke harde regels het nog breekt. Opnieuw maken maakt het hele rooster opnieuw, ook wat je met de hand veranderde.

Vanaf versie 0.78.0 publiceer je het rooster van de AIOS. Onder het rooster staat Publiceren. Een lege dienst vul je eerst met de hand. Waarschuwingen (een harde regel die het rooster breekt, of een dienstpercentage boven 25%) staan erbij: publiceren kan dan toch, na Ja, toch publiceren. Bij Voorlopig publiceren krijgt elke AIOS een mail met de eigen diensten om na te kijken, via de stroom van de AIOS, en ziet die op de eigen pagina. Pas daarna aan wat de AIOS melden: met de hand, met een reden. Met Definitief maken krijgt iedereen de definitieve diensten met wat er veranderde. Publicatie intrekken zet het rooster terug, zonder mail. Voor het roosterbureau, de opleider en de afdeling download je Rooster per dag en Per AIOS met compensatiedagen als Excelbestand.

Vanaf versie 0.79.0 schuift een tekort of overschot van een AIOS door naar het volgende kwartaal, zoals bij de achterwacht: wie minder punten deed dan het doel, krijgt er het volgende kwartaal meer. Hetzelfde geldt voor feestdagdiensten over de jaren. Voor het eerste kwartaal in de app lees je de geschiedenis in bij Geschiedenis van vóór de app op de pagina van de periode. Download het sjabloon en vul per AIOS in: nachten, avonden door de week, avonden in het weekend, dagdiensten, uitloopdiensten, hoeveel daarvan op een feestdag, de gemiddelde fte en over hoeveel maanden. Klik op Controleren: je ziet per AIOS in woorden wat het betekent, zoals "5 punten minder". Klopt het, klik dan op Bewaren. In Per AIOS staat bij het rooster wat elke AIOS meeneemt.

Vanaf versie 0.80.0 ruilen de AIOS onderling, zonder de planner. Op hun eigen pagina kiezen ze bij Ruilen een eigen dienst of blok en zien ze welke collega's een gelijkwaardige dienst hebben waarmee de ruil binnen de regels blijft. Een blok ruil je tegen een blok van dezelfde soort. Een dagdienst en een avonddienst in het weekend zijn gelijkwaardig. De collega keurt goed of wijst af. Gaat een ruil door, dan krijgen de planner en het roosterbureau bericht via de stroom van de AIOS. Voeg in die stroom daarvoor de rijen Planner en Roosterbureau toe, net als bij de achterwacht. Op de pagina van de periode zie je alle ruilverzoeken.

Vanaf versie 0.81.0 is het rooster voor de AIOS in gebruik. Probeer het eerst op de testinstallatie. Zo begin je:

  1. De beheerder maakt bij Accounts beheren een account voor de planner van de AIOS: rol Planner, dienst AIOS. Geef het tijdelijke wachtwoord persoonlijk door.
  2. De planner logt in en kiest een eigen wachtwoord. Daarna ziet de planner alleen de pool AIOS en niets van de achterwacht.
  3. Bij Personeel beheren voeg je de AIOS toe: aios01, aios02 enzovoort.
  4. Maak bij + Nieuwe periode het kwartaal. De beheerder vult op de pagina van die periode bij Mail naar de AIOS het adres van de stroom van de AIOS in (zie 0.72.0).
  5. Kies de deadline, open de periode en verstuur de uitnodigingen.

Een heel kwartaal stap voor stap doorlopen op de testinstallatie, met per stap wat je moet zien: docs/aios-testdraaiboek.md.

Vanaf versie 0.82.0 vul je op de pagina van een AIOS-periode bij Voortgangstoets de datum van de toets in. In de nacht ervoor heeft geen AIOS dienst: een fellow doet die nacht. In het rooster en in de download staat daar Fellow. Wie het is, regel je buiten de app. De andere nachten van dat blok doet één AIOS, met de compensatiedagen op de twee dagen na de laatste van die nachten. Dat kan tot het rooster gepubliceerd is. Verder staat onder het dienstpercentage van elke AIOS nu ook het percentage over dit kwartaal en de drie ervoor samen, met de geschiedenis van vóór de app. Boven 25% waarschuwt de app daarover bij het publiceren. De AIOS zien het bij hun eigen rooster. En op een parttimedag krijgt een AIOS nog steeds geen uitloopdienst, maar de avond en de nacht van die dag staan nu op liever niet: het rooster plant ze alleen als het niet anders kan. De AIOS kan er zelf voorkeur of geblokkeerd van maken. Een eigen blokkade telt mee voor de 50%-regel. Bij ruilen kan een parttimer ook een avond of nacht op de parttimedag krijgen.

Vanaf versie 0.83.0 staat alleen de avond van een parttimedag op liever niet. De nacht van die dag heeft weer geen markering: voor het rooster is het een gewone nacht. Een AIOS ziet op het eigen voorkeurenscherm wanneer de voortgangstoets is, en dat de nacht ervoor daarom niet in het blok staat.

Vanaf versie 0.84.0 werken de achterwacht en de AIOS ook achter de schermen helemaal los van elkaar. Wie bij de ene dienst hoort, kan niets opslaan via de onderdelen van de andere. Een AIOS die je aan de pool toevoegt terwijl een kwartaal open staat, krijgt de eigen parttimedagen en afwezigheid meteen in dat kwartaal. En de app start ook als de solver van de AIOS niet start.

Vanaf versie 0.85.0 toont het voorkeurenscherm van de AIOS bij een parttimedag of afwezigheid ook de stand: PT✕ bij de uitloopdienst (vast) en PT~ bij de avond (liever niet, aan te passen). Zo is het ook zonder kleur en op papier duidelijk. Een AIOS kan per weekdag één parttimedag tegelijk opgeven: twee patronen op dezelfde dag telden voor de fte dubbel. Bij de geschiedenis van vóór de app weigert de import een rij met meer feestdagdiensten dan diensten.

Vanaf versie 0.86.0 verandert er niets meer aan een achterwachtperiode in de prullenbak. Open je hem via een bladwijzer of een tabblad dat nog openstond, dan staat bovenaan dat hij in de prullenbak staat. Openen, genereren of publiceren geeft dan de melding Deze periode staat in de prullenbak. Zet hem eerst terug. Haal je bij de AIOS de voortgangstoets weg of verschuif je hem nadat het rooster gemaakt is, dan gaat de nacht die terugkomt naar de AIOS die de rest van dat blok heeft. Kan dat niet, dan zegt de app waarom en vul je die nacht met de hand in. En Ongedaan maken na Wisselen zet een dienst van de achterwacht weer helemaal terug: een nieuwe generatie mag hem daarna weer verplaatsen. De pagina van een AIOS-periode klapt nu in en uit zoals die van de achterwacht. Alle onderdelen staan eerst dicht. Klik op een titel om het te openen. Een ander onderdeel openen klapt het vorige dicht, tenzij je het met de punaise vastzet.

Vanaf versie 0.87.1 zegt de app bij het maken van het rooster van de AIOS wat er misging. De solver van de AIOS is niet bereikbaar betekent dat er niets antwoordt op het adres van de solver: de container aios-solver draait niet, of staat niet in je docker-compose.yml. Controleer met docker compose ps dat hij draait. Neem anders het nieuwe docker-compose.yml over en draai docker compose pull en docker compose up -d. De solver van de AIOS stopte tijdens het maken betekent dat de verbinding wegviel terwijl hij rekende, bijvoorbeeld omdat de container geen geheugen meer had. Kijk met docker compose logs aios-solver --tail 50 en zet zo nodig meer geheugen bij AIOS_SOLVER_MEM_LIMIT in .env. Staat er een foutcode, dan zit de fout in de app zelf. Zoek die code op met docker compose logs web | grep <foutcode>.

Vanaf versie 0.87.0 stelt alleen de beheerder de mail in, bij de achterwacht net als bij de AIOS. Een planner ziet onder Exporteren & communicatie geen knop Mailinstellingen meer, maar wel hoe de mail ervoor staat. Zolang de mail niet is ingesteld, staan alle knoppen die iets versturen zichtbaar uit, met de reden erbij. Bij de AIOS zijn dat Uitnodigingen versturen en Herinnering versturen. Bij de achterwacht zijn dat Herinnering sturen per persoon en aan iedereen, Deadlineherinnering versturen en de keuzes om uitnodigingen of herinneringen te versturen. Downloaden kan wel. Bij de AIOS kun je de persoonlijke links dan zelf rondsturen met Links downloaden.

Terug naar een eerdere versie gaat op dezelfde manier: geef bij Vrijgeven (productie) het oudere versienummer op. Of zet ROOSTER_VERSION=0.2.3 in .env om de server op één versie vast te zetten.

Op poort 443 met een eigen naam

Op een server met een eigen DNS-naam, bijvoorbeeld https://rooster.voorbeeld.nl, zet je in .env:

APP_PORT=443

en laat je BASE_URL leeg. De app bepaalt dan zelf zijn adres: uit de naam waarmee de planner de app opent. Caddy maakt een certificaat voor elke naam waarmee de server wordt aangesproken. Een nieuwe naam vraagt dus geen aanpassing in .env of Caddyfile. Na een naamswijziging gebruiken automatische herinneringen en ruilmails het nieuwe adres zodra de planner via die naam iets verstuurt (uitnodigingen, een herinnering). Links in mails die al verstuurd zijn, bevatten de oude naam. Laat die oude naam daarom nog een tijd naar de server wijzen.

Wil je dat http://rooster.voorbeeld.nl ook werkt, maak dan naast docker-compose.yml het bestand docker-compose.override.yml:

services:
  caddy:
    ports:
      - "80:80"

Caddy stuurt alles op poort 80 dan door naar https. docker compose leest dat bestand vanzelf mee. Het staat niet standaard in docker-compose.yml, omdat poort 80 op een NAS meestal al bezet is.

Staat er een andere reverse proxy van de organisatie voor, dan moet die de oorspronkelijke naam (de Host-header) ongewijzigd doorgeven. Anders kent de app zijn eigen adres niet.

Draaien in een submap

De app kan onder een submap draaien, bijvoorbeeld https://<nas>/achterwacht in plaats van https://<nas>:8010. Handig als het adres al door een andere website wordt gebruikt.

  • De submap wordt bij het bouwen vastgelegd. Bij Bouwen (test) vul je hem in bij basispad, bijvoorbeeld /achterwacht. Standaard is het leeg: geen submap.
  • Zet in .env op de server:
    BASE_PATH=/achterwacht
    BASE_URL=https://<nas>/achterwacht
    BASE_PATH gebruikt Caddy om https://<nas>/ door te sturen naar de submap (en is nodig als je zelf bouwt met --build). BASE_URL zorgt dat de links in mails de submap bevatten.
  • Ongedaan maken: bouw opnieuw met een leeg basispad, haal BASE_PATH weg uit .env en zet BASE_URL terug op het adres zonder submap. Links in mails die al verstuurd zijn, bevatten nog de submap en werken dan niet meer; stuur zo nodig de uitnodigingen opnieuw.
  • De poort staat hier los van: APP_PORT=443 werkt alleen als de NAS die poort zelf niet al gebruikt (een Synology doet dat vaak voor DSM).

Komt de app later achter de webserver van een andere site, onder een submap van diens adres, dan moet die server alles onder de submap ongewijzigd doorsturen naar de NAS, met het IP-adres van de bezoeker erbij. Let op: de app deelt dan het adres met die andere site, en een zwakke plek daar raakt ook Dienstrooster. Een eigen (sub)domein is veiliger.

Een aparte testinstallatie

Zet op dezelfde NAS een tweede kopie van de app in een eigen map, met in .env:

ROOSTER_VERSION=test
APP_PORT=8011
DATA_DIR=./data
MAIL_UITGESCHAKELD=true

Een eigen map betekent eigen containers, een eigen database en een eigen poort (hier https://:8011). Productie blijft onaangeroerd. Bijwerken gaat met dezelfde twee commando's in die map.

Open de testinstallatie in de browser via een ander adres dan productie, bijvoorbeeld de naam van de NAS voor test en het IP-adres voor productie. Browsers houden cookies per adres bij, maar niet per poort: met hetzelfde adres zouden beide installaties elkaars inlogcookie overschrijven, en met een gekopieerde datamap zou een sessie van productie ook op test gelden.

MAIL_UITGESCHAKELD=true zorgt dat de testinstallatie nooit mail verstuurt, wat er ook bij Mailinstellingen staat. Bovenaan de periodepagina staat dan een melding dat versturen uit staat. Dat is belangrijk als je test met echte gegevens: kopieer je de map data van productie naar de testmap, dan gaan de mailinstellingen mee. Zonder deze regel zou de testinstallatie echte uitnodigingen, herinneringen en ruilmails naar echte deelnemers sturen, de automatische herinneringen al binnen een minuut na het starten.

Zelf bouwen vanuit de broncode kan nog steeds, met docker compose up -d --build.

TLS-certificaat vertrouwen (de browserwaarschuwing oplossen)

Caddy geeft zelf certificaten uit vanuit een eigen, interne CA (pki { ca internal ... } in Caddyfile) - bewust, want deze installatie heeft geen vaste domeinnaam en is alleen bedoeld voor het interne netwerk (alleen caddy publiceert een poort, zie docker-compose.yml). Browsers kennen die CA nog niet, vandaar de "niet veilig"-waarschuwing. Er is niets mis met de verbinding zelf - alleen het vertrouwen ontbreekt.

Optie 1 - het interne CA-certificaat vertrouwen (past bij dit ontwerp). Geen domeinnaam of internetverbinding nodig. Eenmalig per apparaat dat de app gaat gebruiken:

  1. Haal het root-certificaat uit de caddy_data-volume:
    docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt > dienstrooster-ca.crt
  2. Installeer dat bestand als vertrouwde basis-CA:
    • Windows: dubbelklik → "Certificaat installeren" → "Lokale computer" → "Alle certificaten in het volgende archief opslaan" → "Vertrouwde basiscertificeringsinstanties"
    • macOS: open in Sleutelhangertoegang → sleep naar de "Systeem"-sleutelhanger → dubbelklik → "Vertrouwen" → "Altijd vertrouwen"
    • Android: Instellingen → Beveiliging → Certificaat installeren → CA-certificaat
    • iOS: installeer het bestand als configuratieprofiel (Instellingen → Algemeen → VPN en apparaatbeheer), zet daarna Instellingen → Algemeen → Info → Certificaatvertrouwensinstellingen aan voor dit certificaat
    • Linux: kopieer naar /usr/local/share/ca-certificates/, dan sudo update-ca-certificates

Let op een paar dingen:

  • Dit moet op elk apparaat (pc, laptop, telefoon) dat de app gaat gebruiken, eenmalig gebeuren - elk besturingssysteem houdt zijn eigen lijst met vertrouwde CA's bij.
  • Firefox is een uitzondering op elk platform: die gebruikt niet de CA-lijst van het besturingssysteem maar een eigen lijst, dus daar moet het certificaat apart geïmporteerd worden (Instellingen → Privacy & Beveiliging → Certificaten → Certificaten bekijken → Autoriteiten → Importeren).
  • De CA blijft hetzelfde zolang de caddy_data-volume blijft bestaan (dus ook na een herstart van de server) - dit is dus eenmalig per apparaat, niet iets dat bij elke herstart opnieuw moet.
  • Bij 20-40 medewerkers is het meestal efficiënter om dit certificaat centraal uit te rollen via de IT-afdeling (Group Policy/Active Directory voor Windows, of een MDM-oplossing zoals Intune/Jamf voor telefoons) dan om het per apparaat handmatig te doen.

Optie 2 - een echt, publiek vertrouwd certificaat (Let's Encrypt). Alleen mogelijk met een eigen domeinnaam die vanaf het publieke internet bereikbaar is op poort 80/443 - wat haaks staat op het huidige "alleen intern netwerk"-ontwerp. Vervang in dat geval in Caddyfile de regel https://:{$APP_PORT:8010} door https://jouw-domein.voorbeeld.nl en verwijder het blok tls internal { on_demand }; Caddy vraagt dan zelf automatisch een Let's Encrypt-certificaat aan en vernieuwt dat ook zelf. Voordeel: geen enkel apparaat hoeft dan nog iets te installeren.

Productie

  1. docker compose up op eigen hardware (geen cloud/Kubernetes vereist).
  2. Een schone start: dat is de standaard (SEED_ON_START=planner). De app maakt het beheerdersaccount admin, de pool, de soorten diensten, de berichtteksten en 30 deelnemers (staf01 tot en met staf30) aan, zonder voorkeuren en zonder periode. Bij de eerste keer inloggen kies je een eigen wachtwoord. Planners maak je aan bij Accounts beheren. Deelnemers pas je aan, voeg je toe of zet je uit via de pagina van de pool.
  3. Back-ups: de app maakt elke dag zelf een kopie van de database in data/backups/database/ (rooster-JJJJ-MM-DD.db) en bewaart de laatste 14. Zo'n kopie is compleet, ook terwijl de app draait. Kopieer rooster.db niet zelf terwijl de app draait: de laatste wijzigingen kunnen dan nog in rooster.db-wal staan. Neem de map data/backups/database/ mee in de back-up van de NAS zelf (bijvoorbeeld Hyper Backup), zodat er ook een kopie buiten de NAS is. Bewaar ook de sleutel. Het Gmail-app-wachtwoord en de geheimen voor tweestapsverificatie staan versleuteld in de database. De sleutel daarvoor staat in data/.session_secret (of in SESSION_SECRET in .env, als je die zelf hebt ingesteld). Dat bestand zit niet in de dagelijkse kopie. Omdat de naam met een punt begint, is het in bestandsbeheer vaak verborgen en slaat een back-up het soms over. Bewaar een kopie op een veilige plek, niet in dezelfde map als de back-ups: wie de database en de sleutel samen heeft, kan het app-wachtwoord ontsleutelen. Zonder de sleutel werkt een teruggezette database wel, maar moet iedereen opnieuw inloggen, moet het app-wachtwoord opnieuw in Mailinstellingen en moet tweestapsverificatie per account worden uitgezet met scripts/reset-totp.ts (zie Beveiliging).
  4. Terugzetten: docker compose down, verwijder data/rooster.db, data/rooster.db-wal en data/rooster.db-shm, kopieer de gewenste back-up naar data/rooster.db en start weer met docker compose up -d. Op een nieuwe NAS zet je eerst ook data/.session_secret terug. Probeer dit één keer uit voordat de installatie echt in gebruik gaat.
  5. Grenzen: de solver mag hooguit 2 GB geheugen gebruiken, de app 1 GB (SOLVER_MEM_LIMIT, WEB_MEM_LIMIT in .env). Logbestanden van elke container blijven onder de 30 MB.

E-mail versturen (Gmail en Power Automate)

Dienstrooster slaat geen e-mailadressen op. Uitnodigingen, herinneringen (ook de automatische) en meldingen over ruilverzoeken gaan als één verzendlijst naar je eigen mailbox. Een Power Automate-stroom zoekt daar elke codenaam op in jouw Excel-lijst en mailt de persoon.

Hoe je Gmail, het .env-bestand, de Excel-lijst en de stroom instelt, staat stap voor stap in docs/verzendlijst-power-automate.md. Had je de stroom al gebouwd? Kijk dan onderaan die handleiding bij "Had je de stroom al gebouwd?".

Beveiliging

  • Geen inloggegevens in de repository (.env staat in .gitignore)
  • Startwachtwoord: het wachtwoord dat de seed voor admin instelt, staat openbaar in deze repository. Wie ermee inlogt, moet eerst een eigen wachtwoord kiezen en kan daarvoor niets anders.
  • TOTP-tweestapsverificatie voor planner/beheerder, zelf te beheren. Zet dit aan: het is de beste bescherming tegen iemand die wachtwoorden probeert te raden vanaf veel adressen tegelijk. De sleutel staat versleuteld in de database. Kan iemand niet meer inloggen omdat de telefoon weg is of de sleutel op de server is veranderd, dan zet de beheerder het uit met docker compose exec -u node web npx tsx scripts/reset-totp.ts <codenaam>.
  • Wachtwoord vergeten: er is bewust geen "wachtwoord vergeten" op de inlogpagina. De app kent geen e-mailadres om een link naar te sturen, en een herstel dat iedereen op de inlogpagina kan starten, is ook een weg naar binnen. Wie bij de NAS kan, zet een nieuw wachtwoord met docker compose exec -u node web npx tsx scripts/reset-password.ts <codenaam>. Het script vraagt het nieuwe wachtwoord twee keer, zonder het te tonen. Daarvoor gelden dezelfde regels als in de app. Het account wordt overal uitgelogd en de wijziging staat in de wijzigingsgeschiedenis. Tweestapsverificatie blijft aan. Is ook de telefoon weg, draai dan daarna ook reset-totp.ts. Is er een beheerder, dan kan die dit ook in de app doen (zie hieronder).
  • Beheerder: een beheerder kan alles wat een planner kan en beheert daarnaast de accounts, bij Accounts beheren op de lijst met periodes: een planner of beheerder toevoegen, iemand een tijdelijk wachtwoord geven, tweestapsverificatie uitzetten, een account uitschakelen of de rol wijzigen. Bij een nieuw account kies je ook de dienst: Achterwacht of AIOS. Een planner ziet alleen de pools, periodes, roosters en voorkeuren van de eigen dienst. De planner van de achterwacht en die van de AIOS zien dus niets van elkaar. De beheerder ziet beide diensten. Een deelnemer hoort bij één dienst: iemand van de achterwacht kan niet ook in de pool van de AIOS. De mailinstellingen gelden voor beide diensten samen. Alleen de beheerder kan ze wijzigen, een planner ziet ze alleen. Een tijdelijk wachtwoord moet bij de eerste keer inloggen worden vervangen, en de sessies van dat account worden beëindigd. Je eigen account beheer je met Wachtwoord wijzigen en Tweestapsverificatie. Een nieuwe installatie begint met één account: de beheerder admin, met het startwachtwoord. Log meteen na de eerste start in en kies een eigen wachtwoord: tot dan kan iedereen die de inlogpagina bereikt het account claimen. Daarna maak je de planners aan, met een naam naar keuze. Een bestaande installatie houdt haar planner en krijgt admin erbij als er nog geen beheerder is. Dat gebeurt bij elke start, wat er ook bij SEED_ON_START staat. planner kun je daarna uitschakelen bij Accounts beheren. Zijn alle wachtwoorden vergeten, dan is er op de server docker compose exec -u node web npx tsx scripts/reset-password.ts admin, en met docker compose exec -u node web npx tsx scripts/create-account.ts <codenaam> maak je daar een nieuwe beheerder aan. Beide scripts vragen het wachtwoord twee keer, zonder het te tonen. Met --planner maakt create-account een planner, met --beheerder maakt het van een bestaande planner een beheerder.
  • BASE_URL in .env: zet dit op het adres waarop deelnemers de app openen, bijvoorbeeld https://192.168.1.10. Dan wijzen alle links in mails daarheen.
  • Grenzen: een verzoek mag hooguit 1 MB zijn (Caddy en de app zelf), tekst die een deelnemer typt hooguit 1000 tekens.
  • Alleen TLS via Caddy (interne certificaten zijn acceptabel binnen een ziekenhuisnetwerk)
  • Persoonlijke links met SHA256-hashing (alleen de hash staat in de database)
  • Audit-trail van elke planner-/beheerdersactie
  • Pseudoniem: geen herleidbare persoonsgegevens in de database

Teststrategie

Uit CLAUDE.md: "Eén regel = één test die bewijst dat hij niet gebroken kan worden." Voorbeeld: de vensterregel ("iemand met een dienst in week 12 heeft geen dienst in week 11 of 13") heeft een test die bewijst dat de regel een overtreding tegenhoudt, niet slechts een voorbeeld van correct gedrag.

Kritieke testgebieden:

  1. Feestdagberekening (2024-2035, inclusief schrikkeljaren)
  2. ISO-weekafronding en periodegrenzen
  3. Capaciteitsformules
  4. Authenticatie: tokenhashing, sessieversies, TOTP-verificatie
  5. Harde solver-regels: ABSOLUUT, vensterregel, streefbereik, geen dubbele week

Draai met npm run test (unit/integratie) en npm run test:e2e (Playwright, met draaiende app/solver/database).

Bijdragen

  1. Lees eerst CLAUDE.md
  2. Volg de codeconventies (TypeScript strict, geen default exports behalve waar Next.js die vereist, Nederlandse gebruikerstekst, enz.)
  3. Schrijf een test voor elke harde regel die je toevoegt
  4. Gebruik betekenisvolle commitberichten
  5. Leg ontwerpvragen die CLAUDE.md niet beantwoordt eerst voor

Licentie

Alleen voor intern gebruik.

Meer informatie

Zie CLAUDE.md voor:

  • Codestructuur en bestandsindeling
  • Databaseconventies (tekenconventie, constraints, ledger-regels)
  • UI-terminologie (Nederlandse vertalingen van interne termen)
  • Bekende valkuilen en hoe ze te voorkomen

Zie docs/verzendlijst-power-automate.md voor het instellen van e-mail via Gmail en Power Automate.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages