Compare commits
33
Commits
ac5e80726f
...
9c7062104a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7062104a | ||
|
|
189bcb6f38 | ||
|
|
836d0eeda5 | ||
|
|
b3eeca50b3 | ||
|
|
7ab2911c2f | ||
|
|
fd9870871b | ||
|
|
0efb9cb853 | ||
|
|
8cd8908f38 | ||
|
|
c076b5e1ce | ||
|
|
074635c037 | ||
|
|
e6452fa102 | ||
|
|
f8efcf136a | ||
|
|
142e0efa1f | ||
|
|
7b53618921 | ||
|
|
065876ced4 | ||
|
|
e851bb2833 | ||
|
|
700ec6de1c | ||
|
|
6736021af1 | ||
|
|
294de5276b | ||
|
|
078d74f09f | ||
|
|
fa28640c28 | ||
|
|
3b84fce1e6 | ||
|
|
69e424519d | ||
|
|
ad680ae1f6 | ||
|
|
a825a63961 | ||
|
|
7bda92a0a3 | ||
|
|
23683102bc | ||
|
|
3d314e84fd | ||
|
|
625f9c7037 | ||
|
|
790ea72987 | ||
|
|
e4ae866375 | ||
|
|
6fd2cfcf04 | ||
|
|
b794581a06 |
No files matched your search
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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
@@ -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">
|
||||
|
||||
+50
-31
@@ -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;
|
||||
|
||||
const scored = candidates
|
||||
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
monthAssignments.push({ date, employeeId: scored[0].employee.id, manual: false });
|
||||
if (candidates.length < needed) {
|
||||
skippedDates.push(date);
|
||||
continue;
|
||||
}
|
||||
|
||||
return monthAssignments;
|
||||
const scored = candidates
|
||||
.map(e => ({ employee: e, score: scoreCandidate(e, date, histCount, randomFn) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
for (let i = 0; i < needed; i++) {
|
||||
const entry = { date, employeeId: scored[i].employee.id, manual: false };
|
||||
monthAssignments.push(entry);
|
||||
generated.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return { assignments: generated, skippedDates };
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
+167
-74
@@ -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,41 +166,19 @@ 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';
|
||||
};
|
||||
|
||||
// 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.textContent = lbl;
|
||||
headerGrid.appendChild(cell);
|
||||
});
|
||||
wrapper.appendChild(headerGrid);
|
||||
|
||||
// Body grid
|
||||
const bodyGrid = document.createElement('div');
|
||||
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; })();
|
||||
for (let i = 1; i < firstWD; i++) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'cal-day empty-cell';
|
||||
bodyGrid.appendChild(empty);
|
||||
}
|
||||
|
||||
// Day cells
|
||||
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; })();
|
||||
// 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 assigneeId = planByDate[iso];
|
||||
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'cal-day';
|
||||
@@ -162,7 +186,6 @@ function renderGrid(wrapper, exportBtn) {
|
||||
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 + ' ';
|
||||
@@ -172,28 +195,37 @@ function renderGrid(wrapper, exportBtn) {
|
||||
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) {
|
||||
// Show label + remove button
|
||||
const tag = document.createElement('div');
|
||||
tag.style.cssText = 'font-size:11px;margin-top:4px';
|
||||
tag.className = 'cal-day-tag-label';
|
||||
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);
|
||||
refresh();
|
||||
});
|
||||
tag.appendChild(removeBtn);
|
||||
cell.appendChild(tag);
|
||||
} else {
|
||||
// Assignee dropdown
|
||||
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.style.cssText = 'width:100%;margin-top:4px;font-size:11px';
|
||||
sel.dataset.slot = String(slotIdx);
|
||||
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = '';
|
||||
@@ -204,51 +236,112 @@ function renderGrid(wrapper, exportBtn) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = emp.id;
|
||||
opt.textContent = emp.name; // textContent: safe
|
||||
if (emp.id === assigneeId) opt.selected = true;
|
||||
// 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 empId = sel.value;
|
||||
if (empId) {
|
||||
const entry = { date: iso, employeeId: empId, manual: true };
|
||||
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]);
|
||||
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);
|
||||
currentPlan.push(entry);
|
||||
}
|
||||
saveState();
|
||||
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
|
||||
refresh();
|
||||
});
|
||||
|
||||
cell.appendChild(sel);
|
||||
}
|
||||
|
||||
// FT / BU mark buttons
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.cssText = 'margin-top:4px;display:flex;gap:2px';
|
||||
btnRow.className = 'cal-btn-row';
|
||||
|
||||
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); });
|
||||
ftBtn.textContent = 'Feiertag';
|
||||
ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); saveState(); refresh(); });
|
||||
|
||||
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); });
|
||||
buBtn.textContent = 'Betriebsurl.';
|
||||
buBtn.addEventListener('click', () => { toggleClosure(state, iso); saveState(); refresh(); });
|
||||
|
||||
btnRow.append(ftBtn, buBtn);
|
||||
cell.appendChild(btnRow);
|
||||
}
|
||||
}
|
||||
|
||||
bodyGrid.appendChild(cell);
|
||||
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.className = 'cal-header-cell';
|
||||
cell.textContent = lbl;
|
||||
headerGrid.appendChild(cell);
|
||||
});
|
||||
wrapper.appendChild(headerGrid);
|
||||
|
||||
// Body grid
|
||||
const bodyGrid = document.createElement('div');
|
||||
bodyGrid.className = 'calendar-body-grid';
|
||||
|
||||
// Offset empty cells before first day
|
||||
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 — each cell can re-render itself; no full-grid rebuild per click
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
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);
|
||||
}
|
||||
+88
-16
@@ -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) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -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;
|
||||
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)); } catch { /* skip invalid */ }
|
||||
try { state.employees.push(createEmployee(name)); added++; } catch { /* skip invalid */ }
|
||||
}
|
||||
}
|
||||
e.target.value = '';
|
||||
saveState();
|
||||
refreshList();
|
||||
if (added === 0) alert('Alle Namen waren bereits vorhanden.');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Excel-Import fehlgeschlagen: ' + (err?.message || err));
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+29
-7
@@ -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
@@ -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
@@ -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]));
|
||||
}
|
||||
Generated
+557
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
Reference in new issue
Block a user