Compare commits

...
33 Commits
Author SHA1 Message Date
ferdi2go 9c7062104a 1 2026-09-07 13:58:23 +00:00
ferdi2goandClaude Opus 4.8 189bcb6f38 test: add jsdom render + persistence integration tests (#13)
- render.test.mjs drives the real UI under jsdom: employees tab renders and
  add works, planning tab renders the calendar grid (makeDayCell), generate
  fills slots, a manual slot change updates history, and a beforeunload flush
  persists state to localStorage; data tab renders the history view.
- persistence.test.mjs seeds malformed persisted state (employee without
  constraints, out-of-range moderatorsPerDay) and asserts the startup load
  normalizes it without crashing.
- jsdom added as the single devDependency; node_modules gitignored; both
  suites wired into npm test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:03:00 +00:00
ferdi2goandClaude Opus 4.8 836d0eeda5 chore: unify name to "Meeting Mixer", README tech stack, name magic numbers (#16)
- Canonical name "Meeting Mixer" everywhere: <title>, README, .vch-description
  (now non-technical per convention) and package.json (meeting-mixer).
- README gains a Tech-Stack section and an accurate, non-misleading port note.
- Magic numbers replaced by named constants: LOW_PRIORITY_PENALTY in
  algorithm.js; MIN/MAX_MODERATORS_PER_DAY in utils.js, used by data.js,
  calendar.js and app.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:44:45 +00:00
ferdi2goandClaude Opus 4.8 b3eeca50b3 a11y: associate form labels with inputs (#15)
Month/year/slots selects get ids with matching <label for>; the employee name
input, the Excel import file input and the JSON import file input get
aria-labels so screen readers announce them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:06:50 +00:00
ferdi2goandClaude Opus 4.8 7ab2911c2f fix(robustness): parseInt radix, minGapDays null-guard, log silent catches (#14)
- parseInt(month/year) now passes radix 10 consistently.
- minGap eligibility guards null/undefined (c.minGapDays != null && > 0).
- The corrupt-localStorage load and the createEmployee catch now console.warn
  instead of swallowing the error silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:05:34 +00:00
ferdi2goandClaude Opus 4.8 fd9870871b refactor: centralize shared date/name helpers in utils.js (#12)
New js/utils.js exports toISO, monthPrefix, getWeekday, formatDateDE and
buildNameMap. algorithm.js, calendar.js, excel.js and history.js now import
these instead of re-implementing ISO construction, weekday math, DD.MM.YYYY
formatting and the id→name map inline. Adds tests/utils.test.mjs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:01:28 +00:00
ferdi2goandClaude Opus 4.8 0efb9cb853 perf(state/assets): debounce saveState, defer SheetJS script (#11)
- saveState is now debounced (~400ms) to coalesce rapid changes; a pending
  write is flushed on beforeunload and on visibilitychange→hidden so the last
  change is never lost.
- The SheetJS <script> gets defer so it no longer blocks initial parse; the
  typeof XLSX guard already covers any not-yet-available case.

Verified headless: app loads without JS/CSP errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:54:41 +00:00
ferdi2goandClaude Opus 4.8 8cd8908f38 perf(calendar): re-render only the changed day cell, not the whole grid (#10)
Extracts the day-cell construction into a self-contained makeDayCell(d). Slot
changes and holiday/closure toggles now replace just that one cell
(cell.replaceWith) plus update export-button visibility, instead of rebuilding
the entire month grid and re-attaching every listener on each interaction.
Structural changes (month/year/slot-count) still do a full render.

Faithful extraction; verified headless that the page loads without JS errors
and tests pass. Interactive render coverage to follow in the test-layer issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:51:16 +00:00
ferdi2goandClaude Opus 4.8 c076b5e1ce perf(algorithm): precompute history count, drop per-candidate sort (#9)
- generatePlan computes a histCount Map once; scoreCandidate now does an O(1)
  lookup instead of filtering the whole history per candidate per day.
- minGap eligibility finds the latest prior date in a single pass instead of
  spreading history+monthAssignments into a new array and sorting it.

Behaviour is unchanged (all algorithm tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:44:45 +00:00
ferdi2goandClaude Opus 4.8 074635c037 docs(security): document localStorage privacy + note ID fallback (#8)
- README gains a "Datenschutz & Speicherung" section: local single-user tool,
  unencrypted localStorage, avoid shared devices, export JSON to back up.
- Comments the generateId Math.random fallback as a non-crypto last resort,
  acceptable for local employee ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:40:58 +00:00
ferdi2goandClaude Opus 4.8 e6452fa102 fix(excel): neutralize formula injection in exported names (#7)
Adds sanitizeCell(): cell values starting with = + - @ or a control char are
prefixed with an apostrophe so spreadsheets render them as literal text
instead of executing them as formulas. Applied to the names column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:37:32 +00:00
ferdi2goandClaude Opus 4.8 f8efcf136a fix(security): add SRI to SheetJS CDN + Content-Security-Policy (#6)
- SheetJS <script> now carries a sha384 integrity hash and crossorigin so a
  compromised/MITM'd CDN cannot inject code.
- Adds a CSP meta: default-src 'self'; script-src limited to self +
  cdn.sheetjs.com; style-src self + Google Fonts (+inline for app styles);
  font-src gstatic; object-src 'none'; base-uri 'self'.

Verified headless against localhost: no CSP violations, SheetJS loads with
a valid integrity digest, app.js renders normally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:33:34 +00:00
ferdi2goandClaude Opus 4.8 142e0efa1f fix(employees): remove history entries when deleting an employee (#5)
removeEmployee now also filters the deleted employee's history entries, so
no orphaned "(unbekannt)" rows remain. Adds a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:29:16 +00:00
ferdi2goandClaude Opus 4.8 7b53618921 fix(state): surface localStorage save failures instead of silent loss (#4)
saveState now logs a console.warn on failure and alerts the user once per
session (storage full/unavailable) so they can export their data and avoid
silent data loss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:21:23 +00:00
ferdi2goandClaude Opus 4.8 065876ced4 fix(import): validate/normalize imported data, guard Excel I/O (#3)
- loadFromJSON and the localStorage startup load now run employees through
  normalizeEmployees(): fills missing constraints, drops unusable rows,
  de-duplicates ids, and discards invalid vacation ranges — preventing
  later crashes (e.g. c.neverDays on undefined) and same-day double-books.
- Excel import is wrapped in try/catch with a typeof XLSX guard and user
  feedback; the Excel export button gets the same guard.
- createEmployee reuses the shared defaultConstraints() factory.

Adds normalizeEmployees tests (defaults, dropping, dedup, vacations) and a
loadFromJSON normalization test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:20:41 +00:00
ferdi2goandClaude Opus 4.8 e851bb2833 fix(calendar): preserve manual picks when regenerating a month (#2)
generatePlan gains a preAssigned parameter: manual entries occupy slots,
count toward eligibility (maxPerMonth/minGap) and are never double-booked;
only the remaining slots per day are auto-filled. The generate handler now
keeps manual:true entries for the month instead of wiping them.

Adds tests for slot occupation, fully-manual days, and maxPerMonth counting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:17:22 +00:00
ferdi2goandClaude Opus 4.8 700ec6de1c fix(calendar): use dynamic year range instead of hardcoded 2026 (#1)
currentYear now initializes to the current year and the year selector
offers a rolling range (this year -1 to +2) so plans can be created
for years other than 2026.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:12:18 +00:00
ferdi2goandClaude Opus 4.8 6736021af1 feat(employees): default minGapDays to 14, robust ID fallback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:11:36 +00:00
ferdi2go 294de5276b feat(history): sort by date desc + name asc, delete single entry 2026-04-28 11:37:31 +00:00
ferdi2go 078d74f09f feat(calendar): pass slotsPerDay to generator, show skipped-dates warning 2026-04-28 11:26:53 +00:00
ferdi2go fa28640c28 feat(calendar): N moderator slots per day with disable-on-duplicate 2026-04-28 11:17:08 +00:00
ferdi2go 3b84fce1e6 feat(calendar): add 'Pro Tag' moderator-count selector 2026-04-28 11:09:42 +00:00
ferdi2go 69e424519d feat(excel): group entries by date, join names with ' & ' 2026-04-28 11:03:05 +00:00
ferdi2go ad680ae1f6 feat: state.settings.moderatorsPerDay with localStorage persistence 2026-04-28 10:48:58 +00:00
ferdi2go a825a63961 feat(data): settings.moderatorsPerDay in load/save JSON 2026-04-28 10:38:51 +00:00
ferdi2go 7bda92a0a3 feat(data): removeHistoryEntry supports optional employeeId 2026-04-28 10:31:38 +00:00
ferdi2go 23683102bc feat(data): dedupe history by (date, employeeId) 2026-04-28 10:21:18 +00:00
ferdi2go 3d314e84fd feat(algo): skip day when candidates < slotsPerDay 2026-04-28 10:07:22 +00:00
ferdi2go 625f9c7037 feat(algo): slotsPerDay parameter for top-N selection 2026-04-28 09:59:44 +00:00
ferdi2go 790ea72987 refactor: generatePlan returns {assignments, skippedDates} 2026-04-28 09:55:25 +00:00
ferdi2goandClaude Opus 4.7 e4ae866375 chore: ui polish — extract inline styles to CSS classes, add header art
- Move inline style.cssText to semantic classes (cal-legend, cal-header-cell,
  cal-day-tag-label, cal-btn-row) in calendar.js
- Buttons "FT"/"BU" → "Feiertag"/"Betriebsurl." for clarity
- Decorative turntable/mixer SVG header art in index.html
- style.css build-out for the new classes
- Initial README content
- Dev port 3000 → 3001

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 09:43:53 +00:00
ferdi2goandClaude Opus 4.7 6fd2cfcf04 docs: implementation plan for multiple moderators per day
14 Tasks, bottom-up: algorithmus → data.js → app.js (persistence) → UI
(calendar/history/css). TDD wo möglich (algorithm/data/excel mit Tests),
manuelle Verifikation für UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 09:31:43 +00:00
ferdi2goandClaude Opus 4.7 b794581a06 docs: spec for multiple moderators per day
Globale Einstellung moderatorsPerDay (1-5), Top-N-Algorithmus, dynamische
N Slots pro Tag im Kalender, Excel joint Namen mit " & ", Verlauf zeigt
eine Zeile pro Person.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 09:24:42 +00:00
24 changed files with 3907 additions and 247 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
node_modules/
+1
View File
@@ -0,0 +1 @@
Meeting Mixer – Werkzeug zur fairen, automatischen Einteilung von Moderatoren für wiederkehrende Meetings. Es verteilt die Moderation gleichmäßig über das Team, berücksichtigt Verfügbarkeiten und Urlaube und erstellt fertige Monatspläne zum Export. Für Teams, die ihre Meeting-Moderation organisiert rotieren möchten.
+112
View File
@@ -0,0 +1,112 @@
# Project
## Environment
You are working inside a VCH cloud development environment.
- **OS:** Debian-based LXC container
- **Editor:** code-server (VS Code in browser) on port 8080
- **CLI:** Claude Code is available as `claude` in terminal
- **Git:** Pre-configured. Gitea integration available through VCH.
- **Preview:** Start a dev server on any port — VCH detects listening ports automatically and provides preview URLs with SSL.
- **Publishing:** Assign a public subdomain to any port via the VCH Publish page (automatic SSL).
- **User:** `vchuser` with home at `/home/vchuser`
## Preview Access (IMPORTANT)
Apps are accessed through a **reverse proxy**, not directly. There are two modes:
1. **Subdomain proxy** (preferred): `https://lxc{VMID}-{PORT}.dev.example.com/` — your app is at the domain root, everything works normally.
2. **Path-based proxy** (fallback): `https://host/proxy/{INSTANCE}/p/{PORT}/` — your app is behind a path prefix.
### Rules for portable apps that work in both modes:
- **ALWAYS use relative paths** for assets, API calls, and links:
- `css/style.css` ✅ — NOT `/css/style.css` ❌
- `api/users` ✅ — NOT `/api/users` ❌
- `fetch('api/data')` ✅ — NOT `fetch('/api/data')` ❌
- **Static file serving:** Configure your server to serve from the request path, not the filesystem root.
- **`<base>` tag:** If you must use absolute paths, add `<base href="./">` in `<head>`.
### Framework-specific configuration:
- **Express:** `app.use(express.static('public'))` works — just ensure HTML references are relative.
- **Vite:** Set `base: './'` in `vite.config.ts`.
- **Next.js:** Set `basePath` in `next.config.js` if using path-based proxy, or leave default for subdomain proxy.
- **Create React App:** Set `"homepage": "."` in `package.json`.
## Development Conventions
- Write clear, descriptive commit messages (imperative mood: "Add feature", not "Added feature")
- Prefer small, focused commits over large ones
- Run linters and formatters before committing
- Write tests for critical business logic
- Keep dependencies minimal — use native browser/Node APIs where possible
## README Requirement (MANDATORY)
Every project MUST have a meaningful `README.md` in the project root. This is enforced by the VCH audit system — projects without a proper README cannot be published.
Your README must include at minimum:
- **Project description** — what the project does (not just the template placeholder)
- **Installation instructions** — how to install dependencies
- **Development instructions** — how to start the dev server
- **Tech stack** — what technologies are used
Update the README whenever you add features, change setup steps, or modify the tech stack. The audit will reject READMEs that are still just the default template.
## Project Description (MANDATORY)
Every project has a `.vch-description` file in the project root. Keep this file up to date with a clear, non-technical description of your project:
- What the project does
- What problem it solves or what it's used for
- Who it's for
Do NOT include technical details (tech stack, dependencies, setup instructions) — those belong in the README. The `.vch-description` content is shown in the VCH Showcase and is synced automatically when an audit runs.
Update `.vch-description` whenever the project's purpose or scope changes significantly.
## Web Best Practices
- Use semantic HTML elements (`nav`, `main`, `article`, `section`, etc.)
- Mobile-first responsive design
- Follow accessibility guidelines (ARIA labels, keyboard navigation, color contrast)
- Optimize images and assets for performance
- Use environment variables for configuration — never hardcode secrets
## Commands
Fill in project-specific commands below:
- **Install dependencies:** `npm install`
- **Start dev server:** `npm run dev`
- **Run tests:** `npm test`
- **Build for production:** `npm run build`
- **Lint:** `npm run lint`
## Deploy preparation
If this project should be deployed and reads environment variables from `process.env` (or equivalent), create `.env.example` at the repo root. List every variable the app reads, one per line: `KEY=example-value`. Example values are hints only — they are **not** used in production. If the project needs no env vars, no file is required.
Vibecoders' tip: when in doubt, run `grep -RhoE "process\.env\.[A-Z_]+" src/ | sort -u` and put every result into `.env.example`.
**Listen on `PORT` (IMPORTANT).** VCH assigns every deployed app its own host port automatically — you never pick or coordinate ports, and two projects on the same machine never clash. Your app MUST bind the port from the `PORT` environment variable, not a hardcoded one:
- Node/Express: `app.listen(process.env.PORT || 3000)`
- Next.js: `next start` honours `PORT` automatically
- Vite preview / other servers: pass `--port "$PORT"` (or read `process.env.PORT`)
A hardcoded port works locally but fails the production health-check (VCH checks the assigned port, which usually isn't 3000). For Docker, set `port:` below to the port your app listens on *inside* the container — VCH maps the auto-assigned host port to it for you.
**Containerized projects (Docker).** If your project has a `Dockerfile` (or a `compose.yaml`/`docker-compose.yml`), add a `.vch/deploy.yaml` at the repo root so VCH deploys it correctly:
```yaml
runtime: docker
port: 3000 # the port your app LISTENS ON inside the container
health: / # a path that returns 2xx/3xx when the app is up
```
- VCH builds your image, runs the container with `--restart=always`, injects production env vars via `--env-file` at runtime, and health-checks `port`.
- **Build-time variables:** only variables prefixed `NEXT_PUBLIC_`, `VITE_`, or `PUBLIC_` are passed to the build as `--build-arg` (they are client-visible by convention). Secrets are runtime-only and are never baked into the image — declare matching `ARG` lines in your Dockerfile for the public ones.
- **Compose:** the app service MUST publish its port as `ports: ["${VCH_PORT}:<internal>"]`. VCH sets `VCH_PORT` so it can run an isolated candidate stack during audits without touching your live stack. Add `env_file: [.env]` to any service that needs production env vars — VCH writes your production variables to a `.env` file next to your compose file.
- If you omit `.vch/deploy.yaml`, VCH falls back to the Dockerfile `EXPOSE` port (or `3000`).
+51
View File
@@ -0,0 +1,51 @@
# Meeting Mixer
Web-App zur Planung und Verwaltung von Meeting-Moderatoren.
## Features
- **Mitarbeiterverwaltung** – Mitarbeiter anlegen, bearbeiten und deaktivieren
- **Monatsplanung** – Kalenderbasierte Zuweisung von Moderatoren pro Tag
- **Algorithmus** – Automatische, gleichmäßige Verteilung der Moderationen auf alle aktiven Mitarbeiter
- **Excel-Export** – Fertige Monatsplanung als XLSX-Datei exportieren
- **JSON Import/Export** – Kompletten Datenstand sichern und wiederherstellen
- **Verlauf** – Vergangene Monatspläne einsehen und wiederherstellen
- **Persistenz** – Automatische Speicherung im Browser (localStorage)
## Tech-Stack
- **Vanilla JavaScript (ES-Module)** – kein Framework, **kein Build-Schritt**
- **Persistenz:** Browser-`localStorage` (kein Backend, keine Datenbank)
- **Excel-Im-/Export:** [SheetJS](https://sheetjs.com/) (per CDN, mit SRI eingebunden)
- **Statisches Serving:** [`serve`](https://www.npmjs.com/package/serve) (Dev)
- **Tests:** Node-Assertions (`node:assert`); DOM-/Render-Tests via [jsdom](https://github.com/jsdom/jsdom) (einzige devDependency)
Voraussetzung: ein moderner Browser mit ES-Modul-Unterstützung.
## Starten
```bash
npm start
```
Startet einen statischen Server. Der genutzte Port wird beim Start ausgegeben
(Standard 3001; weicht automatisch aus, falls belegt). Öffne anschließend die
angezeigte URL im Browser.
## Tests
```bash
npm test
```
## Datenschutz & Speicherung
Diese App ist ein **lokales Single-User-Werkzeug**: Alle Daten (Mitarbeiternamen,
Urlaube, Pläne, Verlauf) werden ausschließlich **im Browser** (`localStorage`)
gespeichert. Es gibt keinen Server und keine Übertragung an Dritte. Die Daten
liegen **unverschlüsselt** vor und sind für jeden zugänglich, der Zugriff auf
dasselbe Browser-Profil hat.
- App **nicht auf geteilten/öffentlichen Geräten** für sensible Daten nutzen.
- Datenstand regelmäßig über **JSON-Export** (Tab „Daten") sichern.
- Beim Leeren der Browserdaten gehen alle nicht exportierten Daten verloren.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,192 @@
# Mehrere Moderatoren pro Tag
## Ziel
Die Morning Meeting Planner App soll pro Tag **N Moderatoren** zuweisen können (statt bislang fix 1). N ist eine globale Einstellung mit gültigem Bereich 1–5, Default **1** (sowohl bei Neuinstallation als auch wenn das Feld in vorhandenen Daten fehlt — Abwärtskompatibilität, Bestandsverhalten unverändert). Der Nutzer hebt den Wert in der UI bewusst auf 2 (oder höher) an.
Sichtbarstes Ergebnis: Excel-Export joint mehrere Personen eines Tages mit `" & "` zu `"Vorname1 Nachname1 & Vorname2 Nachname2"`.
## Entscheidungen aus dem Brainstorming
| # | Entscheidung |
|---|---|
| 1 | Globale Einstellung `moderatorsPerDay` (1–5), nicht pro Tag konfigurierbar |
| 2 | Wenn weniger Kandidaten verfügbar als `moderatorsPerDay` → Tag bleibt **leer**, Warnung im UI |
| 3 | Algorithmus: **Top-N nach Einzelscore**, keine Pairing-Vielfalt |
| 4 | UI: **N dynamische Dropdowns** pro Tag, dieselbe Person nicht zweimal wählbar |
| 5 | Setting-Control: **oben im Planungs-Reiter**, neben Monat/Jahr |
| 6 | Datenmodell: **flach**, mehrere Einträge pro Datum erlaubt |
## Datenmodell
### Neues Feld in `state`
```js
state.settings = { moderatorsPerDay: 2 } // gültig: 1, 2, 3, 4, 5
```
- App-Start ohne `settings` im LocalStorage → Default `{ moderatorsPerDay: 1 }` (Bestandsnutzer behalten Verhalten).
- `loadFromJSON` ohne `settings` → ebenfalls `{ moderatorsPerDay: 1 }`.
- Wert wird in `localStorage` und in `saveToJSON` mitpersistiert.
### History-Einträge
Form **unverändert**: `{ date: "YYYY-MM-DD", employeeId: "...", manual: bool }`.
Neu: **mehrere Einträge mit demselben `date` sind erlaubt**. Eindeutigkeit liegt jetzt auf `(date, employeeId)`.
### Geänderte Dedup-Logik in `data.js`
`addHistoryEntries(state, entries)`:
- Vorher: löschte alle History-Einträge mit Datum aus `entries.map(e => e.date)`.
- Nachher: löscht nur Einträge mit demselben `(date, employeeId)`-Tupel wie ein eingefügter Eintrag.
`removeHistoryEntry(state, isoDate, employeeId?)`:
- Ohne `employeeId`: löscht **alle** Einträge des Tages (Robustheits-Fallback).
- Mit `employeeId`: löscht nur den einen Eintrag — Standardfall für Slot-Editierungen und Verlauf-✕.
Plan-Generierung verwendet weiterhin den bestehenden Mechanismus (Prefix-Filter entfernt den ganzen Monat aus History, danach werden die neu generierten Einträge per `addHistoryEntries` hinzugefügt). Eine separate `replaceDayEntries`-Funktion ist nicht nötig.
## Algorithmus
### Signatur-Änderung in `algorithm.js`
```js
generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1)
→ { assignments, skippedDates }
```
- Alter Rückgabewert war `Array<assignment>`. Neu: Objekt `{ assignments, skippedDates }`. Es gibt nur einen Aufrufer (`calendar.js`), der wird hart umgestellt.
### Pro Tag
1. Eligible Kandidaten ermitteln (wie bisher: `neverDays`, `onlyDays`, `vacations`, `maxPerMonth`, `minGapDays`).
2. Zusätzlicher Filter: Mitarbeiter, die heute schon in `monthAssignments` stehen, sind nicht erneut wählbar (verhindert Doppelung im selben Tag).
3. Wenn `candidates.length < slotsPerDay`: Tag überspringen, `iso` an `skippedDates` anhängen.
4. Sonst: alle scoren (`scoreCandidate` unverändert), nach Score absteigend sortieren, Top-`slotsPerDay` nehmen.
5. Für jede gewählte Person ein Eintrag `{ date, employeeId, manual: false }` an `monthAssignments` hängen — **bevor** der nächste Tag verarbeitet wird (damit `maxPerMonth`/`minGapDays` korrekt zählen).
### Scoring
Unverändert: weniger bisherige Einsätze → höherer Score, `lowPriorityDays` → −5, Jitter ±0.49.
## UI: Planungs-Reiter
### Neue Setting-Steuerung
Neben den bestehenden Monat/Jahr-Selects, in derselben `controls`-Row:
```
Pro Tag: [Select 1 | 2 | 3 | 4 | 5]
```
- Initialwert: `state.settings.moderatorsPerDay`.
- Bei Änderung: `state.settings.moderatorsPerDay = N`, `saveState()`, `renderGrid()`.
- Existierende Einträge im Monat bleiben unverändert; nur die Zahl der sichtbaren Slots pro Zelle ändert sich.
### Pro Werktag-Zelle (Wochentag, kein Feiertag/Betriebsurlaub)
- **N Dropdowns** untereinander rendern (`N = state.settings.moderatorsPerDay`).
- Jeder Dropdown: `— Leer —` als erste Option, dann alle aktiven Mitarbeiter.
- Vorhandene History-Einträge des Tages **alphabetisch nach Mitarbeiternamen sortieren**, dann auf die ersten Slots verteilen. Restliche Slots zeigen `— Leer —`.
- Im Slot bereits gewählte Personen werden in den anderen Slots desselben Tages **disabled** (verhindert Doppel-Auswahl).
- **Mehr Einträge als Slots** (Setting wurde reduziert): überzählige Einträge bleiben in `state.history` gespeichert, werden im Kalender nicht gezeigt, aber im Excel-Export weiterhin berücksichtigt.
### Slot-Änderungen
- Wert auf einen Mitarbeiter setzen:
- Falls Slot vorher leer: `addHistoryEntries(state, [{ date, employeeId, manual: true }])`.
- Falls Slot vorher belegt war: erst `removeHistoryEntry(state, date, prevEmployeeId)`, dann `addHistoryEntries(...)`.
- Wert auf `— Leer —`: `removeHistoryEntry(state, date, prevEmployeeId)`.
- `currentPlan` synchron mitführen, `saveState()` nach jeder Änderung.
### Plan-Generierung
- Aktuelle Monatseinträge aus History per Prefix-Filter entfernen (`state.history = state.history.filter(h => !h.date.startsWith(prefix))`).
- `generatePlan(..., state.settings.moderatorsPerDay)` aufrufen.
- `result.assignments` per `addHistoryEntries` in History schreiben.
- `result.skippedDates`: nicht-blockierende **Inline-Warn-Box** über dem Kalender:
> „2 Tage konnten nicht vollständig besetzt werden: 14.04., 22.04."
- Box hat ein dezentes Warn-Styling (gelb/orange), schließbar via ✕.
- Bei `skippedDates.length === 0` keine Box rendern.
## Excel-Export
### Format
Unverändert: 2-Zeilen-Layout (Namen oben, Datum unten), eine Spalte pro Tag mit ≥ 1 Eintrag.
**Zellinhalt der Namen-Zeile:**
- 1 Person: `"Vorname Nachname"`
- 2 Personen: `"Vorname1 Nachname1 & Vorname2 Nachname2"`
- N ≥ 3: `"A & B & C"` (mit `" & "` joinen)
- **Reihenfolge:** alphabetisch nach Mitarbeitername — deterministisch, unabhängig von Insertion-Order.
### Verhalten bei Diskrepanz Setting ↔ History
Der Export folgt **der History**, nicht dem aktuellen `moderatorsPerDay`. Hat ein Tag 2 Einträge gespeichert und das Setting steht aktuell auf 1, exportiert die App trotzdem beide Personen (joined mit `" & "`). Begründung: User hat das so eingetragen, niemand soll stillschweigend „verschwinden".
### Implementierung in `excel.js` `exportSchedule`
- Eingabe-Form unverändert: `assignments: [{ date, employeeId }]` — aber jetzt mehrere Einträge mit demselben `date` möglich.
- Vor dem Spalten-Aufbau: nach `date` gruppieren, je Gruppe Namen alphabetisch sortieren, mit `" & "` joinen.
- Spaltenreihenfolge: Datum aufsteigend.
## Verlauf-Tab
`history.js` zeigt eine Tabelle `Datum | Mitarbeiter | Typ | ✕`.
Mit mehreren Personen pro Tag:
- **Eine Zeile pro Eintrag** (nicht pro Tag). Bei 2 Personen am 14.04. erscheinen 2 Zeilen.
- Sortierung: Datum **absteigend**, sekundär Name **aufsteigend** (stabile Tagesgruppen).
- ✕-Button: löscht **nur diese eine Person/diesen einen Eintrag** via `removeHistoryEntry(state, date, employeeId)`.
Beispiel-Render:
```
14.04.2026 | Anna Müller | Auto | ✕
14.04.2026 | Bernd Schulz | Auto | ✕
13.04.2026 | Carla Weber | Manuell | ✕
```
## Persistenz / Migration
- `app.js`: `STORAGE_KEY` bleibt; gespeichertes Objekt wird um `settings` erweitert.
- Beim Laden ohne `settings`: Default `{ moderatorsPerDay: 1 }` setzen — keine Verhaltensänderung für bestehende Nutzer, bis sie im UI umstellen.
- `data.js` `saveToJSON` / `loadFromJSON` analog erweitern.
- Bestehende JSON-Dateien (Pre-Feature) laden ohne Migration — sie haben einfach 1 Eintrag pro Datum, was im neuen Modell ein gültiger Spezialfall ist.
## Tests
`tests/algorithm.test.mjs`:
**Bestehende Tests** rufen `generatePlan` ohne `slotsPerDay` auf → Default 1 → müssen aber auf neue Rückgabe-Form `{ assignments, skippedDates }` umgestellt werden.
**Neue Tests:**
1. `slotsPerDay=2`: 2 verschiedene Personen pro Tag, Top-2 nach Score.
2. Eine Person darf am selben Tag **nicht doppelt** vorkommen.
3. `skippedDates` enthält Tage mit `< slotsPerDay` Kandidaten (z. B. 1 Mitarbeiter mit `onlyDays=[1]` an einem Mo, `slotsPerDay=2`).
4. `maxPerMonth` zählt korrekt, wenn eine Person an mehreren Tagen 2-Slot-Generierung trifft (nicht öfter als `maxPerMonth` zugewiesen).
5. `minGapDays`-Test bleibt grün auch bei `slotsPerDay=2`.
## Edge Cases
| Situation | Verhalten |
|---|---|
| Setting 2→1 reduziert, Tag hat 2 History-Einträge | History bleibt unverändert; Kalender zeigt nur Slot 1 (alphabetisch erste); Excel-Export zeigt weiterhin beide |
| Setting 1→2 erhöht, alle Tage haben 1 Eintrag | Slot 2 zeigt `— Leer —`; „Plan generieren" überschreibt den ganzen Monat (Reset-Semantik) |
| Mitarbeiter gelöscht, der in History steht | Anzeige `(unbekannt)` wie heute; Eintrag bleibt erhalten, bis manuell entfernt |
| Tag hat 0 Einträge | Wie heute: nicht in Excel-Export, alle Slots leer im Kalender |
| User wählt in Slot 2 dieselbe Person wie in Slot 1 | Verhindert: Person ist in Slot 2 disabled |
## Änderungs-Übersicht (zur Implementierungsschätzung)
| Datei | Änderung |
|---|---|
| `js/app.js` | `state.settings` ergänzen, persistieren |
| `js/data.js` | Dedup nach `(date, employeeId)`, `removeHistoryEntry` mit optionalem `employeeId`, `loadFromJSON`/`saveToJSON` um `settings` |
| `js/algorithm.js` | `slotsPerDay`-Param, neuer Rückgabewert, „nicht doppelt am selben Tag"-Filter |
| `js/calendar.js` | Setting-Selector, N Dropdowns pro Zelle, Slot-Disable, Skipped-Dates-Warnbox, Plan-Gen-Aufruf mit `slotsPerDay` |
| `js/excel.js` | Gruppierung nach Datum, alphabetisches Join mit `" & "` |
| `js/history.js` | Sortierung Datum desc, Name asc; ✕ löscht einzelnen Eintrag |
| `style.css` | ggf. kleines Spacing für mehrere Dropdowns pro Zelle, Warnbox-Styling |
| `tests/algorithm.test.mjs` | Neue Tests, Anpassung an neue Rückgabe-Form |
+111 -3
View File
@@ -3,13 +3,121 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Morning Meeting Planner</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdn.sheetjs.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; object-src 'none'; base-uri 'self'">
<title>Meeting Mixer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<script defer src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js" integrity="sha384-EnyY0/GSHQGSxSgMwaIPzSESbqoOLSexfnSMN2AP+39Ckmn92stwABZynq1JyzdT" crossorigin="anonymous"></script>
</head>
<body>
<header>
<h1>Morning Meeting Planner</h1>
<h1>Meeting Mixer</h1>
<div class="header-graphic" aria-hidden="true">
<svg viewBox="0 0 200 160" xmlns="http://www.w3.org/2000/svg">
<!-- ── Platter shadow glow ── -->
<circle cx="80" cy="84" r="68" fill="rgba(200,169,81,0.04)"/>
<!-- ── Turntable platter body ── -->
<circle cx="80" cy="84" r="62" fill="#111111" stroke="#1e1e1e" stroke-width="1"/>
<!-- ── Vinyl grooves (concentric rings) ── -->
<circle cx="80" cy="84" r="56" fill="none" stroke="#1c1c1c" stroke-width="0.8"/>
<circle cx="80" cy="84" r="50" fill="none" stroke="#1c1c1c" stroke-width="0.8"/>
<circle cx="80" cy="84" r="44" fill="none" stroke="#1d1d1d" stroke-width="0.7"/>
<circle cx="80" cy="84" r="38" fill="none" stroke="#1d1d1d" stroke-width="0.7"/>
<circle cx="80" cy="84" r="32" fill="none" stroke="#1e1e1e" stroke-width="0.7"/>
<circle cx="80" cy="84" r="26" fill="none" stroke="#1e1e1e" stroke-width="0.7"/>
<!-- ── Label area border (gold ring) ── -->
<circle cx="80" cy="84" r="21" fill="#161616" stroke="rgba(200,169,81,0.45)" stroke-width="1.2"/>
<!-- ── Label inner ── -->
<circle cx="80" cy="84" r="19" fill="#1a1a1a"/>
<!-- ── Label text ── -->
<text x="80" y="88" text-anchor="middle"
font-family="'Bebas Neue', sans-serif"
font-size="13" fill="rgba(200,169,81,0.65)"
letter-spacing="3">MM</text>
<!-- ── Spindle ── -->
<circle cx="80" cy="84" r="3.5" fill="none" stroke="#c8a951" stroke-width="1.5"/>
<circle cx="80" cy="84" r="1.5" fill="#c8a951" opacity="0.9"/>
<!-- ── Outer platter rim ── -->
<circle cx="80" cy="84" r="62" fill="none" stroke="rgba(200,169,81,0.18)" stroke-width="1.5"/>
<!-- ── Tonearm pivot base ── -->
<circle cx="158" cy="24" r="7" fill="#161616" stroke="#c8a951" stroke-width="1.5"/>
<circle cx="158" cy="24" r="2.5" fill="#c8a951" opacity="0.8"/>
<!-- ── Tonearm tube (two lines = tube shape) ── -->
<line x1="158" y1="24" x2="115" y2="70"
stroke="#c8a951" stroke-width="2.5"
stroke-linecap="round" opacity="0.9"/>
<line x1="157" y1="27" x2="114" y2="73"
stroke="#8c7435" stroke-width="1"
stroke-linecap="round" opacity="0.5"/>
<!-- ── Headshell (cartridge) ── -->
<polygon points="111,66 118,73 113,78 106,71"
fill="#c8a951" opacity="0.85"/>
<!-- ── Needle contact indicator ── -->
<line x1="108" y1="74" x2="100" y2="79"
stroke="rgba(200,169,81,0.5)" stroke-width="1"
stroke-dasharray="2,2"/>
<circle cx="99" cy="80" r="2" fill="#c8a951" opacity="0.6"/>
<!-- ── Mixer panel (right side, abstract) ── -->
<!-- Panel background -->
<rect x="148" y="58" width="44" height="90" rx="2"
fill="#161616" stroke="#2a2a2a" stroke-width="1"/>
<!-- Fader track 1 -->
<rect x="157" y="66" width="3" height="48" rx="1.5"
fill="#0d0d0d" stroke="#2a2a2a" stroke-width="0.5"/>
<rect x="154" y="88" width="9" height="8" rx="1"
fill="#c8a951" opacity="0.85"/>
<!-- Fader track 2 -->
<rect x="170" y="66" width="3" height="48" rx="1.5"
fill="#0d0d0d" stroke="#2a2a2a" stroke-width="0.5"/>
<rect x="167" y="80" width="9" height="8" rx="1"
fill="#c8a951" opacity="0.6"/>
<!-- Fader track 3 -->
<rect x="183" y="66" width="3" height="48" rx="1.5"
fill="#0d0d0d" stroke="#2a2a2a" stroke-width="0.5"/>
<rect x="180" y="100" width="9" height="8" rx="1"
fill="#c8a951" opacity="0.45"/>
<!-- Knob row (top of mixer panel) -->
<circle cx="158" cy="62" r="3.5" fill="#1e1e1e" stroke="rgba(200,169,81,0.4)" stroke-width="1"/>
<line x1="158" y1="59.5" x2="158" y2="62" stroke="#c8a951" stroke-width="1" opacity="0.7"/>
<circle cx="171" cy="62" r="3.5" fill="#1e1e1e" stroke="rgba(200,169,81,0.4)" stroke-width="1"/>
<line x1="170" y1="59.8" x2="171" y2="62" stroke="#c8a951" stroke-width="1" opacity="0.7"/>
<circle cx="184" cy="62" r="3.5" fill="#1e1e1e" stroke="rgba(200,169,81,0.4)" stroke-width="1"/>
<line x1="183" y1="59.7" x2="184" y2="62" stroke="#c8a951" stroke-width="1" opacity="0.7"/>
<!-- Panel label bar -->
<rect x="148" y="120" width="44" height="14" rx="0"
fill="#111" stroke="#2a2a2a" stroke-width="0.5"/>
<rect x="152" y="123" width="4" height="2" rx="0.5" fill="rgba(200,169,81,0.3)"/>
<rect x="158" y="123" width="4" height="2" rx="0.5" fill="rgba(200,169,81,0.3)"/>
<rect x="164" y="123" width="12" height="2" rx="0.5" fill="rgba(200,169,81,0.15)"/>
<!-- EQ line (abstract waveform under mixer) -->
<polyline
points="148,140 152,136 156,142 160,133 164,138 168,130 172,137 176,134 180,139 184,132 188,137 192,135"
fill="none" stroke="rgba(200,169,81,0.3)" stroke-width="1.2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</header>
<nav class="tabs">
+47 -28
View File
@@ -1,13 +1,7 @@
function toISO(year, month, day) {
return year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0');
}
import { toISO, getWeekday } from './utils.js';
// Returns 1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr, 6=Sa, 7=So
function getWeekday(isoDate) {
const d = new Date(isoDate + 'T12:00:00Z');
const day = d.getUTCDay(); // 0=Sun
return day === 0 ? 7 : day;
}
// Score penalty applied when a candidate is low-priority on the given weekday.
const LOW_PRIORITY_PENALTY = 5;
function isOnVacation(employee, isoDate) {
return employee.constraints.vacations.some(v => v.from <= isoDate && isoDate <= v.to);
@@ -46,29 +40,30 @@ function isEligible(employee, isoDate, monthAssignments, allHistory) {
if (count >= c.maxPerMonth) return false;
}
if (c.minGapDays > 0) {
const allEntries = [...allHistory, ...monthAssignments];
const lastDate = allEntries
.filter(a => a.employeeId === employee.id && a.date < isoDate)
.map(a => a.date)
.sort()
.at(-1);
if (c.minGapDays != null && c.minGapDays > 0) {
// Latest prior assignment date for this employee — single pass, no sort/alloc
let lastDate = null;
const isLater = a => a.employeeId === employee.id && a.date < isoDate &&
(lastDate === null || a.date > lastDate);
for (const a of allHistory) if (isLater(a)) lastDate = a.date;
for (const a of monthAssignments) if (isLater(a)) lastDate = a.date;
if (lastDate && daysBetween(lastDate, isoDate) < c.minGapDays) return false;
}
return true;
}
// randomFn injectable so tests can pass () => 0.5 for deterministic output
function scoreCandidate(employee, isoDate, allHistory, randomFn) {
// randomFn injectable so tests can pass () => 0.5 for deterministic output.
// histCount is a precomputed Map<employeeId, number> of past assignments.
function scoreCandidate(employee, isoDate, histCount, randomFn) {
const wd = getWeekday(isoDate);
const myTotal = allHistory.filter(h => h.employeeId === employee.id).length;
const myTotal = histCount.get(employee.id) || 0;
// Fewer past assignments → higher (less negative) score
let score = -myTotal;
// Low priority: increase negative score (less likely to be picked)
if (employee.constraints.lowPriorityDays.includes(wd)) score -= 5;
if (employee.constraints.lowPriorityDays.includes(wd)) score -= LOW_PRIORITY_PENALTY;
// Jitter ±0.49
score += (randomFn() - 0.5) * 0.98;
@@ -76,22 +71,46 @@ function scoreCandidate(employee, isoDate, allHistory, randomFn) {
return score;
}
// Returns [{ date, employeeId, manual: false }] for each assigned workday
export function generatePlan(workdays, employees, history, randomFn = Math.random) {
const monthAssignments = [];
// Returns { assignments: [{ date, employeeId, manual: false }], skippedDates: [] }
// slotsPerDay controls how many top-scored candidates are picked per day (default 1).
export function generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1, preAssigned = []) {
// preAssigned entries (e.g. manual picks for this month) are kept fixed: they
// occupy slots, count toward eligibility, and are never double-booked. Only
// the remaining slots per day are auto-filled. Returns only the newly
// generated assignments (not the preAssigned ones).
const monthAssignments = [...preAssigned];
const generated = [];
const skippedDates = [];
// Precompute each employee's history count once (used for fairness scoring)
const histCount = new Map();
for (const h of history) histCount.set(h.employeeId, (histCount.get(h.employeeId) || 0) + 1);
for (const date of workdays) {
const taken = monthAssignments.filter(a => a.date === date);
const needed = slotsPerDay - taken.length;
if (needed <= 0) continue; // already fully staffed (e.g. by manual entries)
const takenIds = new Set(taken.map(a => a.employeeId));
const candidates = employees.filter(e =>
isEligible(e, date, monthAssignments, history)
!takenIds.has(e.id) && isEligible(e, date, monthAssignments, history)
);
if (candidates.length === 0) continue;
if (candidates.length < needed) {
skippedDates.push(date);
continue;
}
const scored = candidates
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
.map(e => ({ employee: e, score: scoreCandidate(e, date, histCount, randomFn) }))
.sort((a, b) => b.score - a.score);
monthAssignments.push({ date, employeeId: scored[0].employee.id, manual: false });
for (let i = 0; i < needed; i++) {
const entry = { date, employeeId: scored[i].employee.id, manual: false };
monthAssignments.push(entry);
generated.push(entry);
}
}
return monthAssignments;
return { assignments: generated, skippedDates };
}
+42 -9
View File
@@ -1,6 +1,8 @@
import { renderEmployees } from './employees.js';
import { renderCalendar } from './calendar.js';
import { renderDataTab } from './history.js';
import { normalizeEmployees } from './data.js';
import { MIN_MODERATORS_PER_DAY, MAX_MODERATORS_PER_DAY } from './utils.js';
const STORAGE_KEY = 'morning-planner-state';
@@ -8,7 +10,8 @@ const STORAGE_KEY = 'morning-planner-state';
export const state = {
employees: [],
calendar: { holidays: [], companyClosures: [] },
history: []
history: [],
settings: { moderatorsPerDay: 1 }
};
// Load persisted state from localStorage on startup
@@ -16,27 +19,57 @@ try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed.employees)) state.employees = parsed.employees;
if (Array.isArray(parsed.employees)) state.employees = normalizeEmployees(parsed.employees);
if (parsed.calendar) state.calendar = parsed.calendar;
if (Array.isArray(parsed.history)) state.history = parsed.history;
if (parsed.settings && Number.isInteger(parsed.settings.moderatorsPerDay)
&& parsed.settings.moderatorsPerDay >= MIN_MODERATORS_PER_DAY
&& parsed.settings.moderatorsPerDay <= MAX_MODERATORS_PER_DAY) {
state.settings = { moderatorsPerDay: parsed.settings.moderatorsPerDay };
}
}
} catch {
// Corrupt storage — start fresh
} catch (e) {
// Corrupt storage — start fresh, but leave a breadcrumb for debugging
console.warn('Gespeicherter Zustand konnte nicht gelesen werden, starte leer:', e);
}
// Call after any state mutation to persist automatically
export function saveState() {
// Persist immediately. Used by the debounced saveState and the flush handlers.
let storageWarned = false;
let saveTimer = null;
function writeState() {
if (saveTimer !== null) { clearTimeout(saveTimer); saveTimer = null; }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
employees: state.employees,
calendar: state.calendar,
history: state.history
history: state.history,
settings: state.settings
}));
} catch {
// Storage full or unavailable — silently ignore
} catch (e) {
// Storage full or unavailable — warn once so the user can react (export data)
console.warn('Speichern in localStorage fehlgeschlagen:', e);
if (!storageWarned) {
storageWarned = true;
alert('Achtung: Änderungen konnten nicht gespeichert werden ' +
'(Speicher voll oder nicht verfügbar). Bitte exportiere deine Daten ' +
'als JSON, um Datenverlust zu vermeiden.');
}
}
}
// Call after any state mutation. Debounced to coalesce rapid changes; a pending
// write is flushed on page hide/unload so the last change is never lost.
export function saveState() {
if (saveTimer !== null) clearTimeout(saveTimer);
saveTimer = setTimeout(writeState, 400);
}
const flushPending = () => { if (saveTimer !== null) writeState(); };
window.addEventListener('beforeunload', flushPending);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushPending();
});
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
+214 -121
View File
@@ -2,12 +2,14 @@ import { state, saveState } from './app.js';
import { getWorkdays, generatePlan } from './algorithm.js';
import { toggleHoliday, toggleClosure, addHistoryEntries, removeHistoryEntry } from './data.js';
import { exportSchedule, downloadBlob } from './excel.js';
import { toISO, monthPrefix, getWeekday, buildNameMap,
MIN_MODERATORS_PER_DAY, MAX_MODERATORS_PER_DAY } from './utils.js';
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember'];
const WD_LABELS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
let currentYear = 2026;
let currentYear = new Date().getFullYear();
let currentMonth = Math.min(Math.max(new Date().getMonth() + 1, 1), 12);
let currentPlan = []; // [{ date, employeeId, manual }] for displayed month
@@ -27,8 +29,10 @@ export function renderCalendar() {
const monthLabel = document.createElement('label');
monthLabel.textContent = 'Monat:';
monthLabel.htmlFor = 'cal-month';
const monthSel = document.createElement('select');
monthSel.id = 'cal-month';
MONTHS.forEach((m, i) => {
const opt = document.createElement('option');
opt.value = String(i + 1);
@@ -39,13 +43,32 @@ export function renderCalendar() {
const yearLabel = document.createElement('label');
yearLabel.textContent = 'Jahr:';
yearLabel.htmlFor = 'cal-year';
const yearSel = document.createElement('select');
const yearOpt = document.createElement('option');
yearOpt.value = '2026';
yearOpt.textContent = '2026';
yearOpt.selected = true;
yearSel.appendChild(yearOpt);
yearSel.id = 'cal-year';
const thisYear = new Date().getFullYear();
for (let y = thisYear - 1; y <= thisYear + 2; y++) {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
if (y === currentYear) opt.selected = true;
yearSel.appendChild(opt);
}
const slotsLabel = document.createElement('label');
slotsLabel.textContent = 'Pro Tag:';
slotsLabel.htmlFor = 'cal-slots';
const slotsSel = document.createElement('select');
slotsSel.id = 'cal-slots';
for (let n = MIN_MODERATORS_PER_DAY; n <= MAX_MODERATORS_PER_DAY; n++) {
const opt = document.createElement('option');
opt.value = String(n);
opt.textContent = String(n);
if (n === state.settings.moderatorsPerDay) opt.selected = true;
slotsSel.appendChild(opt);
}
const genBtn = document.createElement('button');
genBtn.type = 'button';
@@ -57,16 +80,22 @@ export function renderCalendar() {
exportBtn.textContent = 'Als Excel exportieren';
exportBtn.style.display = 'none';
controls.append(monthLabel, monthSel, yearLabel, yearSel, genBtn, exportBtn);
controls.append(monthLabel, monthSel, yearLabel, yearSel, slotsLabel, slotsSel, genBtn, exportBtn);
container.appendChild(controls);
// Legend
const legend = document.createElement('div');
legend.className = 'row';
legend.style.cssText = 'font-size:12px;color:#6b7280';
legend.textContent = 'FT = Feiertag markieren | BU = Betriebsurlaub markieren';
legend.className = 'cal-legend';
legend.innerHTML =
'<span><span class="cal-legend-dot" style="background:#fde68a"></span>Feiertag</span>' +
'<span><span class="cal-legend-dot" style="background:#fecaca"></span>Betriebsurlaub</span>';
container.appendChild(legend);
const warnBox = document.createElement('div');
warnBox.id = 'skipped-warning';
warnBox.style.display = 'none';
container.appendChild(warnBox);
// Calendar grid wrapper
const wrapper = document.createElement('div');
wrapper.className = 'calendar-outer';
@@ -77,17 +106,23 @@ export function renderCalendar() {
renderGrid(wrapper, exportBtn);
monthSel.addEventListener('change', () => {
currentMonth = parseInt(monthSel.value);
currentMonth = parseInt(monthSel.value, 10);
loadPlanFromHistory();
renderGrid(wrapper, exportBtn);
});
yearSel.addEventListener('change', () => {
currentYear = parseInt(yearSel.value);
currentYear = parseInt(yearSel.value, 10);
loadPlanFromHistory();
renderGrid(wrapper, exportBtn);
});
slotsSel.addEventListener('change', () => {
state.settings.moderatorsPerDay = parseInt(slotsSel.value, 10);
saveState();
renderGrid(wrapper, exportBtn);
});
genBtn.addEventListener('click', () => {
if (state.employees.length === 0) {
alert('Bitte zuerst Mitarbeiter hinzufügen.');
@@ -95,22 +130,33 @@ export function renderCalendar() {
}
const workdays = getWorkdays(currentYear, currentMonth,
state.calendar.holidays, state.calendar.companyClosures);
const prefix = currentYear + '-' + String(currentMonth).padStart(2,'0');
const prefix = monthPrefix(currentYear, currentMonth);
// Preserve manual picks for this month; only auto-entries get regenerated.
const manualEntries = state.history.filter(h => h.date.startsWith(prefix) && h.manual);
const historyWithoutMonth = state.history.filter(h => !h.date.startsWith(prefix));
currentPlan = generatePlan(workdays, state.employees, historyWithoutMonth);
addHistoryEntries(state, currentPlan);
// Drop stale auto-entries for the month, keep history + manual picks
state.history = historyWithoutMonth.concat(manualEntries);
const result = generatePlan(workdays, state.employees, historyWithoutMonth,
Math.random, state.settings.moderatorsPerDay, manualEntries);
addHistoryEntries(state, result.assignments);
currentPlan = manualEntries.concat(result.assignments);
saveState();
renderSkippedWarning(warnBox, result.skippedDates);
renderGrid(wrapper, exportBtn);
});
exportBtn.addEventListener('click', () => {
if (typeof XLSX === 'undefined') {
alert('Excel-Export nicht verfügbar (SheetJS konnte nicht geladen werden).');
return;
}
const blob = exportSchedule(currentPlan, state.employees);
downloadBlob(blob, 'Moderationsplan-' + MONTHS[currentMonth-1] + '-' + currentYear + '.xlsx');
});
}
function loadPlanFromHistory() {
const prefix = currentYear + '-' + String(currentMonth).padStart(2,'0');
const prefix = monthPrefix(currentYear, currentMonth);
currentPlan = state.history
.filter(h => h.date.startsWith(prefix))
.map(h => ({ ...h }));
@@ -120,15 +166,129 @@ function renderGrid(wrapper, exportBtn) {
wrapper.replaceChildren();
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
const nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
const planByDate = Object.fromEntries(currentPlan.map(a => [a.date, a.employeeId]));
const empNameById = buildNameMap(state.employees);
const updateExportVisibility = () => {
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
};
// Build one day cell. Self-contained so a change to a single day re-renders
// just that cell (cell.replaceWith) instead of rebuilding the whole grid.
function makeDayCell(d) {
const iso = toISO(currentYear, currentMonth, d);
const wdNum = getWeekday(iso);
const isWeekend = wdNum >= 6;
const isHoliday = state.calendar.holidays.includes(iso);
const isClosure = state.calendar.companyClosures.includes(iso);
const cell = document.createElement('div');
cell.className = 'cal-day';
if (isWeekend) cell.classList.add('weekend');
else if (isHoliday) cell.classList.add('holiday');
else if (isClosure) cell.classList.add('closure');
const dayNum = document.createElement('div');
dayNum.className = 'day-num';
dayNum.textContent = d + ' ';
const wdSpan = document.createElement('span');
wdSpan.className = 'day-tag';
wdSpan.textContent = WD_LABELS[wdNum - 1];
dayNum.appendChild(wdSpan);
cell.appendChild(dayNum);
// Re-render only this cell after a change affecting this day
const refresh = () => { cell.replaceWith(makeDayCell(d)); updateExportVisibility(); };
if (!isWeekend) {
if (isHoliday || isClosure) {
const tag = document.createElement('div');
tag.className = 'cal-day-tag-label';
tag.textContent = isHoliday ? 'Feiertag' : 'Betriebsurlaub';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = '✕';
removeBtn.addEventListener('click', () => {
if (isHoliday) toggleHoliday(state, iso);
else toggleClosure(state, iso);
saveState();
refresh();
});
tag.appendChild(removeBtn);
cell.appendChild(tag);
} else {
const slotCount = state.settings.moderatorsPerDay;
const dayEntries = currentPlan
.filter(a => a.date === iso)
.sort((x, y) =>
(empNameById[x.employeeId] ?? '').localeCompare(empNameById[y.employeeId] ?? '', 'de'));
const slotValues = []; // employeeId per slot index, '' if empty
for (let s = 0; s < slotCount; s++) slotValues.push(dayEntries[s]?.employeeId ?? '');
for (let slotIdx = 0; slotIdx < slotCount; slotIdx++) {
const sel = document.createElement('select');
sel.dataset.slot = String(slotIdx);
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '— Leer —';
sel.appendChild(emptyOpt);
for (const emp of state.employees) {
const opt = document.createElement('option');
opt.value = emp.id;
opt.textContent = emp.name; // textContent: safe
// Disable if already picked in a different slot for this day
const pickedElsewhere = slotValues.some((id, i) => i !== slotIdx && id === emp.id);
if (pickedElsewhere) opt.disabled = true;
if (emp.id === slotValues[slotIdx]) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener('change', () => {
const newId = sel.value;
const oldId = slotValues[slotIdx];
if (oldId) {
removeHistoryEntry(state, iso, oldId);
currentPlan = currentPlan.filter(a => !(a.date === iso && a.employeeId === oldId));
}
if (newId) {
const entry = { date: iso, employeeId: newId, manual: true };
addHistoryEntries(state, [entry]);
currentPlan.push(entry);
}
saveState();
refresh();
});
cell.appendChild(sel);
}
const btnRow = document.createElement('div');
btnRow.className = 'cal-btn-row';
const ftBtn = document.createElement('button');
ftBtn.type = 'button';
ftBtn.textContent = 'Feiertag';
ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); saveState(); refresh(); });
const buBtn = document.createElement('button');
buBtn.type = 'button';
buBtn.textContent = 'Betriebsurl.';
buBtn.addEventListener('click', () => { toggleClosure(state, iso); saveState(); refresh(); });
btnRow.append(ftBtn, buBtn);
cell.appendChild(btnRow);
}
}
return cell;
}
// Day-of-week header row
const headerGrid = document.createElement('div');
headerGrid.className = 'calendar-header-grid';
WD_LABELS.forEach(lbl => {
const cell = document.createElement('div');
cell.style.cssText = 'text-align:center;font-weight:600;font-size:12px;color:#6b7280;padding:4px';
cell.className = 'cal-header-cell';
cell.textContent = lbl;
headerGrid.appendChild(cell);
});
@@ -139,116 +299,49 @@ function renderGrid(wrapper, exportBtn) {
bodyGrid.className = 'calendar-body-grid';
// Offset empty cells before first day
const firstISO = currentYear + '-' + String(currentMonth).padStart(2,'0') + '-01';
const firstWD = (() => { const d = new Date(firstISO + 'T12:00:00Z').getUTCDay(); return d === 0 ? 7 : d; })();
const firstISO = toISO(currentYear, currentMonth, 1);
const firstWD = getWeekday(firstISO);
for (let i = 1; i < firstWD; i++) {
const empty = document.createElement('div');
empty.className = 'cal-day empty-cell';
bodyGrid.appendChild(empty);
}
// Day cells
// Day cells — each cell can re-render itself; no full-grid rebuild per click
for (let d = 1; d <= daysInMonth; d++) {
const iso = currentYear + '-' + String(currentMonth).padStart(2,'0') + '-' + String(d).padStart(2,'0');
const wdNum = (() => { const x = new Date(iso + 'T12:00:00Z').getUTCDay(); return x === 0 ? 7 : x; })();
const isWeekend = wdNum >= 6;
const isHoliday = state.calendar.holidays.includes(iso);
const isClosure = state.calendar.companyClosures.includes(iso);
const assigneeId = planByDate[iso];
const cell = document.createElement('div');
cell.className = 'cal-day';
if (isWeekend) cell.classList.add('weekend');
else if (isHoliday) cell.classList.add('holiday');
else if (isClosure) cell.classList.add('closure');
// Day number + weekday label
const dayNum = document.createElement('div');
dayNum.className = 'day-num';
dayNum.textContent = d + ' ';
const wdSpan = document.createElement('span');
wdSpan.className = 'day-tag';
wdSpan.textContent = WD_LABELS[wdNum - 1];
dayNum.appendChild(wdSpan);
cell.appendChild(dayNum);
if (!isWeekend) {
if (isHoliday || isClosure) {
// Show label + remove button
const tag = document.createElement('div');
tag.style.cssText = 'font-size:11px;margin-top:4px';
tag.textContent = isHoliday ? 'Feiertag' : 'Betriebsurlaub';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = '✕';
removeBtn.style.cssText = 'font-size:9px;padding:1px 4px;margin-left:4px';
removeBtn.addEventListener('click', () => {
if (isHoliday) toggleHoliday(state, iso);
else toggleClosure(state, iso);
saveState();
renderGrid(wrapper, exportBtn);
});
tag.appendChild(removeBtn);
cell.appendChild(tag);
} else {
// Assignee dropdown
const sel = document.createElement('select');
sel.style.cssText = 'width:100%;margin-top:4px;font-size:11px';
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '— Leer —';
sel.appendChild(emptyOpt);
for (const emp of state.employees) {
const opt = document.createElement('option');
opt.value = emp.id;
opt.textContent = emp.name; // textContent: safe
if (emp.id === assigneeId) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener('change', () => {
const empId = sel.value;
if (empId) {
const entry = { date: iso, employeeId: empId, manual: true };
addHistoryEntries(state, [entry]);
const idx = currentPlan.findIndex(a => a.date === iso);
if (idx >= 0) currentPlan[idx] = entry; else currentPlan.push(entry);
} else {
removeHistoryEntry(state, iso);
currentPlan = currentPlan.filter(a => a.date !== iso);
}
saveState();
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
});
cell.appendChild(sel);
// FT / BU mark buttons
const btnRow = document.createElement('div');
btnRow.style.cssText = 'margin-top:4px;display:flex;gap:2px';
const ftBtn = document.createElement('button');
ftBtn.type = 'button';
ftBtn.textContent = 'FT';
ftBtn.style.cssText = 'font-size:9px;padding:1px 4px';
ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); saveState(); renderGrid(wrapper, exportBtn); });
const buBtn = document.createElement('button');
buBtn.type = 'button';
buBtn.textContent = 'BU';
buBtn.style.cssText = 'font-size:9px;padding:1px 4px';
buBtn.addEventListener('click', () => { toggleClosure(state, iso); saveState(); renderGrid(wrapper, exportBtn); });
btnRow.append(ftBtn, buBtn);
cell.appendChild(btnRow);
}
}
bodyGrid.appendChild(cell);
bodyGrid.appendChild(makeDayCell(d));
}
wrapper.appendChild(bodyGrid);
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
updateExportVisibility();
}
function renderSkippedWarning(box, skippedDates) {
box.replaceChildren();
if (!skippedDates || skippedDates.length === 0) {
box.style.display = 'none';
return;
}
box.style.display = 'flex';
box.className = 'skipped-warning';
const formatted = skippedDates.map(d => {
const [y, m, day] = d.split('-');
return day + '.' + m + '.';
}).join(', ');
const text = document.createElement('span');
text.textContent = skippedDates.length === 1
? '1 Tag konnte nicht vollständig besetzt werden: ' + formatted
: skippedDates.length + ' Tage konnten nicht vollständig besetzt werden: ' + formatted;
box.appendChild(text);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', () => {
box.replaceChildren();
box.style.display = 'none';
});
box.appendChild(closeBtn);
}
+89 -17
View File
@@ -1,3 +1,74 @@
import { MIN_MODERATORS_PER_DAY, MAX_MODERATORS_PER_DAY } from './utils.js';
function generateId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const b = new Uint8Array(16);
crypto.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map(x => x.toString(16).padStart(2, '0'));
return `${h.slice(0,4).join('')}-${h.slice(4,6).join('')}-${h.slice(6,8).join('')}-${h.slice(8,10).join('')}-${h.slice(10,16).join('')}`;
}
// Last-resort fallback for environments without Web Crypto. Not cryptographically
// strong, but adequate for local, non-security-sensitive employee ids.
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
}
function parseSettings(raw) {
const n = raw?.moderatorsPerDay;
const valid = Number.isInteger(n) && n >= MIN_MODERATORS_PER_DAY && n <= MAX_MODERATORS_PER_DAY;
return { moderatorsPerDay: valid ? n : MIN_MODERATORS_PER_DAY };
}
function defaultConstraints() {
return {
neverDays: [],
lowPriorityDays: [],
onlyDays: [],
maxPerMonth: null,
minGapDays: 14,
vacations: []
};
}
// Coerce an arbitrary constraints object into a valid, fully-populated one.
function normalizeConstraints(c) {
const raw = (c && typeof c === 'object') ? c : {};
const arr = v => (Array.isArray(v) ? v : []);
return {
neverDays: arr(raw.neverDays),
lowPriorityDays: arr(raw.lowPriorityDays),
onlyDays: arr(raw.onlyDays),
maxPerMonth: Number.isFinite(raw.maxPerMonth) ? raw.maxPerMonth : null,
minGapDays: Number.isFinite(raw.minGapDays) ? raw.minGapDays : 14,
vacations: arr(raw.vacations).filter(v =>
v && typeof v.from === 'string' && typeof v.to === 'string' && v.from <= v.to
)
};
}
// Validate/repair an imported employee list: drop unusable rows, fill missing
// constraints, and deduplicate ids so downstream code never crashes or
// double-books. Returns a fresh, safe array.
export function normalizeEmployees(rawList) {
if (!Array.isArray(rawList)) return [];
const seen = new Set();
const out = [];
for (const e of rawList) {
if (!e || typeof e !== 'object') continue;
const name = String(e.name ?? '').trim();
if (!name) continue; // unusable without a name
let id = (typeof e.id === 'string' && e.id) ? e.id : generateId();
if (seen.has(id)) id = generateId(); // resolve duplicate ids
seen.add(id);
out.push({ id, name, constraints: normalizeConstraints(e.constraints) });
}
return out;
}
export function loadFromJSON(json) {
let parsed;
try {
@@ -9,14 +80,15 @@ export function loadFromJSON(json) {
throw new Error('Ungültiges Dateiformat: Objekt erwartet');
}
return {
employees: Array.isArray(parsed.employees) ? parsed.employees : [],
employees: normalizeEmployees(parsed.employees),
calendar: (parsed.calendar && typeof parsed.calendar === 'object')
? {
holidays: Array.isArray(parsed.calendar.holidays) ? parsed.calendar.holidays : [],
companyClosures: Array.isArray(parsed.calendar.companyClosures) ? parsed.calendar.companyClosures : []
}
: { holidays: [], companyClosures: [] },
history: Array.isArray(parsed.history) ? parsed.history : []
history: Array.isArray(parsed.history) ? parsed.history : [],
settings: parseSettings(parsed.settings)
};
}
@@ -24,7 +96,8 @@ export function saveToJSON(state) {
return JSON.stringify({
employees: state.employees,
calendar: state.calendar,
history: state.history
history: state.history,
settings: state.settings
}, null, 2);
}
@@ -32,21 +105,16 @@ export function createEmployee(name) {
const trimmed = (name ?? '').trim();
if (!trimmed) throw new Error('Mitarbeitername darf nicht leer sein');
return {
id: crypto.randomUUID(),
id: generateId(),
name: trimmed,
constraints: {
neverDays: [],
lowPriorityDays: [],
onlyDays: [],
maxPerMonth: null,
minGapDays: 0,
vacations: []
}
constraints: defaultConstraints()
};
}
export function removeEmployee(state, id) {
state.employees = state.employees.filter(e => e.id !== id);
// Also drop the employee's history entries so no orphaned "(unbekannt)" rows remain
state.history = state.history.filter(h => h.employeeId !== id);
}
export function toggleHoliday(state, isoDate) {
@@ -61,15 +129,19 @@ export function toggleClosure(state, isoDate) {
if (idx >= 0) arr.splice(idx, 1); else arr.push(isoDate);
}
// Adds entries to history, replacing any existing entries for the same dates
// Adds entries to history, replacing any existing entries with the same (date, employeeId)
export function addHistoryEntries(state, entries) {
if (!entries?.length) return;
const dates = new Set(entries.map(e => e.date));
state.history = state.history.filter(h => !dates.has(h.date));
const keys = new Set(entries.map(e => e.date + '|' + e.employeeId));
state.history = state.history.filter(h => !keys.has(h.date + '|' + h.employeeId));
state.history.push(...entries);
state.history.sort((a, b) => a.date.localeCompare(b.date));
}
export function removeHistoryEntry(state, isoDate) {
state.history = state.history.filter(h => h.date !== isoDate);
export function removeHistoryEntry(state, isoDate, employeeId) {
if (employeeId === undefined) {
state.history = state.history.filter(h => h.date !== isoDate);
} else {
state.history = state.history.filter(h => !(h.date === isoDate && h.employeeId === employeeId));
}
}
+26 -8
View File
@@ -22,6 +22,7 @@ export function renderEmployees() {
nameInput.id = 'emp-name-input';
nameInput.type = 'text';
nameInput.placeholder = 'Name eingeben...';
nameInput.setAttribute('aria-label', 'Mitarbeitername');
nameInput.style.width = '220px';
const addBtn = document.createElement('button');
@@ -37,6 +38,7 @@ export function renderEmployees() {
fileInput.id = 'emp-excel-input';
fileInput.type = 'file';
fileInput.accept = '.xlsx,.xls';
fileInput.setAttribute('aria-label', 'Excel-Datei mit Mitarbeiternamen importieren');
fileInput.style.display = 'none';
row.append(nameInput, addBtn, excelBtn, fileInput);
@@ -54,7 +56,8 @@ export function renderEmployees() {
if (!name) return;
try {
state.employees.push(createEmployee(name));
} catch {
} catch (err) {
console.warn('Mitarbeiter konnte nicht angelegt werden:', err);
return;
}
nameInput.value = '';
@@ -69,16 +72,31 @@ export function renderEmployees() {
fileInput.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const buf = await file.arrayBuffer();
const names = readEmployeeNames(buf);
for (const name of names) {
if (!state.employees.some(emp => emp.name === name)) {
try { state.employees.push(createEmployee(name)); } catch { /* skip invalid */ }
if (typeof XLSX === 'undefined') {
alert('Excel-Funktion nicht verfügbar (SheetJS konnte nicht geladen werden).');
e.target.value = '';
return;
}
try {
const buf = await file.arrayBuffer();
const names = readEmployeeNames(buf);
if (names.length === 0) {
alert('Keine Namen in der Datei gefunden.');
} else {
let added = 0;
for (const name of names) {
if (!state.employees.some(emp => emp.name === name)) {
try { state.employees.push(createEmployee(name)); added++; } catch { /* skip invalid */ }
}
}
saveState();
refreshList();
if (added === 0) alert('Alle Namen waren bereits vorhanden.');
}
} catch (err) {
alert('Excel-Import fehlgeschlagen: ' + (err?.message || err));
}
e.target.value = '';
saveState();
refreshList();
});
}
+29 -7
View File
@@ -1,3 +1,5 @@
import { formatDateDE, buildNameMap } from './utils.js';
// Reads employee names from first column of xlsx (skips header row 0)
export function readEmployeeNames(arrayBuffer) {
const wb = XLSX.read(arrayBuffer, { type: 'array' });
@@ -8,16 +10,36 @@ export function readEmployeeNames(arrayBuffer) {
.filter(name => name.length > 0);
}
// Neutralize spreadsheet formula injection: Excel/Sheets treat a cell starting
// with = + - @ (or certain control chars) as a formula. Prefix such values with
// an apostrophe so they render as literal text.
export function sanitizeCell(value) {
const s = String(value ?? '');
return /^[=+\-@\t\r]/.test(s) ? "'" + s : s;
}
// Pure helper: returns { names: [...], dates: [...] } columns for a schedule.
// Multiple employees per date are joined alphabetically with " & ".
export function buildScheduleColumns(assignments, employees) {
const nameById = buildNameMap(employees);
const byDate = new Map();
for (const a of assignments) {
if (!byDate.has(a.date)) byDate.set(a.date, []);
byDate.get(a.date).push(nameById[a.employeeId] ?? '');
}
const sortedDates = [...byDate.keys()].sort();
const names = sortedDates.map(d =>
sanitizeCell(byDate.get(d).slice().sort((x, y) => x.localeCompare(y, 'de')).join(' & '))
);
const dates = sortedDates.map(formatDateDE);
return { names, dates };
}
// Exports schedule as xlsx blob
// assignments: [{ date: "2026-04-01", employeeId }]
// assignments: [{ date: "2026-04-01", employeeId }] — multiple entries per date allowed
// employees: [{ id, name }]
export function exportSchedule(assignments, employees) {
const nameById = Object.fromEntries(employees.map(e => [e.id, e.name]));
const names = assignments.map(a => nameById[a.employeeId] ?? '');
const dates = assignments.map(a => {
const [y, m, d] = a.date.split('-');
return d + '.' + m + '.' + y;
});
const { names, dates } = buildScheduleColumns(assignments, employees);
const ws = XLSX.utils.aoa_to_sheet([names, dates]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Moderationsplan');
+13 -5
View File
@@ -1,6 +1,7 @@
import { state, saveState } from './app.js';
import { loadFromJSON, saveToJSON, removeHistoryEntry } from './data.js';
import { downloadBlob } from './excel.js';
import { formatDateDE, buildNameMap } from './utils.js';
export function renderDataTab() {
const container = document.getElementById('tab-data');
@@ -39,6 +40,7 @@ export function renderDataTab() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.setAttribute('aria-label', 'JSON-Datensicherung importieren');
fileInput.style.display = 'none';
loadRow.append(loadBtn, fileInput);
saveLoadCol.appendChild(loadRow);
@@ -79,6 +81,7 @@ export function renderDataTab() {
state.employees = loaded.employees;
state.calendar = loaded.calendar;
state.history = loaded.history;
state.settings = loaded.settings;
saveState();
alert('Daten erfolgreich geladen.');
renderDataTab();
@@ -101,8 +104,14 @@ function renderHistoryTable() {
return;
}
const nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
const sorted = [...state.history].sort((a, b) => b.date.localeCompare(a.date));
const nameById = buildNameMap(state.employees);
const sorted = [...state.history].sort((a, b) => {
const dateCmp = b.date.localeCompare(a.date);
if (dateCmp !== 0) return dateCmp;
const nameA = nameById[a.employeeId] ?? '';
const nameB = nameById[b.employeeId] ?? '';
return nameA.localeCompare(nameB, 'de');
});
const table = document.createElement('table');
const thead = document.createElement('thead');
@@ -117,11 +126,10 @@ function renderHistoryTable() {
const tbody = document.createElement('tbody');
for (const h of sorted) {
const [y, m, d] = h.date.split('-');
const tr = document.createElement('tr');
const dateTd = document.createElement('td');
dateTd.textContent = d + '.' + m + '.' + y;
dateTd.textContent = formatDateDE(h.date);
const nameTd = document.createElement('td');
nameTd.textContent = nameById[h.employeeId] ?? '(unbekannt)'; // textContent: safe
@@ -136,7 +144,7 @@ function renderHistoryTable() {
delBtn.textContent = '✕';
delBtn.style.cssText = 'font-size:11px;padding:2px 6px';
delBtn.addEventListener('click', () => {
removeHistoryEntry(state, h.date);
removeHistoryEntry(state, h.date, h.employeeId);
saveState();
renderHistoryTable();
});
+33
View File
@@ -0,0 +1,33 @@
// Shared pure helpers and constants used across modules.
// Allowed range for the "moderators per day" setting.
export const MIN_MODERATORS_PER_DAY = 1;
export const MAX_MODERATORS_PER_DAY = 5;
// "YYYY-MM-DD" from numeric parts.
export function toISO(year, month, day) {
return year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0');
}
// "YYYY-MM" month prefix (used to filter history by month).
export function monthPrefix(year, month) {
return year + '-' + String(month).padStart(2, '0');
}
// Weekday for an ISO date string: 1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr, 6=Sa, 7=So.
export function getWeekday(isoDate) {
const d = new Date(isoDate + 'T12:00:00Z').getUTCDay(); // 0=Sun
return d === 0 ? 7 : d;
}
// "DD.MM.YYYY" from an ISO date string.
export function formatDateDE(isoDate) {
const [y, m, d] = isoDate.split('-');
return d + '.' + m + '.' + y;
}
// { [id]: name } lookup map from an employees array.
export function buildNameMap(employees) {
return Object.fromEntries(employees.map(e => [e.id, e.name]));
}
+557
View File
@@ -0,0 +1,557 @@
{
"name": "meeting-mixer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meeting-mixer",
"version": "1.0.0",
"devDependencies": {
"jsdom": "^29.1.1"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.2"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
"integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+6 -3
View File
@@ -1,10 +1,13 @@
{
"name": "morning-meeting-planner",
"name": "meeting-mixer",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "npx serve . -p 3000",
"test": "node tests/algorithm.test.mjs"
"start": "npx serve . -p 3001",
"test": "node tests/utils.test.mjs && node tests/algorithm.test.mjs && node tests/data.test.mjs && node tests/excel.test.mjs && node tests/render.test.mjs && node tests/persistence.test.mjs"
},
"devDependencies": {
"jsdom": "^29.1.1"
}
}
+409 -35
View File
@@ -1,58 +1,432 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0d;
--surface: #161616;
--surface-2: #1e1e1e;
--border: #2a2a2a;
--border-gold: rgba(200,169,81,0.25);
--text: #f0ede6;
--text-2: #5a5550;
--accent: #c8a951;
--accent-dim: #8c7435;
--accent-fg: #0d0d0d;
--red: #c0392b;
--red-fg: #f0ede6;
--holiday-bg: #1a1600;
--holiday-bd: #3d3000;
--closure-bg: #1a0a0a;
--closure-bd: #3d1010;
--r: 4px;
--r-sm: 3px;
--r-pill: 3px;
--shadow: 4px 4px 0 rgba(200,169,81,0.12);
--shadow-hover: 6px 6px 0 rgba(200,169,81,0.22);
--ease: 120ms ease;
/* Geometric stripe pattern */
--stripe: repeating-linear-gradient(
-45deg,
transparent,
transparent 6px,
rgba(200,169,81,0.025) 6px,
rgba(200,169,81,0.025) 7px
);
}
body {
font-family: system-ui, sans-serif;
font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 14px;
color: #1a1a1a;
background: #f5f5f5;
color: var(--text);
background: var(--bg);
background-image: var(--stripe);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
header { background: #2563eb; color: white; padding: 16px 24px; }
header h1 { font-size: 18px; font-weight: 600; }
/* ── Header ──────────────────────────────────────────── */
header {
padding: 12px 32px 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
background: var(--bg);
overflow: hidden;
}
.tabs { display: flex; gap: 2px; background: #e5e7eb; padding: 8px 24px 0; }
.header-graphic {
flex-shrink: 0;
width: 200px;
height: 160px;
opacity: 0.92;
margin-right: -8px;
}
header h1 {
font-family: 'Bebas Neue', sans-serif;
font-size: 38px;
font-weight: 400;
letter-spacing: 0.08em;
color: var(--accent);
line-height: 1;
position: relative;
z-index: 1;
}
/* Gold underline bar on header title */
header h1::after {
content: '';
display: block;
width: 40%;
height: 2px;
background: var(--accent);
margin-top: 4px;
opacity: 0.5;
}
/* ── Tabs ────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 0;
padding: 0 32px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.tab-btn {
padding: 8px 20px; border: none; background: #d1d5db;
cursor: pointer; border-radius: 6px 6px 0 0; font-size: 14px; color: #374151;
padding: 13px 22px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 0;
font-family: 'Bebas Neue', sans-serif;
font-size: 15px;
font-weight: 400;
letter-spacing: 0.12em;
color: var(--text-2);
transition: color var(--ease), border-color var(--ease);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-btn:hover:not(.active) { color: var(--text); }
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: transparent;
}
/* ── Content ─────────────────────────────────────────── */
.tab-content {
background: transparent;
min-height: calc(100vh - 100px);
padding: 32px;
}
.tab-btn.active { background: white; color: #2563eb; font-weight: 600; }
.tab-content { background: white; min-height: calc(100vh - 100px); padding: 24px; }
.hidden { display: none; }
/* ── Buttons ─────────────────────────────────────────── */
button {
cursor: pointer; padding: 6px 14px; border-radius: 4px;
border: 1px solid #d1d5db; background: white; font-size: 13px;
cursor: pointer;
padding: 7px 16px;
border-radius: var(--r);
border: 1px solid var(--border);
background: var(--surface);
font-family: 'Space Grotesk', sans-serif;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text);
transition: background var(--ease), border-color var(--ease), box-shadow var(--ease);
white-space: nowrap;
}
button.primary { background: #2563eb; color: white; border-color: #2563eb; }
button.danger { background: #ef4444; color: white; border-color: #ef4444; }
input, select { padding: 5px 8px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 13px; }
button:hover {
background: var(--surface-2);
border-color: var(--accent-dim);
}
button.primary {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
font-weight: 700;
}
button.primary:hover {
background: #d4b55c;
border-color: #d4b55c;
box-shadow: var(--shadow-hover);
}
button.danger {
background: var(--red);
color: var(--red-fg);
border-color: var(--red);
}
button.danger:hover { opacity: 0.84; }
/* ── Inputs & Selects ────────────────────────────────── */
input, select {
padding: 7px 11px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-family: 'Space Grotesk', sans-serif;
font-size: 13px;
color: var(--text);
background: var(--surface);
outline: none;
transition: border-color var(--ease), box-shadow var(--ease);
}
input:focus, select:focus {
border-color: var(--accent-dim);
box-shadow: 0 0 0 2px rgba(200,169,81,0.15);
}
input::placeholder { color: var(--text-2); }
select option { background: var(--surface-2); color: var(--text); }
/* ── Table ───────────────────────────────────────────── */
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; }
th { background: #f9fafb; font-weight: 600; }
th {
text-align: left;
padding: 10px 14px;
background: var(--surface);
font-family: 'Bebas Neue', sans-serif;
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
border-bottom: 1px solid var(--border-gold);
}
td {
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--text);
}
tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--surface); }
.section-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
/* ── Utilities ───────────────────────────────────────── */
.section-title {
font-family: 'Bebas Neue', sans-serif;
font-size: 22px;
font-weight: 400;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: 22px;
text-transform: uppercase;
position: relative;
padding-left: 12px;
}
.section-title::before {
content: '';
position: absolute;
left: 0;
top: 10%;
height: 80%;
width: 3px;
background: var(--accent);
}
.row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
/* ── Calendar ────────────────────────────────────────── */
.calendar-outer { overflow-x: auto; }
.calendar-header-grid { display: grid; grid-template-columns: repeat(7,1fr); gap: 6px; margin-bottom: 6px; }
.calendar-body-grid { display: grid; grid-template-columns: repeat(7,1fr); gap: 6px; }
.cal-day { border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px; background: white; min-height: 80px; }
.cal-day.weekend { background: #f3f4f6; color: #9ca3af; }
.cal-day.holiday { background: #fef3c7; }
.cal-day.closure { background: #fee2e2; }
.calendar-header-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
margin-bottom: 5px;
}
.calendar-body-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
}
.cal-header-cell {
text-align: center;
font-family: 'Bebas Neue', sans-serif;
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
padding: 6px 4px;
border-bottom: 1px solid var(--border-gold);
}
.cal-day {
border: 1px solid var(--border);
border-radius: var(--r);
padding: 9px;
background: var(--surface);
min-height: 88px;
transition: box-shadow var(--ease), border-color var(--ease);
position: relative;
overflow: hidden;
}
/* Subtle corner accent on calendar cells */
.cal-day:not(.empty-cell)::after {
content: '';
position: absolute;
top: 0; right: 0;
width: 0; height: 0;
border-style: solid;
border-width: 0 8px 8px 0;
border-color: transparent var(--border) transparent transparent;
}
.cal-day:not(.empty-cell):not(.weekend):hover {
box-shadow: var(--shadow-hover);
border-color: var(--border-gold);
}
.cal-day:not(.empty-cell):not(.weekend):hover::after {
border-color: transparent var(--accent-dim) transparent transparent;
}
.cal-day.weekend {
background: var(--bg);
color: var(--text-2);
border-color: var(--border);
opacity: 0.5;
}
.cal-day.holiday { background: var(--holiday-bg); border-color: var(--holiday-bd); }
.cal-day.closure { background: var(--closure-bg); border-color: var(--closure-bd); }
.cal-day.empty-cell { border: none; background: transparent; min-height: 0; }
.day-num { font-weight: 600; font-size: 13px; }
.day-tag { font-size: 10px; color: #6b7280; }
.day-num {
font-family: 'Bebas Neue', sans-serif;
font-weight: 400;
font-size: 17px;
letter-spacing: 0.04em;
color: var(--text);
display: flex;
align-items: baseline;
gap: 5px;
}
.day-tag { font-size: 10px; color: var(--accent); font-weight: 600; font-family: 'Space Grotesk', sans-serif; letter-spacing: 0.06em; text-transform: uppercase; }
.cal-day select {
width: 100%;
margin-top: 6px;
font-size: 11px;
padding: 4px 7px;
border-radius: var(--r-sm);
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.cal-btn-row {
margin-top: 5px;
display: flex;
gap: 4px;
}
.cal-day .cal-btn-row button {
font-size: 9px;
padding: 2px 7px;
border-radius: var(--r);
color: var(--text-2);
font-weight: 600;
background: var(--surface-2);
border-color: var(--border);
letter-spacing: 0.06em;
}
.cal-day .cal-btn-row button:hover { color: var(--accent); border-color: var(--accent-dim); background: var(--surface-2); }
.cal-day-tag-label {
font-size: 10px;
margin-top: 5px;
color: var(--text-2);
display: flex;
align-items: center;
gap: 4px;
}
.cal-day-tag-label button {
font-size: 9px;
padding: 1px 5px;
border-radius: var(--r);
color: var(--text-2);
background: var(--surface-2);
border-color: var(--border);
}
/* ── Employees ───────────────────────────────────────── */
.employee-item {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid var(--border);
}
.employee-name { font-weight: 600; flex: 1; font-size: 14px; color: var(--text); }
.constraint-form {
background: var(--surface);
border: 1px solid var(--border);
padding: 18px 20px;
border-radius: var(--r);
margin: 4px 0 14px;
border-left: 3px solid var(--accent-dim);
}
.constraint-form label {
display: block;
margin-bottom: 7px;
font-family: 'Bebas Neue', sans-serif;
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
}
.constraint-row { margin-bottom: 18px; }
.employee-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #f3f4f6; }
.employee-name { font-weight: 500; flex: 1; }
.constraint-form { background: #f9fafb; padding: 16px; border-radius: 8px; margin-top: 4px; margin-bottom: 8px; }
.constraint-form label { display: block; margin-bottom: 4px; font-size: 12px; color: #6b7280; }
.constraint-row { margin-bottom: 12px; }
.day-toggle-group { display: flex; gap: 4px; }
.day-toggle {
width: 32px; height: 32px; border-radius: 50%; border: 1px solid #d1d5db;
background: white; font-size: 11px; cursor: pointer;
width: 34px;
height: 34px;
border-radius: var(--r);
border: 1px solid var(--border);
background: var(--surface-2);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
transition: background var(--ease), border-color var(--ease), color var(--ease);
color: var(--text-2);
text-transform: uppercase;
}
.day-toggle.active { background: #2563eb; color: white; border-color: #2563eb; }
.day-toggle:hover:not(.active) { background: var(--surface); border-color: var(--border-gold); color: var(--text); }
.day-toggle.active {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
font-weight: 800;
}
/* ── Calendar legend ─────────────────────────────────── */
.cal-legend {
font-size: 11px;
color: var(--text-2);
margin-bottom: 16px;
display: flex;
gap: 16px;
flex-wrap: wrap;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.cal-legend span { display: flex; align-items: center; gap: 6px; }
.cal-legend-dot {
width: 8px; height: 8px; border-radius: 1px;
display: inline-block;
}
/* ── Scrollbar ───────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
+104 -11
View File
@@ -35,15 +35,15 @@ function emp(id, constraints = {}) {
// generatePlan — basic assignment
{
const result = generatePlan(['2026-04-01'], [emp('alice'), emp('bob')], [], fixed);
assert.equal(result.length, 1, 'One workday → one assignment');
assert.ok(['alice','bob'].includes(result[0].employeeId), 'Assigned to alice or bob');
assert.equal(result.assignments.length, 1, 'One workday → one assignment');
assert.ok(['alice','bob'].includes(result.assignments[0].employeeId), 'Assigned to alice or bob');
}
// neverDays blocks employee
{
// 2026-04-01 is Wednesday (3)
const result = generatePlan(['2026-04-01'], [emp('alice', {neverDays:[3]}), emp('bob')], [], fixed);
assert.equal(result[0].employeeId, 'bob', 'alice blocked on Wednesday → bob assigned');
assert.equal(result.assignments[0].employeeId, 'bob', 'alice blocked on Wednesday → bob assigned');
}
// onlyDays restricts to specific days
@@ -51,7 +51,7 @@ function emp(id, constraints = {}) {
// 2026-04-01 = Wednesday (3), 2026-04-02 = Thursday (4)
const employees = [emp('alice', {onlyDays:[3]}), emp('bob')];
const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed);
const aliceOnThursday = result.find(r => r.date === '2026-04-02' && r.employeeId === 'alice');
const aliceOnThursday = result.assignments.find(r => r.date === '2026-04-02' && r.employeeId === 'alice');
assert.ok(!aliceOnThursday, 'alice with onlyDays:[3] not assigned on Thursday');
}
@@ -62,7 +62,7 @@ function emp(id, constraints = {}) {
emp('bob')
];
const result = generatePlan(['2026-04-01'], employees, [], fixed);
assert.equal(result[0].employeeId, 'bob', 'alice on vacation → bob assigned');
assert.equal(result.assignments[0].employeeId, 'bob', 'alice on vacation → bob assigned');
}
// maxPerMonth cap
@@ -70,7 +70,7 @@ function emp(id, constraints = {}) {
const days = ['2026-04-01','2026-04-02','2026-04-03'];
const employees = [emp('alice', {maxPerMonth:1}), emp('bob'), emp('clara')];
const result = generatePlan(days, employees, [], fixed);
const aliceCount = result.filter(r => r.employeeId === 'alice').length;
const aliceCount = result.assignments.filter(r => r.employeeId === 'alice').length;
assert.ok(aliceCount <= 1, 'alice maxPerMonth=1 → assigned at most once');
}
@@ -80,14 +80,14 @@ function emp(id, constraints = {}) {
const history = [{date:'2026-04-02', employeeId:'alice', manual:false}];
const employees = [emp('alice', {minGapDays:5}), emp('bob')];
const result = generatePlan(['2026-04-06'], employees, history, fixed);
assert.equal(result[0].employeeId, 'bob', 'alice within minGap (4<5) → bob assigned');
assert.equal(result.assignments[0].employeeId, 'bob', 'alice within minGap (4<5) → bob assigned');
}
// No candidates → day skipped
{
// 2026-04-01 = Wednesday (3), only employee blocked
const result = generatePlan(['2026-04-01'], [emp('alice', {neverDays:[3]})], [], fixed);
assert.equal(result.length, 0, 'No candidate → day skipped');
assert.equal(result.assignments.length, 0, 'No candidate → day skipped');
}
// Fairness: less history = higher priority
@@ -96,7 +96,7 @@ function emp(id, constraints = {}) {
date: '2026-04-0' + i, employeeId: 'alice', manual: false
}));
const result = generatePlan(['2026-05-04'], [emp('alice'), emp('bob')], history, fixed);
assert.equal(result[0].employeeId, 'bob', 'bob (0 history) beats alice (5 history)');
assert.equal(result.assignments[0].employeeId, 'bob', 'bob (0 history) beats alice (5 history)');
}
// companyClosures excluded from workdays
@@ -112,13 +112,106 @@ function emp(id, constraints = {}) {
// Both have same history (0) — alice's score gets -5 penalty, bob wins
const employees = [emp('alice', {lowPriorityDays:[3]}), emp('bob')];
const result = generatePlan(['2026-04-01'], employees, [], fixed);
assert.equal(result[0].employeeId, 'bob', 'alice low priority on Wednesday → bob wins');
assert.equal(result.assignments[0].employeeId, 'bob', 'alice low priority on Wednesday → bob wins');
}
// manual: false on auto-assigned entries
{
const result = generatePlan(['2026-04-01'], [emp('alice')], [], fixed);
assert.equal(result[0].manual, false, 'auto-assignment sets manual: false');
assert.equal(result.assignments[0].manual, false, 'auto-assignment sets manual: false');
}
// slotsPerDay=2 → 2 different employees per day
{
const employees = [emp('alice'), emp('bob'), emp('clara')];
const result = generatePlan(['2026-04-01'], employees, [], fixed, 2);
assert.equal(result.assignments.length, 2, 'slotsPerDay=2 → 2 entries');
const ids = result.assignments.map(a => a.employeeId);
assert.notEqual(ids[0], ids[1], 'two different employees on same day');
}
// slotsPerDay=2 across multiple days, maxPerMonth still respected
{
const days = ['2026-04-01','2026-04-02','2026-04-03'];
const employees = [
emp('alice', { maxPerMonth: 1 }),
emp('bob'),
emp('clara'),
emp('dave')
];
const result = generatePlan(days, employees, [], fixed, 2);
const aliceCount = result.assignments.filter(a => a.employeeId === 'alice').length;
assert.ok(aliceCount <= 1, 'maxPerMonth=1 respected with slotsPerDay=2');
}
// slotsPerDay=2: per day, no employee picked twice
{
const employees = [emp('alice'), emp('bob'), emp('clara')];
const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed, 2);
for (const date of ['2026-04-01','2026-04-02']) {
const ids = result.assignments.filter(a => a.date === date).map(a => a.employeeId);
assert.equal(new Set(ids).size, ids.length, 'no employee picked twice on ' + date);
}
}
// slotsPerDay=2 with only 1 eligible employee → day skipped, listed in skippedDates
{
const employees = [emp('alice')];
const result = generatePlan(['2026-04-01'], employees, [], fixed, 2);
assert.equal(result.assignments.length, 0, 'no assignments when candidates < slotsPerDay');
assert.deepEqual(result.skippedDates, ['2026-04-01'], 'date listed in skippedDates');
}
// slotsPerDay=1 with 0 candidates → still skipped, listed
{
// 2026-04-01 = Wednesday (3), only employee blocks Wednesday
const employees = [emp('alice', { neverDays: [3] })];
const result = generatePlan(['2026-04-01'], employees, [], fixed, 1);
assert.equal(result.assignments.length, 0, 'no assignments when 0 candidates');
assert.deepEqual(result.skippedDates, ['2026-04-01'], 'date listed in skippedDates');
}
// Mixed: some days fully assigned, some skipped
{
// 2026-04-01 = Wed, 2026-04-02 = Thu
// alice can only Wed, bob can always work
const employees = [
emp('alice', { onlyDays: [3] }),
emp('bob')
];
const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed, 2);
// 2026-04-01: 2 candidates → 2 entries
assert.equal(result.assignments.filter(a => a.date === '2026-04-01').length, 2, 'Wed has 2 entries');
// 2026-04-02: only bob → skipped
assert.equal(result.assignments.filter(a => a.date === '2026-04-02').length, 0, 'Thu skipped');
assert.deepEqual(result.skippedDates, ['2026-04-02'], 'Thu in skippedDates');
}
// preAssigned: manual pick occupies a slot, only remaining slots auto-filled, no double-book
{
const employees = [emp('alice'), emp('bob'), emp('clara')];
const manual = [{ date: '2026-04-01', employeeId: 'alice', manual: true }];
const result = generatePlan(['2026-04-01'], employees, [], fixed, 2, manual);
assert.equal(result.assignments.length, 1, 'slotsPerDay=2 with 1 manual → 1 auto entry');
assert.notEqual(result.assignments[0].employeeId, 'alice', 'manual employee not double-booked');
assert.equal(result.assignments[0].manual, false, 'auto entry has manual:false');
}
// preAssigned: day fully staffed by manual entries → nothing generated, not skipped
{
const employees = [emp('alice'), emp('bob')];
const manual = [{ date: '2026-04-01', employeeId: 'alice', manual: true }];
const result = generatePlan(['2026-04-01'], employees, [], fixed, 1, manual);
assert.equal(result.assignments.length, 0, 'fully manual day → no auto entries');
assert.deepEqual(result.skippedDates, [], 'fully manual day not skipped');
}
// preAssigned counts toward maxPerMonth eligibility
{
const employees = [emp('alice', { maxPerMonth: 1 }), emp('bob'), emp('clara')];
const manual = [{ date: '2026-04-01', employeeId: 'alice', manual: true }];
const result = generatePlan(['2026-04-02'], employees, [], fixed, 1, manual);
assert.notEqual(result.assignments[0].employeeId, 'alice', 'manual entry counts toward maxPerMonth');
}
console.log('All algorithm tests passed.');
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { addHistoryEntries, removeHistoryEntry, loadFromJSON, saveToJSON, normalizeEmployees, removeEmployee } from '../js/data.js';
function makeState(history = []) {
return {
employees: [],
calendar: { holidays: [], companyClosures: [] },
history: [...history]
};
}
// addHistoryEntries: two entries with same date but different employees both kept
{
const state = makeState();
addHistoryEntries(state, [
{ date: '2026-04-01', employeeId: 'alice', manual: false },
{ date: '2026-04-01', employeeId: 'bob', manual: false }
]);
assert.equal(state.history.length, 2, 'two distinct entries kept on same date');
const ids = state.history.map(h => h.employeeId).sort();
assert.deepEqual(ids, ['alice', 'bob'], 'both employees present');
}
// addHistoryEntries: same (date, employeeId) replaced, others on same date untouched
{
const state = makeState([
{ date: '2026-04-01', employeeId: 'alice', manual: false },
{ date: '2026-04-01', employeeId: 'bob', manual: false }
]);
addHistoryEntries(state, [
{ date: '2026-04-01', employeeId: 'alice', manual: true }
]);
assert.equal(state.history.length, 2, 'still two entries');
const alice = state.history.find(h => h.employeeId === 'alice');
assert.equal(alice.manual, true, 'alice replaced with manual=true');
const bob = state.history.find(h => h.employeeId === 'bob');
assert.equal(bob.manual, false, 'bob untouched');
}
// addHistoryEntries: empty input → no change
{
const state = makeState([{ date: '2026-04-01', employeeId: 'alice', manual: false }]);
addHistoryEntries(state, []);
assert.equal(state.history.length, 1, 'empty entries → no change');
}
// removeHistoryEntry without employeeId: removes all entries on that date
{
const state = makeState([
{ date: '2026-04-01', employeeId: 'alice', manual: false },
{ date: '2026-04-01', employeeId: 'bob', manual: false },
{ date: '2026-04-02', employeeId: 'alice', manual: false }
]);
removeHistoryEntry(state, '2026-04-01');
assert.equal(state.history.length, 1, 'only 2026-04-02 alice remains');
assert.equal(state.history[0].date, '2026-04-02', 'remaining entry is 2026-04-02');
}
// removeHistoryEntry with employeeId: removes only that one entry
{
const state = makeState([
{ date: '2026-04-01', employeeId: 'alice', manual: false },
{ date: '2026-04-01', employeeId: 'bob', manual: false }
]);
removeHistoryEntry(state, '2026-04-01', 'alice');
assert.equal(state.history.length, 1, 'one entry remains');
assert.equal(state.history[0].employeeId, 'bob', 'bob still there');
}
// removeHistoryEntry with employeeId that does not exist: no-op
{
const state = makeState([
{ date: '2026-04-01', employeeId: 'alice', manual: false }
]);
removeHistoryEntry(state, '2026-04-01', 'nonexistent');
assert.equal(state.history.length, 1, 'no change when employeeId not found');
}
// loadFromJSON: missing settings → default {moderatorsPerDay: 1}
{
const json = JSON.stringify({
employees: [],
calendar: { holidays: [], companyClosures: [] },
history: []
});
const loaded = loadFromJSON(json);
assert.deepEqual(loaded.settings, { moderatorsPerDay: 1 }, 'default settings when missing');
}
// loadFromJSON: present settings preserved, moderatorsPerDay clamped to [1,5]
{
const json = JSON.stringify({
employees: [], calendar: { holidays: [], companyClosures: [] }, history: [],
settings: { moderatorsPerDay: 3 }
});
const loaded = loadFromJSON(json);
assert.equal(loaded.settings.moderatorsPerDay, 3, 'preserved value');
}
// loadFromJSON: invalid moderatorsPerDay (string, out of range) → fallback to 1
{
const tooHigh = loadFromJSON(JSON.stringify({
employees: [], calendar: { holidays: [], companyClosures: [] }, history: [],
settings: { moderatorsPerDay: 99 }
}));
assert.equal(tooHigh.settings.moderatorsPerDay, 1, 'out of range falls back to 1');
const wrongType = loadFromJSON(JSON.stringify({
employees: [], calendar: { holidays: [], companyClosures: [] }, history: [],
settings: { moderatorsPerDay: 'two' }
}));
assert.equal(wrongType.settings.moderatorsPerDay, 1, 'wrong type falls back to 1');
}
// saveToJSON: settings are serialized
{
const state = {
employees: [], calendar: { holidays: [], companyClosures: [] }, history: [],
settings: { moderatorsPerDay: 2 }
};
const json = saveToJSON(state);
const parsed = JSON.parse(json);
assert.deepEqual(parsed.settings, { moderatorsPerDay: 2 }, 'settings round-trip');
}
// normalizeEmployees: missing constraints are filled with defaults
{
const out = normalizeEmployees([{ id: 'a', name: 'Alice' }]);
assert.equal(out.length, 1, 'employee kept');
assert.deepEqual(out[0].constraints.neverDays, [], 'neverDays defaulted');
assert.equal(out[0].constraints.minGapDays, 14, 'minGapDays defaulted to 14');
assert.equal(out[0].constraints.maxPerMonth, null, 'maxPerMonth defaulted to null');
}
// normalizeEmployees: rows without a usable name are dropped
{
const out = normalizeEmployees([{ id: 'a', name: ' ' }, { id: 'b' }, null, 42]);
assert.equal(out.length, 0, 'nameless/invalid rows dropped');
}
// normalizeEmployees: duplicate ids are de-duplicated to distinct ids
{
const out = normalizeEmployees([{ id: 'dup', name: 'A' }, { id: 'dup', name: 'B' }]);
assert.equal(out.length, 2, 'both kept');
assert.notEqual(out[0].id, out[1].id, 'duplicate id resolved');
}
// normalizeEmployees: invalid vacation ranges dropped, valid kept
{
const out = normalizeEmployees([{ id: 'a', name: 'A', constraints: { vacations: [
{ from: '2026-05-10', to: '2026-05-05' }, // reversed → dropped
{ from: '2026-06-01', to: '2026-06-07' } // valid → kept
] } }]);
assert.equal(out[0].constraints.vacations.length, 1, 'only valid vacation kept');
assert.equal(out[0].constraints.vacations[0].from, '2026-06-01', 'valid vacation preserved');
}
// loadFromJSON: employees without constraints are normalized (no later crash)
{
const loaded = loadFromJSON(JSON.stringify({
employees: [{ id: 'x', name: 'NoConstraints' }],
calendar: { holidays: [], companyClosures: [] }, history: []
}));
assert.ok(Array.isArray(loaded.employees[0].constraints.neverDays), 'constraints present after load');
}
// removeEmployee: also removes the employee's history entries (no orphans)
{
const state = {
employees: [{ id: 'a', name: 'Alice' }, { id: 'b', name: 'Bob' }],
calendar: { holidays: [], companyClosures: [] },
history: [
{ date: '2026-04-01', employeeId: 'a', manual: false },
{ date: '2026-04-02', employeeId: 'b', manual: false }
]
};
removeEmployee(state, 'a');
assert.equal(state.employees.length, 1, 'alice removed from employees');
assert.ok(!state.history.some(h => h.employeeId === 'a'), 'alice history removed');
assert.equal(state.history.length, 1, 'bob history untouched');
}
console.log('All data tests passed.');
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { buildScheduleColumns, sanitizeCell } from '../js/excel.js';
const employees = [
{ id: 'a1', name: 'Anna Müller' },
{ id: 'b1', name: 'Bernd Schulz' },
{ id: 'c1', name: 'Carla Weber' }
];
// Single employee per day
{
const assignments = [
{ date: '2026-04-01', employeeId: 'a1' },
{ date: '2026-04-02', employeeId: 'b1' }
];
const cols = buildScheduleColumns(assignments, employees);
assert.deepEqual(cols.names, ['Anna Müller', 'Bernd Schulz'], 'names per single-entry day');
assert.deepEqual(cols.dates, ['01.04.2026', '02.04.2026'], 'dates formatted DD.MM.YYYY');
}
// Two employees per day → joined with " & ", alphabetical
{
const assignments = [
{ date: '2026-04-01', employeeId: 'b1' }, // Bernd inserted first
{ date: '2026-04-01', employeeId: 'a1' } // Anna inserted second
];
const cols = buildScheduleColumns(assignments, employees);
assert.equal(cols.names.length, 1, 'one column per date');
assert.equal(cols.names[0], 'Anna Müller & Bernd Schulz', 'names alphabetical, joined with " & "');
assert.equal(cols.dates[0], '01.04.2026', 'one date entry');
}
// Three employees per day
{
const assignments = [
{ date: '2026-04-01', employeeId: 'c1' },
{ date: '2026-04-01', employeeId: 'a1' },
{ date: '2026-04-01', employeeId: 'b1' }
];
const cols = buildScheduleColumns(assignments, employees);
assert.equal(cols.names[0], 'Anna Müller & Bernd Schulz & Carla Weber', 'three names joined alphabetical');
}
// Multiple dates with multiple employees, dates ascending
{
const assignments = [
{ date: '2026-04-02', employeeId: 'a1' },
{ date: '2026-04-01', employeeId: 'b1' },
{ date: '2026-04-01', employeeId: 'a1' }
];
const cols = buildScheduleColumns(assignments, employees);
assert.deepEqual(cols.dates, ['01.04.2026', '02.04.2026'], 'dates sorted ascending');
assert.deepEqual(cols.names, ['Anna Müller & Bernd Schulz', 'Anna Müller'], 'names per column in date order');
}
// Unknown employeeId: empty string slot, sorted with other names
{
const assignments = [
{ date: '2026-04-01', employeeId: 'a1' },
{ date: '2026-04-01', employeeId: 'unknown' }
];
const cols = buildScheduleColumns(assignments, employees);
// Empty-string sorts before 'Anna Müller'
assert.equal(cols.names[0], ' & Anna Müller', 'unknown employee → empty string in join');
}
// sanitizeCell: formula-triggering leading chars get an apostrophe prefix
{
assert.equal(sanitizeCell('=1+1'), "'=1+1", 'leading = neutralized');
assert.equal(sanitizeCell('+cmd'), "'+cmd", 'leading + neutralized');
assert.equal(sanitizeCell('-2'), "'-2", 'leading - neutralized');
assert.equal(sanitizeCell('@x'), "'@x", 'leading @ neutralized');
assert.equal(sanitizeCell('Anna Müller'), 'Anna Müller', 'normal name unchanged');
}
// buildScheduleColumns: a malicious name is sanitized in the output cell
{
const cols = buildScheduleColumns(
[{ date: '2026-04-01', employeeId: 'x' }],
[{ id: 'x', name: '=HYPERLINK("http://evil")' }]
);
assert.ok(cols.names[0].startsWith("'="), 'formula name prefixed with apostrophe');
}
console.log('All excel tests passed.');
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
const HTML = `<!DOCTYPE html><html><body>
<button class="tab-btn active" data-tab="employees">Mitarbeiter</button>
<button class="tab-btn" data-tab="planning">Monatsplanung</button>
<button class="tab-btn" data-tab="data">Daten</button>
<section id="tab-employees" class="tab-content"></section>
<section id="tab-planning" class="tab-content hidden"></section>
<section id="tab-data" class="tab-content hidden"></section>
</body></html>`;
const dom = new JSDOM(HTML, { url: 'http://localhost/' });
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
dom.window.alert = () => {};
global.alert = () => {};
// Seed malformed persisted state BEFORE app.js loads:
// - an employee with no constraints (would crash plan generation if not normalized)
// - an out-of-range moderatorsPerDay
localStorage.setItem('morning-planner-state', JSON.stringify({
employees: [{ id: 'x', name: 'NoConstraints' }],
calendar: { holidays: [], companyClosures: [] },
history: [],
settings: { moderatorsPerDay: 99 }
}));
const { state } = await import('../js/app.js');
// Employee is loaded but normalized (constraints filled) so nothing crashes later
assert.equal(state.employees.length, 1, 'employee loaded from storage');
assert.ok(Array.isArray(state.employees[0].constraints.neverDays), 'missing constraints normalized');
assert.equal(state.employees[0].constraints.minGapDays, 14, 'minGapDays defaulted');
// Invalid moderatorsPerDay (99) falls back to the valid default
assert.equal(state.settings.moderatorsPerDay, 1, 'out-of-range setting falls back to 1');
// Rendering the employee tab with the normalized data does not throw
assert.ok(document.getElementById('emp-name-input'), 'app rendered after recovering from malformed state');
console.log('All persistence tests passed.');
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
// Minimal DOM matching index.html's tab contract
const HTML = `<!DOCTYPE html><html><body>
<button class="tab-btn active" data-tab="employees">Mitarbeiter</button>
<button class="tab-btn" data-tab="planning">Monatsplanung</button>
<button class="tab-btn" data-tab="data">Daten</button>
<section id="tab-employees" class="tab-content"></section>
<section id="tab-planning" class="tab-content hidden"></section>
<section id="tab-data" class="tab-content hidden"></section>
</body></html>`;
const dom = new JSDOM(HTML, { url: 'http://localhost/' });
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
dom.window.alert = () => {};
global.alert = () => {};
const $ = sel => document.querySelector(sel);
const tab = name => [...document.querySelectorAll('.tab-btn')].find(b => b.dataset.tab === name);
const btnByText = (root, text) => [...root.querySelectorAll('button')].find(b => b.textContent === text);
const fire = (el, type) => el.dispatchEvent(new dom.window.Event(type, { bubbles: true }));
// Importing app.js runs the startup load + renderEmployees()
const { state } = await import('../js/app.js');
// --- Employees tab renders its input ---
assert.ok($('#emp-name-input'), 'employee name input rendered');
// --- Adding an employee updates state and the list ---
const input = $('#emp-name-input');
input.value = 'Alice';
btnByText(document.getElementById('tab-employees'), 'Hinzufügen').click();
input.value = 'Bob';
btnByText(document.getElementById('tab-employees'), 'Hinzufügen').click();
assert.equal(state.employees.length, 2, 'two employees added to state');
assert.ok(document.getElementById('employee-list').textContent.includes('Alice'), 'Alice listed');
// --- Planning tab renders a calendar grid (exercises makeDayCell) ---
tab('planning').click();
const dayCells = document.querySelectorAll('#calendar-wrapper .cal-day');
assert.ok(dayCells.length > 0, 'calendar grid rendered day cells');
// --- Generating a plan fills slots ---
btnByText(document.getElementById('tab-planning'), 'Plan generieren').click();
const selects = [...document.querySelectorAll('#calendar-wrapper select')];
const filled = selects.filter(s => s.value !== '');
assert.ok(filled.length > 0, 'generate assigned moderators to slots');
assert.ok(state.history.length > 0, 'generated assignments stored in history');
// --- Manual slot change persists as a manual entry ---
const histBefore = state.history.length;
const firstFilled = filled[0];
const iso = state.history[0].date;
// change first filled slot to "— Leer —"
firstFilled.value = '';
fire(firstFilled, 'change');
assert.ok(state.history.length <= histBefore, 'clearing a slot removed its entry');
// --- Debounced save flushes on beforeunload and persists to localStorage ---
fire(window, 'beforeunload');
const saved = JSON.parse(localStorage.getItem('morning-planner-state'));
assert.equal(saved.employees.length, 2, 'employees persisted to localStorage');
assert.ok(saved.history.length > 0, 'history persisted to localStorage');
// --- Data tab renders the history table ---
tab('data').click();
assert.ok(document.getElementById('tab-data').textContent.includes('Verlauf'), 'data tab rendered');
console.log('All render tests passed.');
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import { toISO, monthPrefix, getWeekday, formatDateDE, buildNameMap } from '../js/utils.js';
// toISO: zero-pads month and day
assert.equal(toISO(2026, 4, 1), '2026-04-01', 'toISO pads single digits');
assert.equal(toISO(2026, 12, 31), '2026-12-31', 'toISO keeps two-digit parts');
// monthPrefix: YYYY-MM
assert.equal(monthPrefix(2026, 5), '2026-05', 'monthPrefix pads month');
// getWeekday: 1=Mon … 7=Sun (2026-04-01 is a Wednesday → 3; 2026-04-05 Sunday → 7)
assert.equal(getWeekday('2026-04-01'), 3, 'Wednesday → 3');
assert.equal(getWeekday('2026-04-05'), 7, 'Sunday → 7');
assert.equal(getWeekday('2026-04-06'), 1, 'Monday → 1');
// formatDateDE: ISO → DD.MM.YYYY
assert.equal(formatDateDE('2026-04-01'), '01.04.2026', 'formatDateDE DD.MM.YYYY');
// buildNameMap: id → name lookup
assert.deepEqual(
buildNameMap([{ id: 'a', name: 'Alice' }, { id: 'b', name: 'Bob' }]),
{ a: 'Alice', b: 'Bob' },
'buildNameMap maps id to name'
);
console.log('All utils tests passed.');