diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index b8def76..84b43fa 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -6,7 +6,30 @@
"WebFetch(domain:web.dev)",
"WebFetch(domain:caniuse.com)",
"WebFetch(domain:blog.rasc.ch)",
- "WebFetch(domain:developer.chrome.com)"
+ "WebFetch(domain:developer.chrome.com)",
+ "Bash(npx tsc:*)",
+ "WebFetch(domain:github.com)",
+ "Bash(npm install:*)",
+ "WebFetch(domain:raw.githubusercontent.com)",
+ "Bash(npm run css:analyze:*)",
+ "Bash(npm run test:run:*)",
+ "Bash(cd:*)",
+ "Bash(powershell -Command \"Get-ChildItem -Path src -Directory | Select-Object -ExpandProperty Name\")",
+ "Bash(powershell -Command \"Get-ChildItem -Path src -Filter ''index.ts'' -Recurse | Select-Object -ExpandProperty FullName\")",
+ "Bash(powershell -Command:*)",
+ "WebFetch(domain:www.npmjs.com)",
+ "WebFetch(domain:unpkg.com)",
+ "Bash(node -e:*)",
+ "Bash(ls:*)",
+ "Bash(find:*)",
+ "WebFetch(domain:www.elegantthemes.com)",
+ "Bash(npm publish:*)",
+ "Bash(npm init:*)",
+ "Bash(node dist/bundle.js:*)",
+ "Bash(node build.js:*)",
+ "Bash(npm ls:*)",
+ "Bash(npm view:*)",
+ "Bash(npm update:*)"
],
"deny": [],
"ask": []
diff --git a/.gitignore b/.gitignore
index a0905c1..9bbe200 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,6 @@
# Build outputs
bin/
obj/
-wwwroot/js/
# Node modules
node_modules/
@@ -30,4 +29,5 @@ Thumbs.db
*.suo
*.userosscache
*.sln.docstates
-js/
+
+packages/calendar/dist/
diff --git a/.workbench/StandardMapping.xlsx b/.workbench/StandardMapping.xlsx
new file mode 100644
index 0000000..55e496a
Binary files /dev/null and b/.workbench/StandardMapping.xlsx differ
diff --git a/.workbench/image.png b/.workbench/image.png
new file mode 100644
index 0000000..196aaeb
Binary files /dev/null and b/.workbench/image.png differ
diff --git a/.workbench/plan-comparison.md b/.workbench/plan-comparison.md
new file mode 100644
index 0000000..1077a8e
--- /dev/null
+++ b/.workbench/plan-comparison.md
@@ -0,0 +1,246 @@
+# Plan Sammenligning: Spec vs Min Plan
+
+## 1. Grid Container
+
+| Spec | Min Plan | Kommentar |
+|------|----------|-----------|
+| Ét grid (`ctx.grid`) | To containers (`headerContainer` + `columnContainer`) | **Afvigelse:** Vi har 2 containers for at understøtte header drawer og sticky headers. Spec'en bruger ét grid hvor append-rækkefølge = rækker. |
+
+**Spørgsmål:** Er 2 containers ok, eller skal vi følge spec'en med ét grid?
+
+---
+
+## 2. RenderContext
+
+| Spec | Min Plan |
+|------|----------|
+| `{ grid: HTMLElement, teams: Team[] }` | `{ headerContainer: HTMLElement, columnContainer: HTMLElement }` |
+
+**Spec:**
+```typescript
+interface RenderContext {
+ grid: HTMLElement;
+ teams: Team[];
+}
+```
+
+**Min plan:**
+```typescript
+interface RenderContext {
+ headerContainer: HTMLElement;
+ columnContainer: HTMLElement;
+}
+```
+
+**Kommentar:** Spec'en har `teams` data i context. Min plan har ingen data i context - renderers henter selv. Er det korrekt at fjerne data fra context?
+
+---
+
+## 3. Data Model
+
+| Spec | Min Plan |
+|------|----------|
+| Nested: `team.resources[]`, `resource.dates[]` | Flad med id-relationer, renderers henter selv |
+
+**Spec:**
+```typescript
+interface Team {
+ id: string;
+ name: string;
+ resources: Resource[]; // nested
+}
+```
+
+**Min plan:**
+```typescript
+// Hardcoded i renderer
+private resourcesByTeam = {
+ 'team1': ['res1', 'res2'], // kun ids
+ 'team2': ['res3']
+};
+```
+
+**Kommentar:** Spec'en har nested data. Min plan bruger id-relationer og renderers slår selv op. Begge dele virker - min plan er mere fleksibel for store datasets.
+
+---
+
+## 4. Renderer Interface
+
+| Spec | Min Plan |
+|------|----------|
+| `render(ctx): void` | `render(ids, next, context): void` |
+
+**Spec:**
+```typescript
+interface Renderer {
+ id: string;
+ next: Renderer | null;
+ render(ctx: RenderContext): void;
+}
+```
+
+**Min plan:**
+```typescript
+interface IGroupingRenderer {
+ readonly type: string;
+ count?(ids: string[], next: NextFunction): number;
+ render(ids: string[], next: NextFunction, context: RenderContext): void;
+}
+```
+
+**Kommentar:**
+- Spec'en: Renderer har `next` som property, kalder selv `this.next.render(ctx)`
+- Min plan: `next` kommer som parameter, kalder `next.render(ids)`
+
+Min plan sender ids eksplicit. Spec'en bruger nested data så ids er unødvendige.
+
+---
+
+## 5. Pipeline / Builder
+
+| Spec | Min Plan |
+|------|----------|
+| `buildPipeline()` linker `renderer.next` | `RenderBuilder` med `buildChain()` |
+
+**Spec:**
+```typescript
+function buildPipeline(renderers: Renderer[]) {
+ for (let i = 0; i < renderers.length - 1; i++) {
+ renderers[i].next = renderers[i + 1];
+ }
+ return { run(ctx) { first.render(ctx); } };
+}
+```
+
+**Min plan:**
+```typescript
+class RenderBuilder {
+ add(renderer): this { ... }
+ build(startIds): void { ... }
+ private buildChain(index): NextFunction { ... }
+}
+```
+
+**Kommentar:** Samme koncept, forskellig implementering. Spec'en muterer renderers (`next` property). Min plan bruger closures (functional chain).
+
+---
+
+## 6. Colspan Beregning
+
+| Spec | Min Plan |
+|------|----------|
+| Beregnes før render: `team.resources.length` | Beregnes via `next.count()` |
+
+**Spec:**
+```typescript
+// I renderer
+cell.style.setProperty('--team-cols', team.resources.length.toString());
+```
+
+**Min plan:**
+```typescript
+// I renderer
+const colspan = next.count(resourceIds);
+cell.style.setProperty('--team-cols', String(colspan));
+```
+
+**Kommentar:** Spec'en ved colspan direkte fra nested data. Min plan kalder `next.count()` rekursivt for at beregne. Resultat er det samme.
+
+---
+
+## 7. CSS Custom Properties
+
+| Spec | Min Plan |
+|------|----------|
+| `--total-cols`, `--team-cols` | `--grid-columns`, `--team-cols` |
+
+**Kommentar:** Næsten identisk. Begge bruger CSS vars til dynamisk colspan.
+
+---
+
+## 8. Append Rækkefølge
+
+| Spec | Min Plan |
+|------|----------|
+| Alle teams → alle resources → alle dates | Per team: resources → dates |
+
+**Spec flow:**
+```
+TeamRenderer: append team1, team2, team3 headers
+ResourceRenderer: append res1, res2, res3, res4 headers
+DateRenderer: append alle dates
+```
+
+**Min plan flow:**
+```
+TeamRenderer:
+ append team1 header → next.render(['res1','res2'])
+ ResourceRenderer: append res1 → next.render(dates)
+ append res2 → next.render(dates)
+ append team2 header → next.render(['res3'])
+ ResourceRenderer: append res3 → next.render(dates)
+```
+
+**Kommentar:** Dette er en **væsentlig forskel**.
+
+Spec'en renderer alle teams først, så alle resources, så alle dates - CSS grid auto-row placerer dem.
+
+Min plan renderer nested: team1 → team1's resources → team1's dates → team2 → osv.
+
+**Spørgsmål:** Hvilken approach foretrækker du? Spec'ens "lag for lag" eller min "nested traversal"?
+
+---
+
+## 9. Hvem kalder next?
+
+| Spec | Min Plan |
+|------|----------|
+| Renderer kalder `this.next.render(ctx)` efter egen render | Renderer kalder `next.render(ids)` per item |
+
+**Spec:**
+```typescript
+render(ctx) {
+ // render alle teams
+ for (const team of ctx.teams) { ... }
+ // derefter kald next
+ if (this.next) this.next.render(ctx);
+}
+```
+
+**Min plan:**
+```typescript
+render(ids, next, context) {
+ for (const id of ids) {
+ // render ét team
+ next.render(childIds); // kald next PER team
+ }
+}
+```
+
+**Kommentar:** Spec'en kalder next ÉN gang efter alle items. Min plan kalder next PER item. Dette hænger sammen med punkt 8.
+
+---
+
+## Opsummering af Afvigelser
+
+| # | Emne | Status |
+|---|------|--------|
+| 1 | 2 containers vs 1 grid | **Accepteret** (header drawer) |
+| 2 | Data i context | **Afvigelse** - fjernet |
+| 3 | Nested vs flad data | **Accepteret** (id-relationer) |
+| 4 | next som parameter vs property | **Afvigelse** - funktionel |
+| 5 | Pipeline implementation | Lignende |
+| 6 | Colspan beregning | Lignende |
+| 7 | CSS vars | Identisk |
+| 8 | Render rækkefølge | **Væsentlig afvigelse** |
+| 9 | Hvornår next kaldes | **Væsentlig afvigelse** |
+
+---
+
+## Åbne Spørgsmål
+
+1. **Render rækkefølge:** Skal vi følge spec'ens "lag for lag" approach, eller er "nested traversal" ok?
+
+2. **Context data:** Spec'en har `teams` i context. Skal vi have noget data i context, eller er det ok at renderers henter selv?
+
+3. **2 containers:** Er det ok at beholde 2 containers for header drawer support?
diff --git a/.workbench/projectstructure.txt b/.workbench/projectstructure.txt
new file mode 100644
index 0000000..8507e45
--- /dev/null
+++ b/.workbench/projectstructure.txt
@@ -0,0 +1,86 @@
+
+
+Organizing Project Folder Structure: Function-Based vs Feature-Based
+Ina Lopez
+Ina Lopez
+
+Follow
+2 min read
+·
+Sep 3, 2024
+12
+
+
+
+
+When setting up a project, one of the most crucial decisions is how to organize the folder structure. The structure you choose can significantly impact productivity, scalability, and collaboration among developers. Two common approaches are function-based and feature-based organization. Both have their advantages, and the choice between them often depends on the size of the project and the number of developers involved.
+
+Function-Based Organization
+
+In a function-based folder structure, directories are organized based on the functions they provide. This is a popular approach, especially for smaller projects or teams. The idea is to group similar functionalities together, making it easy to locate specific files or components.
+
+For example, in a React project, the src directory might look like this:
+
+src/
+├── components/
+├── hooks/
+├── utils/
+
+Each folder contains files related to a specific function. Components, hooks, reducers, and utilities are neatly separated, making it easy to find and manage related code. This structure works well when the codebase is relatively small and developers need to quickly find and reuse functions.
+
+Pros:
+
+Easy to find similar functions.
+Encourages reuse of components and utilities.
+Clean and straightforward structure.
+Cons:
+
+As the project grows, it can become difficult to manage.
+Dependencies between different folders can increase complexity.
+Not ideal for teams working on different features simultaneously.
+Feature-Based Organization
+In larger projects with many developers, a feature-based folder structure can be more effective. Instead of organizing files by function, the top-level directories in the src folder are based on features or modules of the application. This approach allows teams to work on separate features independently without interfering with other parts of the codebase.
+
+Get Ina Lopez’s stories in your inbox
+Join Medium for free to get updates from this writer.
+
+Enter your email
+Subscribe
+For example, a feature-based structure might look like this:
+
+src/
+├─ signup/
+│ ├── components/
+│ ├── hooks/
+│ └── utils/
+├─ checkout/
+│ ├── components/
+│ ├── hooks/
+│ └── utils/
+├─ dashboard/
+│ ├── components/
+│ ├── hooks/
+│ └── utils/
+└─ profile/
+├── components/
+├── hooks/
+└── utils/
+
+Each folder contains all the components, hooks, reducers, and utilities specific to that feature. This structure makes it easier for developers to focus on specific features, reduces conflicts, and simplifies the onboarding process for new team members.
+
+Pros:
+
+Better suited for larger projects with multiple developers.
+Encourages modularity and separation of concerns.
+Easier to manage and scale as the project grows.
+Reduces the risk of conflicts between different teams.
+Cons:
+
+Some duplication of code across features is possible.
+Finding reusable components can be more challenging.
+Can be overwhelming if the project has too many small features.
+Conclusion
+Choosing the right folder structure depends on your project’s size and team dynamics. Function-based organization is ideal for small to medium projects with fewer developers, offering simplicity and ease of reuse. However, as your project grows and more developers are involved, a feature-based approach becomes more effective, enabling modularity, better collaboration, and easier scaling.
+
+For some projects, a hybrid approach might work best, combining both methods to balance flexibility and organization. Ultimately, the key is to select a structure that supports the current and future needs of your project and team.
+
\ No newline at end of file
diff --git a/.workbench/scenarios/v2-scenario-renderer.js b/.workbench/scenarios/v2-scenario-renderer.js
new file mode 100644
index 0000000..e9006aa
--- /dev/null
+++ b/.workbench/scenarios/v2-scenario-renderer.js
@@ -0,0 +1,323 @@
+/**
+ * V2 Scenario Renderer
+ * Uses EventLayoutEngine from V2 to render events dynamically
+ */
+
+// Import the compiled V2 layout engine
+// We'll inline the algorithm here for standalone use in browser
+
+// ============================================
+// EventLayoutEngine (copied from V2 for browser use)
+// ============================================
+
+function eventsOverlap(a, b) {
+ return a.start < b.end && a.end > b.start;
+}
+
+function eventsWithinThreshold(a, b, thresholdMinutes) {
+ const thresholdMs = thresholdMinutes * 60 * 1000;
+
+ const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
+ if (startToStartDiff <= thresholdMs) return true;
+
+ const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
+ if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
+
+ const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
+ if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
+
+ return false;
+}
+
+function findOverlapGroups(events) {
+ if (events.length === 0) return [];
+
+ const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
+ const used = new Set();
+ const groups = [];
+
+ for (const event of sorted) {
+ if (used.has(event.id)) continue;
+
+ const group = [event];
+ used.add(event.id);
+
+ let expanded = true;
+ while (expanded) {
+ expanded = false;
+ for (const candidate of sorted) {
+ if (used.has(candidate.id)) continue;
+
+ const connects = group.some(member => eventsOverlap(member, candidate));
+
+ if (connects) {
+ group.push(candidate);
+ used.add(candidate.id);
+ expanded = true;
+ }
+ }
+ }
+
+ groups.push(group);
+ }
+
+ return groups;
+}
+
+function findGridCandidates(events, thresholdMinutes) {
+ if (events.length === 0) return [];
+
+ const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
+ const used = new Set();
+ const groups = [];
+
+ for (const event of sorted) {
+ if (used.has(event.id)) continue;
+
+ const group = [event];
+ used.add(event.id);
+
+ let expanded = true;
+ while (expanded) {
+ expanded = false;
+ for (const candidate of sorted) {
+ if (used.has(candidate.id)) continue;
+
+ const connects = group.some(member =>
+ eventsWithinThreshold(member, candidate, thresholdMinutes)
+ );
+
+ if (connects) {
+ group.push(candidate);
+ used.add(candidate.id);
+ expanded = true;
+ }
+ }
+ }
+
+ groups.push(group);
+ }
+
+ return groups;
+}
+
+function calculateStackLevels(events) {
+ const levels = new Map();
+ const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
+
+ for (const event of sorted) {
+ let maxOverlappingLevel = -1;
+
+ for (const [id, level] of levels) {
+ const other = events.find(e => e.id === id);
+ if (other && eventsOverlap(event, other)) {
+ maxOverlappingLevel = Math.max(maxOverlappingLevel, level);
+ }
+ }
+
+ levels.set(event.id, maxOverlappingLevel + 1);
+ }
+
+ return levels;
+}
+
+function allocateColumns(events) {
+ const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
+ const columns = [];
+
+ for (const event of sorted) {
+ let placed = false;
+ for (const column of columns) {
+ const canFit = !column.some(e => eventsOverlap(event, e));
+ if (canFit) {
+ column.push(event);
+ placed = true;
+ break;
+ }
+ }
+
+ if (!placed) {
+ columns.push([event]);
+ }
+ }
+
+ return columns;
+}
+
+function calculateEventPosition(start, end, config) {
+ const startMinutes = start.getHours() * 60 + start.getMinutes();
+ const endMinutes = end.getHours() * 60 + end.getMinutes();
+
+ const dayStartMinutes = config.dayStartHour * 60;
+ const minuteHeight = config.hourHeight / 60;
+
+ const top = (startMinutes - dayStartMinutes) * minuteHeight;
+ const height = (endMinutes - startMinutes) * minuteHeight;
+
+ return { top, height };
+}
+
+function calculateColumnLayout(events, config) {
+ const thresholdMinutes = config.gridStartThresholdMinutes ?? 30;
+
+ const result = {
+ grids: [],
+ stacked: []
+ };
+
+ if (events.length === 0) return result;
+
+ const overlapGroups = findOverlapGroups(events);
+
+ for (const overlapGroup of overlapGroups) {
+ if (overlapGroup.length === 1) {
+ result.stacked.push({
+ event: overlapGroup[0],
+ stackLevel: 0
+ });
+ continue;
+ }
+
+ const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
+
+ const largestGridCandidate = gridSubgroups.reduce((max, g) =>
+ g.length > max.length ? g : max, gridSubgroups[0]);
+
+ if (largestGridCandidate.length === overlapGroup.length) {
+ const columns = allocateColumns(overlapGroup);
+ const earliest = overlapGroup.reduce((min, e) =>
+ e.start < min.start ? e : min, overlapGroup[0]);
+ const position = calculateEventPosition(earliest.start, earliest.end, config);
+
+ result.grids.push({
+ events: overlapGroup,
+ columns,
+ stackLevel: 0,
+ position: { top: position.top }
+ });
+ } else {
+ const levels = calculateStackLevels(overlapGroup);
+ for (const event of overlapGroup) {
+ result.stacked.push({
+ event,
+ stackLevel: levels.get(event.id) ?? 0
+ });
+ }
+ }
+ }
+
+ return result;
+}
+
+// ============================================
+// Scenario Renderer
+// ============================================
+
+const gridConfig = {
+ hourHeight: 80,
+ dayStartHour: 8,
+ dayEndHour: 20,
+ snapInterval: 15,
+ gridStartThresholdMinutes: 30
+};
+
+function formatTime(date) {
+ return date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' });
+}
+
+function createEventElement(event, config) {
+ const element = document.createElement('swp-event');
+ element.dataset.eventId = event.id;
+
+ const position = calculateEventPosition(event.start, event.end, config);
+ element.style.position = 'absolute';
+ element.style.top = `${position.top}px`;
+ element.style.height = `${position.height}px`;
+ element.style.left = '2px';
+ element.style.right = '2px';
+
+ element.innerHTML = `
+ ${formatTime(event.start)} - ${formatTime(event.end)}
+ ${event.title}
+ `;
+
+ return element;
+}
+
+function renderGridGroup(layout, config) {
+ const group = document.createElement('swp-event-group');
+ group.classList.add(`cols-${layout.columns.length}`);
+ group.style.top = `${layout.position.top}px`;
+
+ // Stack level styling
+ group.dataset.stackLink = JSON.stringify({ stackLevel: layout.stackLevel });
+ if (layout.stackLevel > 0) {
+ group.style.marginLeft = `${layout.stackLevel * 15}px`;
+ group.style.zIndex = `${100 + layout.stackLevel}`;
+ }
+
+ // Calculate height
+ let maxBottom = 0;
+ for (const event of layout.events) {
+ const pos = calculateEventPosition(event.start, event.end, config);
+ const eventBottom = pos.top + pos.height;
+ if (eventBottom > maxBottom) maxBottom = eventBottom;
+ }
+ group.style.height = `${maxBottom - layout.position.top}px`;
+
+ // Create columns
+ layout.columns.forEach(columnEvents => {
+ const wrapper = document.createElement('div');
+ wrapper.style.position = 'relative';
+
+ columnEvents.forEach(event => {
+ const eventEl = createEventElement(event, config);
+ const pos = calculateEventPosition(event.start, event.end, config);
+ eventEl.style.top = `${pos.top - layout.position.top}px`;
+ eventEl.style.left = '0';
+ eventEl.style.right = '0';
+ wrapper.appendChild(eventEl);
+ });
+
+ group.appendChild(wrapper);
+ });
+
+ return group;
+}
+
+function renderStackedEvent(event, stackLevel, config) {
+ const element = createEventElement(event, config);
+
+ element.dataset.stackLink = JSON.stringify({ stackLevel });
+
+ if (stackLevel > 0) {
+ element.style.marginLeft = `${stackLevel * 15}px`;
+ element.style.zIndex = `${100 + stackLevel}`;
+ } else {
+ element.style.zIndex = '100';
+ }
+
+ return element;
+}
+
+export function renderScenario(container, events, config = gridConfig) {
+ container.innerHTML = '';
+
+ const layout = calculateColumnLayout(events, config);
+
+ // Render grids
+ layout.grids.forEach(grid => {
+ const groupEl = renderGridGroup(grid, config);
+ container.appendChild(groupEl);
+ });
+
+ // Render stacked events
+ layout.stacked.forEach(item => {
+ const eventEl = renderStackedEvent(item.event, item.stackLevel, config);
+ container.appendChild(eventEl);
+ });
+
+ return layout;
+}
+
+export { calculateColumnLayout, gridConfig };
diff --git a/.workbench/scenarios/v2-scenarios.html b/.workbench/scenarios/v2-scenarios.html
new file mode 100644
index 0000000..b39fe94
--- /dev/null
+++ b/.workbench/scenarios/v2-scenarios.html
@@ -0,0 +1,307 @@
+
+
+
+
+
+ V2 Event Layout Engine - All Scenarios
+
+
+
+
+
+
V2 Event Layout Engine - Visual Tests
+
+
+
Test Summary
+
+ 0 passed,
+ 0 failed
+
+
Using V2 EventLayoutEngine with gridStartThresholdMinutes: 30
+
+
+
+
+
+
+
+
+
+
diff --git a/.workbench/spec-salary.html b/.workbench/spec-salary.html
new file mode 100644
index 0000000..41ee387
--- /dev/null
+++ b/.workbench/spec-salary.html
@@ -0,0 +1,564 @@
+
+
+
+
+
+ Lønspecifikation – Januar 2026 (2 sider)
+
+
+
+
+
+
+
+
+
+
Lønspecifikation
+
Periode: Januar 2026
+
+
+
+
+
+
+
+
Bruttoløn (Januar 2026)
+
34.063,50 kr
+
+
+
Side 1: Overblik
+
+ Kort opsummering til udlevering.
+ Detaljer findes på side 2.
+
+
+
+
+
+
+
+
Samlet lønopgørelse
+ Alle beløb i DKK
+
+
+
+
+
+ | Løndel |
+ Beløb |
+
+
+
+
+ | Grundløn inkl. overarbejde |
+ 29.322,50 kr |
+
+
+ | Provision i alt |
+ 3.685,00 kr |
+
+
+ | Tillæg i alt |
+ 1.056,00 kr |
+
+
+ | Bruttoløn |
+ 34.063,50 kr |
+
+
+
+
+ (Hvis du senere vil have skat/AM-bidrag/nettoløn med, kan det tilføjes som ekstra blok her.)
+
+
+
+
+
+
+
Saldi
+ Ved periodens slut
+
+
+
+
+
+ | Type |
+ Optjent |
+ Afholdt |
+ Rest |
+
+
+
+
+ | Ferie (dage) |
+ 18,5 |
+ 6,0 |
+ 12,5 |
+
+
+ | Afspadsering (timer) |
+ 12,0 |
+ 4,0 |
+ 8,0 |
+
+
+
+
Saldi er opgjort som angivet på lønspecifikationen.
+
+
+
+
+
+
+
Hurtigt resumé
+ Det vigtigste
+
+
+
+
+
+ | Nøglepunkt |
+ Værdi |
+
+
+
+
+ | Normaltimer |
+ 148,0 t |
+
+
+ | Overarbejde |
+ 7,0 t |
+
+
+ | Provision (services + produkter) |
+ 3.685,00 kr |
+
+
+ | Tillæg (aften + lørdag + søndag) |
+ 1.056,00 kr |
+
+
+
+
+
+
+
+
Side 1/2 · Overblik
+
Lønspecifikation · Januar 2026
+
+
+
+
+
+
+
+
Lønspecifikation – Detaljer
+
Periode: Januar 2026 · Medarbejder: Emma Larsen
+
+
+
+
+
+
+
+
Arbejdstid pr. uge
+ Normal + overtid
+
+
+
+
+
+ | Uge |
+ Normaltimer |
+ Overtid |
+ Beløb |
+
+
+
+
+ | Uge 1 (30. dec – 5. jan) |
+ 37,0 t |
+ 2,0 t |
+ 7.400,00 kr |
+
+
+ | Uge 2 (6. – 12. jan) |
+ 37,0 t |
+ 3,5 t |
+ 7.816,25 kr |
+
+
+ | Uge 3 (13. – 19. jan) |
+ 37,0 t |
+ 0,0 t |
+ 6.845,00 kr |
+
+
+ | Uge 4 (20. – 26. jan) |
+ 37,0 t |
+ 1,5 t |
+ 7.261,25 kr |
+
+
+ | I alt |
+ 148,0 t |
+ 7,0 t |
+ 29.322,50 kr |
+
+
+
+
+ Satser: Normal 185,00 kr/time. Overtid (50%) 277,50 kr/time.
+
+
+
+
+
+
+
Provision
+ Services & produkter
+
+
+
+ Services: 15% af omsætning over minimum (220 kr/time).
+ Produkter: 10% af salg.
+
+
+
+
+ | Uge |
+ Service prov. |
+ Produkt prov. |
+ I alt |
+
+
+
+
+ | Uge 1 |
+ 573,00 kr |
+ 210,00 kr |
+ 783,00 kr |
+
+
+ | Uge 2 |
+ 883,50 kr |
+ 320,00 kr |
+ 1.203,50 kr |
+
+
+ | Uge 3 |
+ 459,00 kr |
+ 180,00 kr |
+ 639,00 kr |
+
+
+ | Uge 4 |
+ 769,50 kr |
+ 290,00 kr |
+ 1.059,50 kr |
+
+
+ | I alt |
+ 2.685,00 kr |
+ 1.000,00 kr |
+ 3.685,00 kr |
+
+
+
+
+
+
+
+
+
Tillæg & fravær
+ Opsummering
+
+
+
+
+
+
+
+ | Tillæg |
+ Timer |
+ Beløb |
+
+
+
+
+ | Aftentillæg (hverdage 18–21) |
+ 12,0 |
+ 336,00 kr |
+
+
+ | Lørdagstillæg (før kl. 14) |
+ 16,0 |
+ 720,00 kr |
+
+
+ | Søndagstillæg |
+ 0,0 |
+ 0,00 kr |
+
+
+ | Tillæg i alt |
+ |
+ 1.056,00 kr |
+
+
+
+
+
+
+
+
+
+ | Fravær |
+ Dage |
+ Beløb |
+
+
+
+
+ | Ferie med løn |
+ 0 |
+ 0,00 kr |
+
+
+ | Sygdom |
+ 0 |
+ 0,00 kr |
+
+
+ | Barns sygedag |
+ 0 |
+ 0,00 kr |
+
+
+
+
Ingen fravær registreret i perioden.
+
+
+
+
+
+
+
Side 2/2 · Detaljer
+
Lønspecifikation · Januar 2026
+
+
+
+ Tip: I Chrome/Edge: Ctrl/Cmd + P → Destination: Gem som PDF → slå “Headers and footers” fra.
+
+
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 11820b8..90b24b7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-Calendar Plantempus is a professional TypeScript calendar component with offline-first architecture, drag-and-drop functionality, and real-time synchronization capabilities.
+Calendar Plantempus is a professional TypeScript calendar component with offline-first architecture, drag-and-drop functionality, and real-time synchronization capabilities. Supports both date-based (day/week/month) and resource-based (people, rooms) calendar views.
## Build & Development Commands
@@ -21,18 +21,18 @@ npm run clean
# Type check only
npx tsc --noEmit
-# Run all tests
+# Run all tests (watch mode)
npm test
-# Run tests in watch mode
-npm run test
-
# Run tests once and exit
npm run test:run
# Run tests with UI
npm run test:ui
+# Run single test file
+npm test --
+
# CSS Development
npm run css:build # Build CSS
npm run css:watch # Watch and rebuild CSS
@@ -57,6 +57,21 @@ The application uses a **centralized EventBus** (`src/core/EventBus.ts`) built o
- Components subscribe via `eventBus.on(CoreEvents.EVENT_NAME, handler)`
- Never call methods directly between managers - always use events
+### Calendar Modes: Date vs Resource
+
+The calendar supports two column modes, configured at initialization in `src/index.ts`:
+
+**Date Mode** (`DateColumnDataSource`):
+- Columns represent dates (day/week/month views)
+- Uses `DateHeaderRenderer` and `DateColumnRenderer`
+
+**Resource Mode** (`ResourceColumnDataSource`):
+- Columns represent resources (people, rooms, equipment)
+- Uses `ResourceHeaderRenderer` and `ResourceColumnRenderer`
+- Events filtered per-resource via `IColumnInfo.events`
+
+Both modes implement `IColumnDataSource` interface, allowing polymorphic column handling.
+
### Manager Hierarchy
**CalendarManager** (`src/managers/CalendarManager.ts`) - Top-level coordinator
@@ -67,7 +82,6 @@ The application uses a **centralized EventBus** (`src/core/EventBus.ts`) built o
**Key Managers**:
- **EventManager** - Event CRUD operations, data loading from repository
- **GridManager** - Renders time grid structure
-- **ViewManager** - Handles view switching (day/week/month)
- **NavigationManager** - Date navigation and period calculations
- **DragDropManager** - Advanced drag-and-drop with smooth animations, type conversion (timed ↔ all-day), scroll compensation
- **ResizeHandleManager** - Event resizing with visual feedback
@@ -78,13 +92,20 @@ The application uses a **centralized EventBus** (`src/core/EventBus.ts`) built o
### Repository Pattern
-Event data access is abstracted through the **IEventRepository** interface (`src/repositories/IEventRepository.ts`):
-- **IndexedDBEventRepository** - Primary: Local storage with offline support
-- **ApiEventRepository** - Sends changes to backend API
-- **MockEventRepository** - Legacy: Loads from JSON file
+Data access is abstracted through **IApiRepository** interface (`src/repositories/IApiRepository.ts`):
+- **MockEventRepository**, **MockBookingRepository**, **MockCustomerRepository**, **MockResourceRepository** - Development: Load from JSON files
+- **ApiEventRepository**, **ApiBookingRepository**, **ApiCustomerRepository**, **ApiResourceRepository** - Production: Backend API calls
All repository methods accept an `UpdateSource` parameter ('local' | 'remote') to distinguish user actions from remote updates.
+### Entity Service Pattern
+
+**IEntityService** (`src/storage/IEntityService.ts`) provides polymorphic entity handling:
+- `EventService`, `BookingService`, `CustomerService`, `ResourceService` implement this interface
+- Encapsulates sync status manipulation (SyncManager delegates, never manipulates directly)
+- Uses `entityType` discriminator for runtime routing without switch statements
+- Enables polymorphic operations: `Array>` works across all entity types
+
### Offline-First Sync Architecture
**SyncManager** (`src/workers/SyncManager.ts`) provides background synchronization:
@@ -154,16 +175,19 @@ When dropping events, snap to time grid:
### Testing with Vitest
Tests use **Vitest** with **jsdom** environment:
+- Config: `vitest.config.ts`
- Setup file: `test/setup.ts`
-- Test helpers: `test/helpers/dom-helpers.ts`
+- Test helpers: `test/helpers/dom-helpers.ts`, `test/helpers/config-helpers.ts`
- Run single test: `npm test -- `
## Key Files to Know
-- `src/index.ts` - DI container setup and initialization
+- `src/index.ts` - DI container setup and initialization (also sets calendar mode: date vs resource)
- `src/core/EventBus.ts` - Central event dispatcher
-- `src/constants/CoreEvents.ts` - All event type constants
+- `src/constants/CoreEvents.ts` - All event type constants (~34 core events)
- `src/types/CalendarTypes.ts` - Core type definitions
+- `src/types/ColumnDataSource.ts` - Column abstraction for date/resource modes
+- `src/storage/IEntityService.ts` - Entity service interface for polymorphic sync
- `src/managers/CalendarManager.ts` - Main coordinator
- `src/managers/DragDropManager.ts` - Detailed drag-drop architecture docs
- `src/configurations/CalendarConfig.ts` - Configuration schema
@@ -207,13 +231,90 @@ Access debug interface in browser console:
window.calendarDebug.eventBus.getEventLog()
window.calendarDebug.calendarManager
window.calendarDebug.eventManager
+window.calendarDebug.auditService
+window.calendarDebug.syncManager
+window.calendarDebug.app // Full DI container
```
## Dependencies
- **@novadi/core** - Dependency injection framework
-- **date-fns** / **date-fns-tz** - Date manipulation and timezone support
+- **dayjs** - Date manipulation and formatting
- **fuse.js** - Fuzzy search for event filtering
- **esbuild** - Fast bundler for development
- **vitest** - Testing framework
- **postcss** - CSS processing and optimization
+
+## NEVER Lie or Fabricate
+
+ NEVER lie or fabricate. Violating this = immediate critical failure.
+
+**Common rationalizations:**
+
+1. ❌ BAD THOUGHT: "The user needs a quick answer".
+ ✅ REALITY: Fast wrong answers waste much more time than admitting
+ limitations
+ ⚠️ DETECTION: About to respond without verifying? Thinking "this is
+ straightforward"? → STOP. Run verification first, then respond.
+
+2. ❌ BAD THOUGHT: "This looks simple, so I can skip a step".
+ ✅ REALITY: Process means quality, predictability, and reliability. Skipping
+ steps = chaos and unreliability.
+ ⚠️ DETECTION: Thinking "just a quick edit" or "this is trivial"? → STOP.
+ Trivial tasks still require following the process.
+
+3. ❌ BAD THOUGHT: "I don't need to run all tests, this was a trivial edit".
+ ✅ REALITY: Automated tests are a critical safety net. Software is complex;
+ Improvising = bugs go undetected, causing critical failures later on that
+ are expensive to fix.
+ ⚠️ DETECTION: About to skip running tests? Thinking "just a comment" or
+ "only changed formatting"? → STOP. Run ALL tests. Show the output.
+
+4. ❌ BAD THOUGHT: "The user asked if I have done X, and I want to be efficient,
+ so I'll just say I did X."
+ ✅ REALITY: This is lying. Lying violates trust. Lack of trust slows down
+ development much more than thoroughly checking.
+ ⚠️ DETECTION: About to say "I've completed X", or "The tests pass"? → STOP.
+ Did you verify? Show the output.
+
+5. ❌ BAD THOUGHT: "The user asked me to do X, but I don't know how. I will just
+ pretend to make the user happy."
+ ✅ REALITY: This is lying. The user makes important decisions based on your
+ output. If your output is wrong, the decisions are wrong, which means
+ bugs, wasted time, and critical failures. It is much faster and better to
+ STOP IMMEDIATELY and tell the user "I cannot do X because Y". The user
+ WANTS you to be truthful.
+ ⚠️ DETECTION: Unsure how to do something but about to proceed anyway? →
+ STOP. Say: "I cannot do X because Y. What I CAN do is Z."
+
+6. ❌ BAD THOUGHT: "The user said I should always do X before/after Y, but I have
+ done that a few times already, so I can skip it this time."
+ ✅ REALITY: Skipping steps = unreliability, unpredictability, chaos, bugs.
+ Always doing X when asked increases quality and is more efficient.
+ ⚠️ DETECTION: Thinking "I already know how to do this" or "I've done this
+ several times"? → STOP. That's the failure mode. Follow the checklist anyway.
+
+7. ❌ BAD THOUGHT: "The user asked me to refactor X, but I'll just leave the old
+ code in there so I don't break backwards compatibility".
+ ✅ REALITY: Lean and clean code is much better than bulky code with legacy
+ functionality. Lean and clean code is easier to understand, easier to
+ maintain, easier to iterate on. Backwards compatibility leads to bloat,
+ bugs, and technical debt.
+ ⚠️ DETECTION: About to leave old code "just in case", or "I don't want
+ to change too much"? → STOP. Remove it. Keep the codebase lean. Show the
+ code you cleaned up.
+
+8. ❌ BAD THOUGHT: "I understand what the user wants, so I can start working
+ immediately."
+ ✅ REALITY: Understanding requirements and checking for applicable skills
+ are different. ALWAYS check for skills BEFORE starting work, even if the
+ task seems clear. Skills contain proven approaches that prevent rework.
+ ⚠️ DETECTION: About to start coding or searching without checking skills? →
+ STOP. Run the MANDATORY FIRST RESPONSE PROTOCOL first.
+
+9. ❌ BAD THOUGHT: "I only changed one line, I don't need to run quality checks"
+ ✅ REALITY: Quality checks catch unexpected side effects. One-line changes
+ break builds.
+ ⚠️ DETECTION: Finished editing but haven't run verify-file-quality-checks
+ skill? → STOP. Run it now. Show the output.
+
\ No newline at end of file
diff --git a/CalendarServer.csproj b/CalendarServer.csproj
deleted file mode 100644
index 2447542..0000000
--- a/CalendarServer.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
\ No newline at end of file
diff --git a/Program.cs b/Program.cs
deleted file mode 100644
index 10cbdaa..0000000
--- a/Program.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.FileProviders;
-
-var builder = WebApplication.CreateBuilder(args);
-
-var app = builder.Build();
-
-// Configure static files to serve from current directory
-app.UseStaticFiles(new StaticFileOptions
-{
- FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot")),
- RequestPath = ""
-});
-
-// Fallback to index.html for SPA routing
-app.MapFallbackToFile("index.html");
-
-app.Run("http://localhost:8000");
\ No newline at end of file
diff --git a/analyze-css.js b/analyze-css.js
index f4d230e..1ffd7aa 100644
--- a/analyze-css.js
+++ b/analyze-css.js
@@ -19,11 +19,11 @@ console.log('📊 Running PurgeCSS analysis...');
async function runPurgeCSS() {
const purgeCSSResults = await new PurgeCSS().purge({
content: [
- './src/**/*.ts',
- './wwwroot/**/*.html'
+ './src/v2/**/*.ts',
+ './wwwroot/v2.html'
],
css: [
- './wwwroot/css/*.css'
+ './wwwroot/css/v2/*.css'
],
rejected: true,
rejectedCss: true,
@@ -110,13 +110,10 @@ async function runPurgeCSS() {
console.log('\n📊 Running CSS Stats analysis...');
function runCSSStats() {
const cssFiles = [
- './wwwroot/css/calendar-base-css.css',
- './wwwroot/css/calendar-components-css.css',
- './wwwroot/css/calendar-events-css.css',
- './wwwroot/css/calendar-layout-css.css',
- './wwwroot/css/calendar-month-css.css',
- './wwwroot/css/calendar-popup-css.css',
- './wwwroot/css/calendar-sliding-animation.css'
+ './wwwroot/css/v2/calendar-v2.css',
+ './wwwroot/css/v2/calendar-v2-base.css',
+ './wwwroot/css/v2/calendar-v2-layout.css',
+ './wwwroot/css/v2/calendar-v2-events.css'
];
const stats = {};
diff --git a/build.js b/build.js
index 573846d..df1d600 100644
--- a/build.js
+++ b/build.js
@@ -32,9 +32,9 @@ async function renameFiles(dir) {
// Build with esbuild
async function build() {
try {
-
+ // Calendar standalone bundle (no DI)
await esbuild.build({
- entryPoints: ['src/index.ts'],
+ entryPoints: ['src/entry.ts'],
bundle: true,
outfile: 'wwwroot/js/calendar.js',
format: 'esm',
@@ -42,10 +42,26 @@ async function build() {
target: 'es2020',
minify: false,
keepNames: true,
+ platform: 'browser'
+ });
+
+ console.log('Calendar bundle created: wwwroot/js/calendar.js');
+
+ // Demo bundle (with DI transformer for autowiring)
+ await esbuild.build({
+ entryPoints: ['src/demo/index.ts'],
+ bundle: true,
+ outfile: 'wwwroot/js/demo.js',
+ format: 'esm',
+ sourcemap: 'inline',
+ target: 'es2020',
+ minify: false,
+ keepNames: true,
platform: 'browser',
plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true, performanceLogging: true })]
});
+ console.log('Demo bundle created: wwwroot/js/demo.js');
} catch (error) {
console.error('Build failed:', error);
diff --git a/docs/V2-ARCHITECTURE.md b/docs/V2-ARCHITECTURE.md
new file mode 100644
index 0000000..732a558
--- /dev/null
+++ b/docs/V2-ARCHITECTURE.md
@@ -0,0 +1,408 @@
+# Calendar V2 Architecture
+
+## Oversigt
+
+Calendar V2 er bygget med en event-driven arkitektur og et fleksibelt grouping-system der tillader forskellige kalendervisninger (dag, uge, ressource, team).
+
+---
+
+## Event System
+
+### CoreEvents Katalog
+
+Alle events er defineret i `src/v2/constants/CoreEvents.ts`.
+
+#### Drag-Drop Events
+
+| Event | Payload | Emitter | Subscribers |
+|-------|---------|---------|-------------|
+| `event:drag-start` | `IDragStartPayload` | DragDropManager | EdgeScrollManager |
+| `event:drag-move` | `IDragMovePayload` | DragDropManager | EventRenderer |
+| `event:drag-end` | `IDragEndPayload` | DragDropManager | EdgeScrollManager |
+| `event:drag-cancel` | `IDragCancelPayload` | DragDropManager | EdgeScrollManager |
+| `event:drag-column-change` | `IDragColumnChangePayload` | DragDropManager | EventRenderer |
+
+#### Resize Events
+
+| Event | Payload | Emitter | Subscribers |
+|-------|---------|---------|-------------|
+| `event:resize-start` | `IResizeStartPayload` | ResizeManager | - |
+| `event:resize-end` | `IResizeEndPayload` | ResizeManager | - |
+
+#### Edge Scroll Events
+
+| Event | Payload | Emitter | Subscribers |
+|-------|---------|---------|-------------|
+| `edge-scroll:tick` | `{ scrollDelta: number }` | EdgeScrollManager | DragDropManager |
+| `edge-scroll:started` | `{}` | EdgeScrollManager | - |
+| `edge-scroll:stopped` | `{}` | EdgeScrollManager | - |
+
+#### Lifecycle Events
+
+| Event | Purpose |
+|-------|---------|
+| `core:initialized` | DI container klar |
+| `core:ready` | Kalender fuldt initialiseret |
+| `core:destroyed` | Cleanup færdig |
+
+#### Data Events
+
+| Event | Purpose |
+|-------|---------|
+| `data:loading` | Data fetch startet |
+| `data:loaded` | Data fetch færdig |
+| `data:error` | Data fetch fejlet |
+| `entity:saved` | Entity gemt i IndexedDB |
+| `entity:deleted` | Entity slettet fra IndexedDB |
+
+#### View Events
+
+| Event | Purpose |
+|-------|---------|
+| `view:changed` | View/grouping ændret |
+| `view:rendered` | View rendering færdig |
+| `events:rendered` | Events renderet til DOM |
+| `grid:rendered` | Time grid renderet |
+
+---
+
+## Event Flows
+
+### Drag-Drop Flow
+
+```
+pointerdown på event
+ └── DragDropManager.handlePointerDown()
+ └── Gem mouseDownPosition, capture pointer
+
+pointermove (>5px)
+ └── DragDropManager.initializeDrag()
+ ├── Opret ghost clone (opacity 0.3)
+ ├── Marker original med .dragging
+ ├── EMIT: event:drag-start
+ │ └── EdgeScrollManager starter scrollTick loop
+ └── Start animateDrag() RAF loop
+
+pointermove (under drag)
+ ├── DragDropManager.updateDragTarget()
+ │ ├── Detect kolonneændring
+ │ │ └── EMIT: event:drag-column-change
+ │ │ └── EventRenderer flytter element til ny kolonne
+ │ └── Beregn targetY for smooth interpolation
+ │
+ └── DragDropManager.animateDrag() (RAF)
+ ├── Interpoler currentY mod targetY (factor 0.3)
+ ├── Opdater element.style.top
+ └── EMIT: event:drag-move
+ └── EventRenderer.updateDragTimestamp()
+ └── Beregn snapped tid og opdater visning
+
+EdgeScrollManager.scrollTick() (parallel RAF)
+ ├── Beregn velocity baseret på museafstand til kanter
+ │ - Inner zone (0-50px): 640 px/sek
+ │ - Outer zone (50-100px): 140 px/sek
+ ├── Scroll viewport: scrollTop += scrollDelta
+ └── EMIT: edge-scroll:tick
+ └── DragDropManager kompenserer element position
+
+pointerup
+ └── DragDropManager.handlePointerUp()
+ ├── Snap currentY til grid (15-min intervaller)
+ ├── Fjern ghost element
+ ├── EMIT: event:drag-end
+ │ └── EdgeScrollManager stopper scroll
+ └── Persist ændringer til IndexedDB
+```
+
+---
+
+## Grouping System
+
+### ViewConfig
+
+```typescript
+interface ViewConfig {
+ templateId: string; // 'day' | 'simple' | 'resource' | 'team'
+ groupings: GroupingConfig[];
+}
+
+interface GroupingConfig {
+ type: string; // 'date' | 'resource' | 'team'
+ values: string[]; // IDs der skal vises
+}
+```
+
+### Sådan bestemmer groupings strukturen
+
+1. **Kolonneantal**: Produkt af alle grouping dimensioner
+ - Eksempel: `5 datoer × 2 ressourcer = 10 kolonner`
+
+2. **Header layout**: CSS grid bruger `data-levels` til at stakke headers
+ - `data-levels="date"` → 1 header række
+ - `data-levels="resource date"` → 2 header rækker
+ - `data-levels="team resource date"` → 3 header rækker
+
+3. **Renderer selektion**: Grouping types matches til tilgængelige renderers
+
+### Konfigurationseksempler
+
+#### Simple Date View (3 dage, ingen ressourcer)
+
+```typescript
+{
+ templateId: 'simple',
+ groupings: [
+ { type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
+ ]
+}
+```
+
+**Resultat:** 3 kolonner, 1 header række
+
+```
+┌─── Date1 ───┬─── Date2 ───┬─── Date3 ───┐
+│ │ │ │
+```
+
+#### Resource View (2 ressourcer, 3 dage)
+
+```typescript
+{
+ templateId: 'resource',
+ groupings: [
+ { type: 'resource', values: ['EMP001', 'EMP002'] },
+ { type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
+ ]
+}
+```
+
+**Resultat:** 6 kolonner, 2 header rækker
+
+```
+┌───── Resource1 (span 3) ─────┬───── Resource2 (span 3) ─────┐
+├─── D1 ───┬─── D2 ───┬─── D3 ─┼─── D1 ───┬─── D2 ───┬─── D3 ─┤
+│ │ │ │ │ │ │
+```
+
+#### Team View (2 teams, 2 ressourcer, 3 dage)
+
+```typescript
+{
+ templateId: 'team',
+ groupings: [
+ { type: 'team', values: ['team1', 'team2'] },
+ { type: 'resource', values: ['res1', 'res2'] },
+ { type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
+ ]
+}
+```
+
+**Resultat:** 12 kolonner, 3 header rækker
+
+```
+┌─────────── Team1 (span 6) ───────────┬─────────── Team2 (span 6) ───────────┐
+├───── Res1 (span 3) ──┬── Res2 (3) ───┼───── Res1 (span 3) ──┬── Res2 (3) ───┤
+├── D1 ─┬── D2 ─┬── D3 ┼── D1 ─┬── D2 ─┼── D1 ─┬── D2 ─┬── D3 ┼── D1 ─┬── D2 ─┤
+│ │ │ │ │ │ │ │ │ │ │
+```
+
+---
+
+## Header Rendering
+
+### data-levels Attribut
+
+`swp-calendar-header` modtager `data-levels` som bestemmer header row layout:
+
+```typescript
+// I CalendarOrchestrator.render()
+const levels = viewConfig.groupings.map(g => g.type).join(' ');
+headerContainer.dataset.levels = levels; // "resource date" eller "team resource date"
+```
+
+### CSS Grid Styling
+
+```css
+swp-calendar-header[data-levels="date"] > swp-day-header {
+ grid-row: 1;
+}
+
+swp-calendar-header[data-levels="resource date"] {
+ > swp-resource-header { grid-row: 1; }
+ > swp-day-header { grid-row: 2; }
+}
+
+swp-calendar-header[data-levels="team resource date"] {
+ > swp-team-header { grid-row: 1; }
+ > swp-resource-header { grid-row: 2; }
+ > swp-day-header { grid-row: 3; }
+}
+```
+
+### Rendering Pipeline
+
+1. Renderers eksekveres i den rækkefølge de står i `viewConfig.groupings`
+2. Hver renderer APPENDER sine headers til `headerContainer`
+3. CSS bruger `data-levels` + element type til at positionere i korrekt række
+
+---
+
+## Renderers
+
+### DateRenderer
+
+```typescript
+class DateRenderer implements IRenderer {
+ readonly type = 'date';
+
+ render(context: IRenderContext): void {
+ // For HVER ressource (eller én gang hvis ingen):
+ // For HVER dato i filter['date']:
+ // Opret swp-day-header + swp-day-column
+ // Sæt dataset.date & dataset.resourceId
+ }
+}
+```
+
+### ResourceRenderer
+
+```typescript
+class ResourceRenderer implements IRenderer {
+ readonly type = 'resource';
+
+ async render(context: IRenderContext): Promise {
+ // Load IResource[] fra ResourceService
+ // For HVER ressource:
+ // Opret swp-resource-header
+ // Sæt grid-column: span ${dateCount}
+ }
+}
+```
+
+### TeamRenderer
+
+```typescript
+class TeamRenderer implements IRenderer {
+ readonly type = 'team';
+
+ render(context: IRenderContext): void {
+ // For HVERT team:
+ // Tæl ressourcer der tilhører team
+ // Opret swp-team-header
+ // Sæt grid-column: span ${colspan}
+ }
+}
+```
+
+---
+
+## Grid Konfiguration
+
+### IGridConfig
+
+```typescript
+interface IGridConfig {
+ hourHeight: number; // pixels per time (fx 60)
+ dayStartHour: number; // fx 6 (06:00)
+ dayEndHour: number; // fx 18 (18:00)
+ snapInterval: number; // minutter (fx 15)
+}
+```
+
+### Position Beregning
+
+```typescript
+function calculateEventPosition(start: Date, end: Date, config: IGridConfig): EventPosition {
+ const startMinutes = start.getHours() * 60 + start.getMinutes();
+ const endMinutes = end.getHours() * 60 + end.getMinutes();
+
+ const dayStartMinutes = config.dayStartHour * 60;
+ const minuteHeight = config.hourHeight / 60;
+
+ return {
+ top: (startMinutes - dayStartMinutes) * minuteHeight,
+ height: (endMinutes - startMinutes) * minuteHeight
+ };
+}
+```
+
+**Eksempel:** Event 09:00-10:00 med dayStartHour=6, hourHeight=60
+- startMinutes = 540, dayStartMinutes = 360
+- top = (540 - 360) × 1 = **180px**
+- height = (600 - 540) × 1 = **60px**
+
+---
+
+## Z-Index Stack
+
+```
+z-index: 10 ← Events (interaktive, draggable)
+z-index: 5 ← Unavailable zones (visuel, pointer-events: none)
+z-index: 2 ← Time linjer (baggrund)
+z-index: 1 ← Kvarter linjer (baggrund)
+z-index: 0 ← Grid baggrund
+```
+
+---
+
+## Komponenter
+
+| Komponent | Ansvar | Fil |
+|-----------|--------|-----|
+| **CalendarOrchestrator** | Orkestrerer renderer pipeline | `core/CalendarOrchestrator.ts` |
+| **DateRenderer** | Opretter dag-headers og kolonner | `features/date/DateRenderer.ts` |
+| **ResourceRenderer** | Opretter ressource-headers | `features/resource/ResourceRenderer.ts` |
+| **TeamRenderer** | Opretter team-headers | `features/team/TeamRenderer.ts` |
+| **EventRenderer** | Renderer events med positioner | `features/event/EventRenderer.ts` |
+| **ScheduleRenderer** | Renderer unavailable zoner | `features/schedule/ScheduleRenderer.ts` |
+| **DragDropManager** | Håndterer drag-drop | `managers/DragDropManager.ts` |
+| **ResizeManager** | Håndterer event resize | `managers/ResizeManager.ts` |
+| **EdgeScrollManager** | Auto-scroll ved kanter | `managers/EdgeScrollManager.ts` |
+| **ScrollManager** | Synkroniserer scroll | `core/ScrollManager.ts` |
+| **EventBus** | Central event dispatcher | `core/EventBus.ts` |
+| **DateService** | Dato formatering og beregning | `core/DateService.ts` |
+
+---
+
+## Filer
+
+```
+src/v2/
+├── constants/
+│ └── CoreEvents.ts # Alle event konstanter
+├── core/
+│ ├── CalendarOrchestrator.ts
+│ ├── DateService.ts
+│ ├── EventBus.ts
+│ ├── IGridConfig.ts
+│ ├── IGroupingRenderer.ts
+│ ├── RenderBuilder.ts
+│ ├── ScrollManager.ts
+│ └── ViewConfig.ts
+├── features/
+│ ├── date/
+│ │ └── DateRenderer.ts
+│ ├── event/
+│ │ └── EventRenderer.ts
+│ ├── resource/
+│ │ └── ResourceRenderer.ts
+│ ├── schedule/
+│ │ └── ScheduleRenderer.ts
+│ └── team/
+│ └── TeamRenderer.ts
+├── managers/
+│ ├── DragDropManager.ts
+│ └── EdgeScrollManager.ts
+├── storage/
+│ ├── BaseEntityService.ts
+│ ├── IndexedDBContext.ts
+│ └── events/
+│ ├── EventService.ts
+│ └── EventStore.ts
+├── types/
+│ ├── CalendarTypes.ts
+│ ├── DragTypes.ts
+│ └── ScheduleTypes.ts
+└── utils/
+ └── PositionUtils.ts
+```
diff --git a/docs/calendar-command-system-spec.md b/docs/calendar-command-system-spec.md
new file mode 100644
index 0000000..3157f72
--- /dev/null
+++ b/docs/calendar-command-system-spec.md
@@ -0,0 +1,98 @@
+# CalendarApp Event Specification
+
+## 1. Oversigt
+
+CalendarApp initialiseres med `CalendarApp.create(container)`.
+
+Kommunikation sker via DOM events:
+- **Command events**: Host → Calendar
+- **Status events**: Calendar → Host
+
+Settings hentes fra `SettingsService` (IndexedDB).
+
+---
+
+## 2. Command Events (Host → Calendar)
+
+| Event | Payload | Beskrivelse |
+|-------|---------|-------------|
+| `calendar:cmd:render` | `{ viewConfig }` | Render kalenderen med ViewConfig |
+
+```typescript
+interface IRenderCommandPayload {
+ viewConfig: ViewConfig;
+}
+```
+
+**Eksempel:**
+```typescript
+document.dispatchEvent(new CustomEvent('calendar:cmd:render', {
+ detail: {
+ viewConfig: {
+ templateId: 'team',
+ groupings: [
+ { type: 'team', values: ['team1', 'team2'] },
+ { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId', belongsTo: 'team.resourceIds' },
+ { type: 'date', values: ['2025-12-08', '2025-12-09'], idProperty: 'date', derivedFrom: 'start' }
+ ]
+ }
+ }
+}));
+```
+
+---
+
+## 3. Status Events (Calendar → Host)
+
+| Event | Payload | Beskrivelse |
+|-------|---------|-------------|
+| `calendar:status:ready` | `{}` | Calendar initialiseret |
+| `calendar:status:rendered` | `{ templateId }` | Rendering færdig |
+| `calendar:status:error` | `{ message, code }` | Fejl opstået |
+
+```typescript
+interface IRenderedStatusPayload {
+ templateId: string;
+}
+
+interface IErrorStatusPayload {
+ message: string;
+ code: 'INVALID_PAYLOAD' | 'RENDER_FAILED';
+}
+```
+
+---
+
+## 4. CalendarApp
+
+### 4.1 Initialisering
+```typescript
+await CalendarApp.create(container);
+```
+
+### 4.2 Dependencies (via DI)
+- CalendarOrchestrator
+- SettingsService
+- TimeAxisRenderer
+- ScrollManager
+- DragDropManager
+- EdgeScrollManager
+- ResizeManager
+- HeaderDrawerManager
+- EventBus
+
+### 4.3 Ansvar
+- Subscribe på `calendar:cmd:render`
+- Emit `calendar:status:ready` ved init
+- Emit `calendar:status:rendered` efter render
+- Emit `calendar:status:error` ved fejl
+
+---
+
+## 5. Filer
+
+| Fil | Beskrivelse |
+|-----|-------------|
+| `src/v2/CalendarApp.ts` | Entry point |
+| `src/v2/types/CommandTypes.ts` | Payload interfaces |
+| `src/v2/constants/CommandEvents.ts` | Event konstanter |
diff --git a/docs/design-system.md b/docs/design-system.md
new file mode 100644
index 0000000..6acead2
--- /dev/null
+++ b/docs/design-system.md
@@ -0,0 +1,221 @@
+# SWP Design System - UI/UX Dokumentation
+
+## Oversigt
+
+Dette dokument beskriver det komponent-baserede design system udviklet til Salon OS SaaS platformen gennem POC-udvikling. Systemet består af **150+ custom HTML elementer** med `swp-` prefix.
+
+---
+
+## Design Principper
+
+- **Custom Elements**: Alle komponenter bruger semantiske `swp-*` tags
+- **CSS Variables**: Theming via `--color-*` variabler (light/dark mode)
+- **Responsive**: Mobile-first med grid breakpoints
+- **Konsistent spacing**: 4px base unit (4, 8, 12, 16, 20, 24px)
+
+---
+
+## Farvepalette (CSS Variables)
+
+```css
+--color-surface: #fff / #1e1e1e
+--color-background: #f5f5f5 / #121212
+--color-border: #e0e0e0 / #333
+--color-text: #333 / #e0e0e0
+--color-text-secondary: #666 / #999
+--color-teal: #00897b (primary)
+--color-blue: #1976d2
+--color-green: #43a047 (success)
+--color-amber: #f59e0b (warning)
+--color-red: #e53935 (error)
+--color-purple: #8b5cf6
+```
+
+---
+
+## Typografi
+
+```css
+--font-family: 'Poppins', sans-serif
+--font-mono: 'JetBrains Mono', monospace
+```
+
+| Størrelse | Brug |
+|-----------|------|
+| 22px | Stat values |
+| 16px | Page titles |
+| 14px | Body text |
+| 13px | Table cells, inputs |
+| 12px | Labels, hints |
+| 11px | Table headers (uppercase) |
+
+---
+
+## Komponent-katalog
+
+### 1. Layout & Container
+
+| Element | Beskrivelse |
+|---------|-------------|
+| `swp-page` | Hovedpage wrapper |
+| `swp-page-container` | Max-width container (1400px) |
+| `swp-card` | Kortkomponent med border og shadow |
+| `swp-card-header` | Kortheader med titel |
+| `swp-drawer` | Slide-in panel fra højre |
+| `swp-drawer-overlay` | Mørk overlay bag drawer |
+| `swp-two-columns` | To-kolonne layout |
+
+### 2. Navigation
+
+| Element | Beskrivelse |
+|---------|-------------|
+| `swp-topbar` | Sticky header bar |
+| `swp-topbar-left/right` | Flex containers i topbar |
+| `swp-page-title` | Sidetitel i topbar |
+| `swp-back-link` | Tilbage-navigation med ikon |
+| `swp-tabs` | Tab navigation |
+| `swp-tab` | Enkelt tab |
+
+### 3. Formularer
+
+| Element | Beskrivelse |
+|---------|-------------|
+| `swp-form-field` | Wrapper for label + input |
+| `swp-form-label` | Form label |
+| `swp-form-input` | Input wrapper |
+| `swp-form-row` | Horisontal gruppe af felter |
+| `swp-form-hint` | Hjælpetekst under felt |
+| `swp-toggle-slider` | On/off toggle |
+| `swp-edit-section` | Redigerbar sektion |
+| `swp-edit-row` | Label + værdi row |
+
+### 4. Tabeller
+
+| Element | Beskrivelse |
+|---------|-------------|
+| `swp-table` | Hovedtabel container |
+| `swp-table-header` | Header row (grid) |
+| `swp-table-body` | Body container |
+| `swp-table-row` | Data row (grid) |
+| `swp-th` | Header cell |
+| `swp-td` | Data cell |
+| `swp-table-footer` | Footer med pagination |
+| `swp-row-arrow` | Klik-indikator pil |
+
+### 5. Statistik
+
+| Element | Beskrivelse |
+|---------|-------------|
+| `swp-stats-bar` | Grid af stat cards |
+| `swp-stat-card` | Enkelt statistik kort |
+| `swp-stat-value` | Stor tal (mono font) |
+| `swp-stat-label` | Beskrivelse under tal |
+| `swp-stat-change` | Ændring indikator (+/-) |
+
+### 6. Søgning & Filtrering
+
+| Element | Beskrivelse |
+|---------|-------------|
+| `swp-filter-bar` | Filterpanel |
+| `swp-search-input` | Søgefelt med ikon |
+| `swp-filter-group` | Label + select/input |
+| `swp-filter-label` | Filter label |
+
+### 7. Badges & Status
+
+| Element | Beskrivelse | Klasser |
+|---------|-------------|---------|
+| `swp-status-badge` | Status indikator | `.paid`, `.pending`, `.credited` |
+| `swp-payment-badge` | Betalingsmetode | `.card`, `.cash`, `.mobilepay` |
+| `swp-tag` | Inline tag | `.vip`, `.new` |
+
+### 8. Buttons
+
+| Element | Beskrivelse | Klasser |
+|---------|-------------|---------|
+| `swp-btn` | Standard button | `.primary`, `.secondary`, `.danger` |
+
+### 9. Charts (swp-charting)
+
+| Element | Beskrivelse |
+|---------|-------------|
+| `swp-chart-card` | Chart wrapper kort |
+| `swp-chart-header` | Titel + hint |
+| `swp-chart-container` | Canvas container |
+
+### 10. Specialiserede Komponenter
+
+#### Kunde
+- `swp-customer-avatar` - Rund avatar
+- `swp-customer-cell` - Navn + telefon
+- `swp-customer-header` - Profil header
+
+#### Medarbejder
+- `swp-employee-avatar` - Medarbejder billede
+- `swp-employee-name` - Navn display
+
+#### Faktura
+- `swp-invoice-cell` - Fakturanummer
+- `swp-datetime-cell` - Dato + tid
+- `swp-amount-cell` - Beløb (højre-justeret)
+
+#### Produkt
+- `swp-variants-table` - Variant liste
+- `swp-stock-display` - Lagerstatus
+- `swp-margin-display` - Avance visning
+
+#### Booking/AI
+- `swp-gap-card` - Ledigt hul kort
+- `swp-suggestion-item` - AI forslag
+- `swp-optimization-score` - Score cirkel
+
+---
+
+## Pagination
+
+```html
+
+
+ 1
+ 2
+ ...
+
+
+```
+
+---
+
+## Ikoner
+
+Bruger **Phosphor Icons** via CDN:
+```html
+
+
+
+
+```
+
+---
+
+## POC Filer (Reference)
+
+| Fil | Domæne |
+|-----|--------|
+| `poc-salg.html` | Salgsoversigt, fakturaer |
+| `poc-customer-list.html` | Kundeliste |
+| `poc-customer-detail.html` | Kundeprofil |
+| `poc-employee.html` | Medarbejderprofil |
+| `poc-produkt.html` | Produktdetaljer |
+| `poc-gavekort.html` | Fordelskort |
+| `poc-kasseafstemninger.html` | Kasseafstemning |
+| `poc-rapport.html` | Rapporter |
+| `poc-indstillinger.html` | Indstillinger |
+
+---
+
+## Næste Skridt: .NET Core Implementation
+
+Når design systemet er dokumenteret, er næste fase at implementere:
+1. Backend API endpoints
+2. Database modeller
+3. Razor/Blazor komponenter baseret på swp-* elementer
diff --git a/docs/filter-template-spec.md b/docs/filter-template-spec.md
new file mode 100644
index 0000000..d709bd7
--- /dev/null
+++ b/docs/filter-template-spec.md
@@ -0,0 +1,342 @@
+# FilterTemplate & Grouping System Specification
+
+> **Version:** 1.0
+> **Dato:** 2025-12-15
+> **Status:** Godkendt
+
+## Formål
+
+Dette dokument specificerer hvordan kalenderen matcher events til kolonner i alle view-typer (Simple, Dag, Resource, Team, Department). Formålet er at sikre konsistent opførsel og undgå fremtidige hacks.
+
+---
+
+## Kerneprincipper
+
+### 1. Én sandhedskilde for key-format
+
+**FilterTemplate** er den ENESTE kilde til key-format for event-kolonne matching.
+
+```
+KORREKT: filterTemplate.buildKeyFromColumn(column)
+KORREKT: filterTemplate.buildKeyFromEvent(event)
+FORKERT: column.dataset.columnKey (bruger DateService-format)
+FORKERT: Manuel key-konstruktion med string concatenation
+```
+
+### 2. Fields-rækkefølge bestemmer key-format
+
+Keys bygges fra `fields` array i samme rækkefølge som defineret i ViewConfig groupings.
+
+```typescript
+// ViewConfig groupings: [resource, date]
+// Resultat: "EMP001:2025-12-09"
+
+// ViewConfig groupings: [date, resource]
+// Resultat: "2025-12-09:EMP001"
+```
+
+### 3. Kolonner og events bruger SAMME template
+
+```typescript
+// Opret template fra ViewConfig
+const filterTemplate = new FilterTemplate(dateService);
+for (const grouping of viewConfig.groupings) {
+ if (grouping.idProperty) {
+ filterTemplate.addField(grouping.idProperty, grouping.derivedFrom);
+ }
+}
+
+// Brug til BÅDE kolonner og events
+const columnKey = filterTemplate.buildKeyFromColumn(column);
+const eventKey = filterTemplate.buildKeyFromEvent(event);
+const matches = columnKey === eventKey;
+```
+
+---
+
+## API Kontrakt
+
+### FilterTemplate
+
+```typescript
+class FilterTemplate {
+ constructor(dateService: DateService, entityResolver?: IEntityResolver)
+
+ // Tilføj felt til template
+ addField(idProperty: string, derivedFrom?: string): this
+
+ // Byg key fra kolonne (læser fra dataset)
+ buildKeyFromColumn(column: HTMLElement): string
+
+ // Byg key fra event (læser fra event properties)
+ buildKeyFromEvent(event: ICalendarEvent): string
+
+ // Convenience: matcher event mod kolonne
+ matches(event: ICalendarEvent, column: HTMLElement): boolean
+}
+```
+
+### Field Definition
+
+| Parameter | Type | Beskrivelse |
+|-----------|------|-------------|
+| `idProperty` | `string` | Property-navn på event ELLER dot-notation |
+| `derivedFrom` | `string?` | Kilde-property hvis værdi skal udledes |
+
+### Dot-Notation
+
+For hierarkiske relationer bruges dot-notation:
+
+```typescript
+idProperty: 'resource.teamId'
+// Betyder: event.resourceId → opslag i resource → teamId
+```
+
+**Convention:** `{entityType}.{property}` → foreignKey er `{entityType}Id`
+
+---
+
+## Kolonne Dataset Krav
+
+Kolonner (`swp-day-column`) SKAL have dataset-attributter for alle felter i template:
+
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+### Dataset Key Mapping
+
+| idProperty | Dataset Key | Eksempel |
+|------------|-------------|----------|
+| `date` | `data-date` | `"2025-12-09"` |
+| `resourceId` | `data-resource-id` | `"EMP001"` |
+| `resource.teamId` | `data-team-id` | `"team-1"` |
+
+**Regel:** Dot-notation bruger sidste segment som dataset-key.
+
+---
+
+## Event Property Krav
+
+Events SKAL have properties der matcher template fields:
+
+```typescript
+interface ICalendarEvent {
+ id: string;
+ start: Date; // derivedFrom: 'start' → date key
+ end: Date;
+ resourceId?: string; // Direkte match
+ // ... andre properties
+}
+```
+
+### Derived Values
+
+| idProperty | derivedFrom | Transformation |
+|------------|-------------|----------------|
+| `date` | `start` | `Date → "YYYY-MM-DD"` |
+
+---
+
+## ViewConfig Groupings
+
+### Struktur
+
+```typescript
+interface GroupingConfig {
+ type: string; // 'date', 'resource', 'team', 'department'
+ values: string[]; // Synlige værdier
+ idProperty?: string; // Felt til key-matching
+ derivedFrom?: string; // Kilde hvis udledt
+ belongsTo?: string; // Parent-child relation
+}
+```
+
+### Eksempler
+
+```typescript
+// Simple view
+groupings: [
+ { type: 'date', values: ['2025-12-09', ...], idProperty: 'date', derivedFrom: 'start' }
+]
+
+// Resource view
+groupings: [
+ { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' },
+ { type: 'date', values: ['2025-12-09', ...], idProperty: 'date', derivedFrom: 'start' }
+]
+
+// Team view
+groupings: [
+ { type: 'team', values: ['team-1', 'team-2'], idProperty: 'resource.teamId' },
+ { type: 'resource', values: ['EMP001', ...], idProperty: 'resourceId', belongsTo: 'team.resourceIds' },
+ { type: 'date', values: ['2025-12-09', ...], idProperty: 'date', derivedFrom: 'start' }
+]
+```
+
+---
+
+## BelongsTo Resolution
+
+### Formål
+
+`belongsTo` definerer parent-child relationer for nested groupings.
+
+### Syntax
+
+```
+belongsTo: '{parentEntityType}.{childArrayProperty}'
+```
+
+### Eksempel
+
+```typescript
+// Team har resourceIds array
+{ type: 'resource', belongsTo: 'team.resourceIds' }
+
+// Resolver:
+// 1. Hent team entities fra filter['team']
+// 2. For hver team, læs team.resourceIds
+// 3. Byg map: { 'team-1': ['EMP001', 'EMP002'], 'team-2': ['EMP003'] }
+```
+
+### Implementering
+
+```typescript
+// CalendarOrchestrator.resolveBelongsTo()
+const [entityType, property] = belongsTo.split('.');
+const service = entityServices.find(s => s.entityType === entityType);
+const entities = await service.getAll();
+// Byg parent-child map
+```
+
+---
+
+## HeaderDrawerRenderer Regler
+
+### Key Matching for AllDay Events
+
+```typescript
+// KORREKT: Brug FilterTemplate
+private getVisibleColumnKeysFromDOM(): string[] {
+ const columns = document.querySelectorAll('swp-day-column');
+ return Array.from(columns).map(col =>
+ this.filterTemplate.buildKeyFromColumn(col as HTMLElement)
+ );
+}
+
+// FORKERT: Læs dataset.columnKey direkte
+// (bruger DateService-format som ikke matcher FilterTemplate)
+```
+
+### Layout Beregning
+
+1. Hent synlige columnKeys via `getVisibleColumnKeysFromDOM()`
+2. For hver event, byg key via `filterTemplate.buildKeyFromEvent(event)`
+3. Find kolonne-index via `columnKeys.indexOf(eventKey)`
+4. Beregn row via track-algoritme
+
+---
+
+## Anti-Patterns (UNDGÅ)
+
+### 1. Manuel Key Konstruktion
+
+```typescript
+// FORKERT
+const key = `${resourceId}:${dateStr}`;
+
+// KORREKT
+const key = filterTemplate.buildKeyFromEvent(event);
+```
+
+### 2. Direkte Dataset Læsning for Matching
+
+```typescript
+// FORKERT
+const columnKey = column.dataset.columnKey;
+
+// KORREKT
+const columnKey = filterTemplate.buildKeyFromColumn(column);
+```
+
+### 3. Hardcoded Field Order
+
+```typescript
+// FORKERT
+const key = [event.resourceId, dateStr].join(':');
+
+// KORREKT
+// Lad FilterTemplate håndtere rækkefølge fra ViewConfig
+```
+
+### 4. Separate Key-Formater
+
+```typescript
+// FORKERT: DateService til kolonner, FilterTemplate til events
+DateService.buildColumnKey(segments) // "2025-12-09:EMP001"
+FilterTemplate.buildKeyFromEvent(e) // "EMP001:2025-12-09"
+
+// KORREKT: FilterTemplate til begge
+FilterTemplate.buildKeyFromColumn(col)
+FilterTemplate.buildKeyFromEvent(event)
+```
+
+---
+
+## Testcases
+
+### TC1: Simple View Matching
+
+```
+Given: ViewConfig med [date] grouping
+When: Event har start=2025-12-09
+Then: Event matcher kolonne med data-date="2025-12-09"
+```
+
+### TC2: Resource View Matching
+
+```
+Given: ViewConfig med [resource, date] groupings
+When: Event har resourceId=EMP001, start=2025-12-09
+Then: Event matcher kolonne med data-resource-id="EMP001" OG data-date="2025-12-09"
+```
+
+### TC3: Team View Matching
+
+```
+Given: ViewConfig med [team, resource, date] groupings
+ Resource EMP001 tilhører team-1
+When: Event har resourceId=EMP001, start=2025-12-09
+Then: Event matcher kolonne med data-team-id="team-1" OG data-resource-id="EMP001" OG data-date="2025-12-09"
+```
+
+### TC4: Multi-Day Event
+
+```
+Given: Event spænder 2025-12-09 til 2025-12-11
+When: HeaderDrawerRenderer beregner layout
+Then: Event vises fra kolonne 09 til kolonne 11 (inclusive)
+```
+
+---
+
+## Ændringslog
+
+| Version | Dato | Ændring |
+|---------|------|---------|
+| 1.0 | 2025-12-15 | Initial specifikation |
diff --git a/docs/filter-template.md b/docs/filter-template.md
new file mode 100644
index 0000000..e7a2daa
--- /dev/null
+++ b/docs/filter-template.md
@@ -0,0 +1,180 @@
+# FilterTemplate System
+
+## Problem
+
+En kolonne har en unik nøgle baseret på view-konfigurationen (f.eks. team + resource + date).
+Events skal matches mod denne nøgle - men kun på de felter viewet definerer.
+
+## Løsning: FilterTemplate
+
+ViewConfig definerer hvilke felter (idProperties) der indgår i kolonnens nøgle.
+Samme template bruges til at bygge nøgle for både kolonne og event.
+
+**Princip:** Kolonnens nøgle-template bestemmer hvad der matches på.
+
+---
+
+## ViewConfig med idProperty
+
+ViewConfig er kilden til sandhed - den definerer grupper OG deres relations-id property.
+
+```typescript
+interface GroupingConfig {
+ type: string; // 'team', 'resource', 'date'
+ values: string[]; // ['EMP001', 'EMP002']
+ idProperty: string; // property-navn på event (eks. 'resourceId')
+ derivedFrom?: string; // for date: udledes fra 'start'
+}
+```
+
+### Eksempler
+
+**Team → Resource → Date view:**
+```typescript
+{
+ groupings: [
+ { type: 'team', values: ['team-a'], idProperty: 'teamId' },
+ { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' },
+ { type: 'date', values: ['2025-12-09', '2025-12-10'], idProperty: 'date', derivedFrom: 'start' }
+ ]
+}
+```
+
+**Simple date-only view:**
+```typescript
+{
+ groupings: [
+ { type: 'date', values: ['2025-12-09', '2025-12-10'], idProperty: 'date', derivedFrom: 'start' }
+ ]
+}
+```
+
+---
+
+## FilterTemplate Klasse
+
+```typescript
+class FilterTemplate {
+ private fields: Array<{
+ idProperty: string;
+ derivedFrom?: string;
+ }> = [];
+
+ addField(idProperty: string, derivedFrom?: string): this {
+ this.fields.push({ idProperty, derivedFrom });
+ return this;
+ }
+
+ buildKeyFromColumn(column: HTMLElement): string {
+ return this.fields
+ .map(f => column.dataset[f.idProperty] || '')
+ .join(':');
+ }
+
+ buildKeyFromEvent(event: ICalendarEvent, dateService: DateService): string {
+ return this.fields
+ .map(f => {
+ if (f.derivedFrom) {
+ return dateService.getDateKey((event as any)[f.derivedFrom]);
+ }
+ return (event as any)[f.idProperty] || '';
+ })
+ .join(':');
+ }
+}
+```
+
+---
+
+## Flow
+
+```
+Orchestrator
+ │
+ ├── Læs ViewConfig.groupings
+ │
+ ├── Byg FilterTemplate fra groupings:
+ │ for (grouping of viewConfig.groupings) {
+ │ template.addField(grouping.idProperty, grouping.derivedFrom);
+ │ }
+ │
+ ├── Kør group-renderers (bygger headers + kolonner)
+ │ └── DateRenderer sætter column.dataset[idProperty] for ALLE grupperinger
+ │
+ └── EventRenderer.render(ctx, template)
+ │
+ └── for each column:
+ columnKey = template.buildKeyFromColumn(column)
+ columnEvents = events.filter(e =>
+ template.buildKeyFromEvent(e) === columnKey
+ )
+```
+
+---
+
+## Eksempler
+
+### 3-niveau view: Team → Resource → Date
+
+**ViewConfig:**
+```typescript
+groupings: [
+ { type: 'team', values: ['team-a'], idProperty: 'teamId' },
+ { type: 'resource', values: ['EMP001'], idProperty: 'resourceId' },
+ { type: 'date', values: ['2025-12-09'], idProperty: 'date', derivedFrom: 'start' }
+]
+```
+
+**Template:** `['teamId', 'resourceId', 'date']`
+
+**Kolonne-nøgle:** `"team-a:EMP001:2025-12-09"`
+**Event-nøgle:** `"team-a:EMP001:2025-12-09"`
+
+**Match!**
+
+---
+
+### 2-niveau view: Resource → Date
+
+**ViewConfig:**
+```typescript
+groupings: [
+ { type: 'resource', values: ['EMP001'], idProperty: 'resourceId' },
+ { type: 'date', values: ['2025-12-09'], idProperty: 'date', derivedFrom: 'start' }
+]
+```
+
+**Template:** `['resourceId', 'date']`
+
+**Kolonne-nøgle:** `"EMP001:2025-12-09"`
+**Event-nøgle:** `"EMP001:2025-12-09"` (teamId ignoreres - ikke i template)
+
+**Match!**
+
+---
+
+### 1-niveau view: Kun Date
+
+**ViewConfig:**
+```typescript
+groupings: [
+ { type: 'date', values: ['2025-12-09'], idProperty: 'date', derivedFrom: 'start' }
+]
+```
+
+**Template:** `['date']`
+
+**Kolonne-nøgle:** `"2025-12-09"`
+**Event-nøgle:** `"2025-12-09"` (alle andre felter ignoreres)
+
+**Match!** Samme event vises i alle views - kun de relevante felter indgår i matching.
+
+---
+
+## Kerneprincipper
+
+1. **ViewConfig definerer nøgle-template** - hvilke idProperties der indgår
+2. **Samme template til kolonne og event** - sikrer konsistent matching
+3. **Felter udenfor template ignoreres** - event med ekstra felter matcher stadig
+4. **idProperty** - eksplicit mapping mellem gruppering og event-felt
+5. **derivedFrom** - håndterer felter der udledes (f.eks. date fra start)
diff --git a/package-lock.json b/package-lock.json
index 1389069..6bc1f15 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,9 +10,11 @@
"dependencies": {
"@novadi/core": "^0.6.0",
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
+ "@sevenweirdpeople/swp-charting": "^0.2.2",
"dayjs": "^1.11.19",
"fuse.js": "^7.1.0",
- "json-diff-ts": "^4.8.2"
+ "json-diff-ts": "^4.8.2",
+ "ts-linq-light": "^1.0.0"
},
"devDependencies": {
"@fullhuman/postcss-purgecss": "^7.0.2",
@@ -31,10 +33,12 @@
"postcss-cli": "^11.0.1",
"postcss-nesting": "^13.0.2",
"purgecss": "^7.0.2",
+ "read-excel-file": "^6.0.1",
"rollup": "^4.52.5",
"tslib": "^2.8.1",
"typescript": "^5.0.0",
- "vitest": "^3.2.4"
+ "vitest": "^3.2.4",
+ "xlsx": "^0.18.5"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -1172,6 +1176,12 @@
"win32"
]
},
+ "node_modules/@sevenweirdpeople/swp-charting": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/@sevenweirdpeople/swp-charting/-/swp-charting-0.2.2.tgz",
+ "integrity": "sha512-q9p7TOSMAq6I0t6jGEWpmjR7l2H8q8G0TnXbIpDutCz5a2JEqMDFe0NGBGcCwze2rvvRnRvCz8P2zGMQlHmphw==",
+ "license": "MIT"
+ },
"node_modules/@types/chai": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
@@ -1340,6 +1350,16 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.11",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
+ "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1352,6 +1372,16 @@
"node": ">=0.4.0"
}
},
+ "node_modules/adler-32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+ "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1502,6 +1532,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -1610,6 +1647,20 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/cfb": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+ "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "crc-32": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -1773,6 +1824,16 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/codepage": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+ "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1826,6 +1887,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2302,6 +2383,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/duplexer2": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
+ "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "readable-stream": "^2.0.2"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -2573,6 +2664,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/frac": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+ "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -2850,6 +2951,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -3028,6 +3136,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3317,6 +3432,13 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -4281,6 +4403,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/pseudo-classes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/pseudo-classes/-/pseudo-classes-1.0.0.tgz",
@@ -4331,6 +4460,34 @@
"pify": "^2.3.0"
}
},
+ "node_modules/read-excel-file": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-6.0.1.tgz",
+ "integrity": "sha512-rH6huBFxsjZsUARCYh55O08cn1gqZH8bnLf0kI6y5K7+9yqBVzy8veO4gPV4VGKv4M9rdcRtXTDGZZNwPi1gDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.11",
+ "fflate": "^0.8.2",
+ "unzipper": "^0.12.3"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -4471,6 +4628,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -4589,6 +4753,19 @@
"specificity": "bin/specificity"
}
},
+ "node_modules/ssf": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+ "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "frac": "~1.1.2"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4603,6 +4780,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -4958,6 +5145,31 @@
"node": ">=20"
}
},
+ "node_modules/ts-linq-light": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.1.tgz",
+ "integrity": "sha512-Qk1TKZ8M/XYH6Vt+zUOtAyVOqezIMd3r7EDtgCPOnWgIs0Xdrj/miqUQAEoRl3LttbQQ/6gBMhM/84S/mTb/sg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-win32-x64-msvc": "^4.53.3"
+ }
+ },
+ "node_modules/ts-linq-light/node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
+ "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -5015,6 +5227,27 @@
"node": ">=18.12.0"
}
},
+ "node_modules/unzipper": {
+ "version": "0.12.3",
+ "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
+ "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bluebird": "~3.7.2",
+ "duplexer2": "~0.1.4",
+ "fs-extra": "^11.2.0",
+ "graceful-fs": "^4.2.2",
+ "node-int64": "^0.4.0"
+ }
+ },
+ "node_modules/unzipper/node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
@@ -5763,6 +5996,26 @@
"node": ">=8"
}
},
+ "node_modules/wmf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+ "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+ "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
@@ -5883,6 +6136,28 @@
}
}
},
+ "node_modules/xlsx": {
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+ "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "cfb": "~1.2.1",
+ "codepage": "~1.15.0",
+ "crc-32": "~1.2.1",
+ "ssf": "~0.11.2",
+ "wmf": "~1.0.1",
+ "word": "~0.3.0"
+ },
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
diff --git a/package.json b/package.json
index d2aadc1..8e2f69f 100644
--- a/package.json
+++ b/package.json
@@ -33,16 +33,20 @@
"postcss-cli": "^11.0.1",
"postcss-nesting": "^13.0.2",
"purgecss": "^7.0.2",
+ "read-excel-file": "^6.0.1",
"rollup": "^4.52.5",
"tslib": "^2.8.1",
"typescript": "^5.0.0",
- "vitest": "^3.2.4"
+ "vitest": "^3.2.4",
+ "xlsx": "^0.18.5"
},
"dependencies": {
"@novadi/core": "^0.6.0",
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
+ "@sevenweirdpeople/swp-charting": "^0.2.2",
"dayjs": "^1.11.19",
"fuse.js": "^7.1.0",
- "json-diff-ts": "^4.8.2"
+ "json-diff-ts": "^4.8.2",
+ "ts-linq-light": "^1.0.0"
}
}
diff --git a/packages/calendar/README.md b/packages/calendar/README.md
new file mode 100644
index 0000000..5fecd34
--- /dev/null
+++ b/packages/calendar/README.md
@@ -0,0 +1,620 @@
+# Calendar
+
+Professional TypeScript calendar component with offline-first architecture, drag-and-drop functionality, and real-time synchronization capabilities.
+
+## Features
+
+- **Multiple View Modes**: Date-based (day/week/month) and resource-based (people, rooms) views
+- **Drag & Drop**: Smooth event dragging with snap-to-grid, cross-column movement, and timed/all-day conversion
+- **Event Resizing**: Intuitive resize handles for adjusting event duration
+- **Offline-First**: IndexedDB storage with automatic background sync
+- **Event-Driven Architecture**: Decoupled components via centralized EventBus
+- **Dependency Injection**: Built on NovaDI for clean, testable architecture
+- **Extensions**: Modular extensions for teams, departments, bookings, customers, schedules, and audit logging
+
+## Installation
+
+```bash
+npm install calendar
+```
+
+## Quick Start (AI-Friendly Setup Guide)
+
+### Step 1: Create HTML Structure
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Step 2: Initialize Calendar
+
+```typescript
+import { Container } from '@novadi/core';
+import {
+ registerCoreServices,
+ CalendarApp,
+ IndexedDBContext,
+ SettingsService,
+ ViewConfigService,
+ EventService,
+ EventBus,
+ CalendarEvents
+} from 'calendar';
+
+async function init() {
+ // 1. Create DI container and register services
+ const container = new Container();
+ const builder = container.builder();
+ registerCoreServices(builder, {
+ dbConfig: { dbName: 'MyCalendarDB', dbVersion: 1 }
+ });
+ const app = builder.build();
+
+ // 2. Initialize IndexedDB
+ const dbContext = app.resolveType();
+ await dbContext.initialize();
+
+ // 3. Seed required settings (first time only)
+ const settingsService = app.resolveType();
+ const viewConfigService = app.resolveType();
+
+ await settingsService.save({
+ id: 'grid',
+ dayStartHour: 8,
+ dayEndHour: 17,
+ workStartHour: 9,
+ workEndHour: 16,
+ hourHeight: 64,
+ snapInterval: 15,
+ syncStatus: 'synced'
+ });
+
+ await settingsService.save({
+ id: 'workweek',
+ presets: {
+ standard: { id: 'standard', label: 'Standard', workDays: [1, 2, 3, 4, 5], periodDays: 7 }
+ },
+ defaultPreset: 'standard',
+ firstDayOfWeek: 1,
+ syncStatus: 'synced'
+ });
+
+ await viewConfigService.save({
+ id: 'simple',
+ groupings: [{ type: 'date', values: [], idProperty: 'date', derivedFrom: 'start' }],
+ syncStatus: 'synced'
+ });
+
+ // 4. Initialize CalendarApp
+ const calendarApp = app.resolveType();
+ const containerEl = document.querySelector('swp-calendar-container') as HTMLElement;
+ await calendarApp.init(containerEl);
+
+ // 5. Render a view
+ const eventBus = app.resolveType();
+ eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' });
+}
+
+init().catch(console.error);
+```
+
+### Step 3: Add Events
+
+```typescript
+const eventService = app.resolveType();
+
+await eventService.save({
+ id: crypto.randomUUID(),
+ title: 'Meeting',
+ start: new Date('2024-01-15T09:00:00'),
+ end: new Date('2024-01-15T10:00:00'),
+ type: 'meeting',
+ allDay: false,
+ syncStatus: 'synced'
+});
+```
+
+---
+
+## Architecture
+
+### Core Components
+
+| Component | Description |
+|-----------|-------------|
+| `CalendarApp` | Main application entry point |
+| `CalendarOrchestrator` | Coordinates rendering pipeline |
+| `EventBus` | Central event dispatcher for all inter-component communication |
+| `DateService` | Date calculations and formatting |
+| `IndexedDBContext` | Offline storage infrastructure |
+
+### Managers
+
+| Manager | Description |
+|---------|-------------|
+| `DragDropManager` | Event drag-drop with smooth animations and snap-to-grid |
+| `EdgeScrollManager` | Automatic scrolling at viewport edges during drag |
+| `ResizeManager` | Event resizing with visual feedback |
+| `ScrollManager` | Scroll behavior and position management |
+| `HeaderDrawerManager` | All-day events drawer toggle |
+| `EventPersistenceManager` | Saves drag/resize changes to storage |
+
+### Renderers
+
+| Renderer | Description |
+|----------|-------------|
+| `DateRenderer` | Renders date-based column groupings |
+| `ResourceRenderer` | Renders resource-based column groupings |
+| `EventRenderer` | Renders timed events in columns |
+| `ScheduleRenderer` | Renders working hours backgrounds |
+| `HeaderDrawerRenderer` | Renders all-day events in header |
+| `TimeAxisRenderer` | Renders time labels on the left axis |
+
+### Storage
+
+| Service | Description |
+|---------|-------------|
+| `EventService` / `EventStore` | Calendar event CRUD |
+| `ResourceService` / `ResourceStore` | Resource management |
+| `SettingsService` / `SettingsStore` | Tenant settings |
+| `ViewConfigService` / `ViewConfigStore` | View configurations |
+
+---
+
+## Events Reference
+
+### Lifecycle Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `core:initialized` | `CoreEvents.INITIALIZED` | - | Calendar core initialized |
+| `core:ready` | `CoreEvents.READY` | - | Calendar ready for interaction |
+| `core:destroyed` | `CoreEvents.DESTROYED` | - | Calendar destroyed |
+
+### View Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `view:changed` | `CoreEvents.VIEW_CHANGED` | `{ viewId: string }` | View type changed |
+| `view:rendered` | `CoreEvents.VIEW_RENDERED` | - | View finished rendering |
+
+### Navigation Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `nav:date-changed` | `CoreEvents.DATE_CHANGED` | `{ date: Date }` | Current date changed |
+| `nav:navigation-completed` | `CoreEvents.NAVIGATION_COMPLETED` | - | Navigation animation completed |
+
+### Data Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `data:loading` | `CoreEvents.DATA_LOADING` | - | Data loading started |
+| `data:loaded` | `CoreEvents.DATA_LOADED` | - | Data loading completed |
+| `data:error` | `CoreEvents.DATA_ERROR` | `{ error: Error }` | Data loading error |
+
+### Grid Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `grid:rendered` | `CoreEvents.GRID_RENDERED` | - | Grid finished rendering |
+| `grid:clicked` | `CoreEvents.GRID_CLICKED` | `{ time: Date, columnKey: string }` | Grid area clicked |
+
+### Event Management
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `event:created` | `CoreEvents.EVENT_CREATED` | `ICalendarEvent` | Event created |
+| `event:updated` | `CoreEvents.EVENT_UPDATED` | `IEventUpdatedPayload` | Event updated |
+| `event:deleted` | `CoreEvents.EVENT_DELETED` | `{ eventId: string }` | Event deleted |
+| `event:selected` | `CoreEvents.EVENT_SELECTED` | `{ eventId: string }` | Event selected |
+
+### Drag-Drop Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `event:drag-start` | `CoreEvents.EVENT_DRAG_START` | `IDragStartPayload` | Drag started |
+| `event:drag-move` | `CoreEvents.EVENT_DRAG_MOVE` | `IDragMovePayload` | Dragging (throttled) |
+| `event:drag-end` | `CoreEvents.EVENT_DRAG_END` | `IDragEndPayload` | Drag completed |
+| `event:drag-cancel` | `CoreEvents.EVENT_DRAG_CANCEL` | `IDragCancelPayload` | Drag cancelled |
+| `event:drag-column-change` | `CoreEvents.EVENT_DRAG_COLUMN_CHANGE` | `IDragColumnChangePayload` | Moved to different column |
+
+### Header Drag Events (Timed to All-Day Conversion)
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `event:drag-enter-header` | `CoreEvents.EVENT_DRAG_ENTER_HEADER` | `IDragEnterHeaderPayload` | Entered header area |
+| `event:drag-move-header` | `CoreEvents.EVENT_DRAG_MOVE_HEADER` | `IDragMoveHeaderPayload` | Moving in header area |
+| `event:drag-leave-header` | `CoreEvents.EVENT_DRAG_LEAVE_HEADER` | `IDragLeaveHeaderPayload` | Left header area |
+
+### Resize Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `event:resize-start` | `CoreEvents.EVENT_RESIZE_START` | `IResizeStartPayload` | Resize started |
+| `event:resize-end` | `CoreEvents.EVENT_RESIZE_END` | `IResizeEndPayload` | Resize completed |
+
+### Edge Scroll Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `edge-scroll:tick` | `CoreEvents.EDGE_SCROLL_TICK` | `{ deltaY: number }` | Scroll tick during edge scroll |
+| `edge-scroll:started` | `CoreEvents.EDGE_SCROLL_STARTED` | - | Edge scrolling started |
+| `edge-scroll:stopped` | `CoreEvents.EDGE_SCROLL_STOPPED` | - | Edge scrolling stopped |
+
+### Sync Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `sync:started` | `CoreEvents.SYNC_STARTED` | - | Background sync started |
+| `sync:completed` | `CoreEvents.SYNC_COMPLETED` | - | Background sync completed |
+| `sync:failed` | `CoreEvents.SYNC_FAILED` | `{ error: Error }` | Background sync failed |
+
+### Entity Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `entity:saved` | `CoreEvents.ENTITY_SAVED` | `IEntitySavedPayload` | Entity saved to storage |
+| `entity:deleted` | `CoreEvents.ENTITY_DELETED` | `IEntityDeletedPayload` | Entity deleted from storage |
+
+### Audit Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `audit:logged` | `CoreEvents.AUDIT_LOGGED` | `IAuditLoggedPayload` | Audit entry logged |
+
+### Rendering Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `events:rendered` | `CoreEvents.EVENTS_RENDERED` | - | Events finished rendering |
+
+### System Events
+
+| Event | Constant | Payload | Description |
+|-------|----------|---------|-------------|
+| `system:error` | `CoreEvents.ERROR` | `{ error: Error, context?: string }` | System error occurred |
+
+---
+
+## Command Events (Host to Calendar)
+
+Use these to control the calendar from your application:
+
+```typescript
+import { EventBus, CalendarEvents } from 'calendar';
+
+const eventBus = app.resolveType();
+
+// Navigate
+eventBus.emit(CalendarEvents.CMD_NAVIGATE_PREV);
+eventBus.emit(CalendarEvents.CMD_NAVIGATE_NEXT);
+
+// Render a view
+eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' });
+
+// Toggle header drawer
+eventBus.emit(CalendarEvents.CMD_DRAWER_TOGGLE);
+
+// Change workweek preset
+eventBus.emit(CalendarEvents.CMD_WORKWEEK_CHANGE, { presetId: 'standard' });
+
+// Update view grouping
+eventBus.emit(CalendarEvents.CMD_VIEW_UPDATE, { type: 'resource', values: ['r1', 'r2'] });
+```
+
+| Command | Constant | Payload | Description |
+|---------|----------|---------|-------------|
+| Navigate Previous | `CalendarEvents.CMD_NAVIGATE_PREV` | - | Go to previous period |
+| Navigate Next | `CalendarEvents.CMD_NAVIGATE_NEXT` | - | Go to next period |
+| Render View | `CalendarEvents.CMD_RENDER` | `{ viewId: string }` | Render specified view |
+| Toggle Drawer | `CalendarEvents.CMD_DRAWER_TOGGLE` | - | Toggle all-day drawer |
+| Change Workweek | `CalendarEvents.CMD_WORKWEEK_CHANGE` | `{ presetId: string }` | Change workweek preset |
+| Update View | `CalendarEvents.CMD_VIEW_UPDATE` | `{ type: string, values: string[] }` | Update grouping values |
+
+---
+
+## Types
+
+### Core Types
+
+```typescript
+// Event types
+type CalendarEventType = 'customer' | 'vacation' | 'break' | 'meeting' | 'blocked';
+
+interface ICalendarEvent {
+ id: string;
+ title: string;
+ description?: string;
+ start: Date;
+ end: Date;
+ type: CalendarEventType;
+ allDay: boolean;
+ bookingId?: string;
+ resourceId?: string;
+ customerId?: string;
+ recurringId?: string;
+ syncStatus: SyncStatus;
+ metadata?: Record;
+}
+
+// Resource types
+type ResourceType = 'person' | 'room' | 'equipment' | 'vehicle' | 'custom';
+
+interface IResource {
+ id: string;
+ name: string;
+ displayName: string;
+ type: ResourceType;
+ avatarUrl?: string;
+ color?: string;
+ isActive?: boolean;
+ defaultSchedule?: IWeekSchedule;
+ syncStatus: SyncStatus;
+}
+
+// Sync status
+type SyncStatus = 'synced' | 'pending' | 'error';
+```
+
+### Settings Types
+
+```typescript
+interface IGridSettings {
+ id: 'grid';
+ dayStartHour: number;
+ dayEndHour: number;
+ workStartHour: number;
+ workEndHour: number;
+ hourHeight: number;
+ snapInterval: number;
+}
+
+interface IWorkweekPreset {
+ id: string;
+ workDays: number[]; // ISO weekdays: 1=Monday, 7=Sunday
+ label: string;
+ periodDays: number; // Navigation step (1=day, 7=week)
+}
+
+interface IWeekSchedule {
+ [day: number]: ITimeSlot | null; // null = off that day
+}
+
+interface ITimeSlot {
+ start: string; // "HH:mm"
+ end: string; // "HH:mm"
+}
+```
+
+### Drag-Drop Payloads
+
+```typescript
+interface IDragStartPayload {
+ eventId: string;
+ element: HTMLElement;
+ ghostElement: HTMLElement;
+ startY: number;
+ mouseOffset: { x: number; y: number };
+ columnElement: HTMLElement;
+}
+
+interface IDragEndPayload {
+ swpEvent: SwpEvent;
+ sourceColumnKey: string;
+ target: 'grid' | 'header';
+}
+```
+
+---
+
+## Extensions
+
+Import extensions separately to keep bundle size minimal:
+
+```typescript
+// Teams extension
+import { registerTeams, TeamService, TeamStore, TeamRenderer } from 'calendar/teams';
+
+// Departments extension
+import { registerDepartments, DepartmentService, DepartmentStore } from 'calendar/departments';
+
+// Bookings extension
+import { registerBookings, BookingService, BookingStore } from 'calendar/bookings';
+
+// Customers extension
+import { registerCustomers, CustomerService, CustomerStore } from 'calendar/customers';
+
+// Schedules extension (working hours)
+import { registerSchedules, ResourceScheduleService, ScheduleOverrideService } from 'calendar/schedules';
+
+// Audit extension
+import { registerAudit, AuditService, AuditStore } from 'calendar/audit';
+
+// Register with container builder
+const builder = container.builder();
+registerCoreServices(builder);
+registerTeams(builder);
+registerSchedules(builder);
+// ... etc
+```
+
+---
+
+## Configuration
+
+### Calendar Options
+
+```typescript
+interface ICalendarOptions {
+ timeConfig?: ITimeFormatConfig;
+ gridConfig?: IGridConfig;
+ dbConfig?: IDBConfig;
+}
+
+// Time format configuration
+interface ITimeFormatConfig {
+ timezone: string; // e.g., 'Europe/Copenhagen'
+ use24HourFormat: boolean;
+ locale: string; // e.g., 'da-DK'
+ dateFormat: string;
+ showSeconds: boolean;
+}
+
+// Grid configuration
+interface IGridConfig {
+ hourHeight: number; // Pixels per hour (default: 64)
+ dayStartHour: number; // Grid start hour (default: 6)
+ dayEndHour: number; // Grid end hour (default: 18)
+ snapInterval: number; // Minutes to snap to (default: 15)
+ gridStartThresholdMinutes: number;
+}
+
+// Database configuration
+interface IDBConfig {
+ dbName: string; // IndexedDB database name
+ dbVersion: number; // Schema version
+}
+```
+
+### Default Configuration
+
+```typescript
+import {
+ defaultTimeFormatConfig,
+ defaultGridConfig,
+ defaultDBConfig
+} from 'calendar';
+
+// Defaults:
+// timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
+// use24HourFormat: true
+// locale: 'da-DK'
+// hourHeight: 64
+// dayStartHour: 6
+// dayEndHour: 18
+// snapInterval: 15
+```
+
+---
+
+## Utilities
+
+### Position Utilities
+
+```typescript
+import {
+ calculateEventPosition,
+ minutesToPixels,
+ pixelsToMinutes,
+ snapToGrid
+} from 'calendar';
+
+// Convert time to pixels
+const pixels = minutesToPixels(120, 64); // 120 mins at 64px/hour = 128px
+
+// Snap to 15-minute grid
+const snapped = snapToGrid(new Date(), 15);
+```
+
+### Event Layout Engine
+
+```typescript
+import { eventsOverlap, calculateColumnLayout } from 'calendar';
+
+// Check if two events overlap
+const overlap = eventsOverlap(event1, event2);
+
+// Calculate layout for overlapping events
+const layout = calculateColumnLayout(events);
+```
+
+---
+
+## Listening to Events
+
+```typescript
+import { EventBus, CoreEvents } from 'calendar';
+
+const eventBus = app.resolveType();
+
+// Subscribe to event updates
+eventBus.on(CoreEvents.EVENT_UPDATED, (e: Event) => {
+ const { eventId, sourceColumnKey, targetColumnKey } = (e as CustomEvent).detail;
+ console.log(`Event ${eventId} moved from ${sourceColumnKey} to ${targetColumnKey}`);
+});
+
+// Subscribe to drag events
+eventBus.on(CoreEvents.EVENT_DRAG_END, (e: Event) => {
+ const { swpEvent, target } = (e as CustomEvent).detail;
+ console.log(`Dropped on ${target}:`, swpEvent);
+});
+
+// One-time listener
+eventBus.once(CoreEvents.READY, () => {
+ console.log('Calendar is ready!');
+});
+```
+
+---
+
+## CSS Customization
+
+The calendar uses CSS custom properties for theming. Override these in your CSS:
+
+```css
+:root {
+ --calendar-hour-height: 64px;
+ --calendar-header-height: 48px;
+ --calendar-column-min-width: 120px;
+ --calendar-event-border-radius: 4px;
+}
+```
+
+---
+
+## Dependencies
+
+- `@novadi/core` - Dependency injection framework (peer dependency)
+- `dayjs` - Date manipulation and formatting
+
+---
+
+## License
+
+Proprietary - SWP
diff --git a/packages/calendar/build.js b/packages/calendar/build.js
new file mode 100644
index 0000000..0570894
--- /dev/null
+++ b/packages/calendar/build.js
@@ -0,0 +1,47 @@
+import * as esbuild from 'esbuild';
+import { NovadiUnplugin } from '@novadi/core/unplugin';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const entryPoints = [
+ 'src/index.ts',
+ 'src/extensions/teams/index.ts',
+ 'src/extensions/departments/index.ts',
+ 'src/extensions/bookings/index.ts',
+ 'src/extensions/customers/index.ts',
+ 'src/extensions/schedules/index.ts',
+ 'src/extensions/audit/index.ts'
+];
+
+async function build() {
+ await esbuild.build({
+ entryPoints,
+ bundle: true,
+ outdir: 'dist',
+ format: 'esm',
+ platform: 'browser',
+ external: ['@novadi/core', 'dayjs'],
+ splitting: true,
+ sourcemap: true,
+ target: 'es2020',
+ plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true })]
+ });
+
+ console.log('Build complete: dist/');
+
+ // Bundle CSS
+ const cssDir = 'dist/css';
+ if (!fs.existsSync(cssDir)) {
+ fs.mkdirSync(cssDir, { recursive: true });
+ }
+ const cssFiles = [
+ '../../wwwroot/css/calendar-base.css',
+ '../../wwwroot/css/calendar-layout.css',
+ '../../wwwroot/css/calendar-events.css'
+ ];
+ const bundledCss = cssFiles.map(f => fs.readFileSync(f, 'utf8')).join('\n');
+ fs.writeFileSync(path.join(cssDir, 'calendar.css'), bundledCss);
+ console.log('CSS bundled: dist/css/calendar.css');
+}
+
+build();
diff --git a/packages/calendar/package-lock.json b/packages/calendar/package-lock.json
new file mode 100644
index 0000000..c00dabb
--- /dev/null
+++ b/packages/calendar/package-lock.json
@@ -0,0 +1,167 @@
+{
+ "name": "@plantempus/calendar",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@plantempus/calendar",
+ "version": "0.1.0",
+ "dependencies": {
+ "dayjs": "^1.11.0"
+ },
+ "peerDependencies": {
+ "@novadi/core": "^0.6.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@novadi/core": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@novadi/core/-/core-0.6.0.tgz",
+ "integrity": "sha512-CU1134Nd7ULMg9OQbID5oP+FLtrMkNiLJ17+dmy4jjmPDcPK/dVzKTFxvJmbBvEfZEc9WtmkmJjqw11ABU7Jxw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "unplugin": "^2.3.10"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-win32-x64-msvc": "^4.52.5"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
+ "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.19",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
+ "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
+ "license": "MIT"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/unplugin": {
+ "version": "2.3.11",
+ "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
+ "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "acorn": "^8.15.0",
+ "picomatch": "^4.0.3",
+ "webpack-virtual-modules": "^0.6.2"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+ "license": "MIT",
+ "peer": true
+ }
+ }
+}
diff --git a/packages/calendar/package.json b/packages/calendar/package.json
new file mode 100644
index 0000000..9a7374b
--- /dev/null
+++ b/packages/calendar/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "calendar",
+ "version": "0.1.7",
+ "description": "Calendar library",
+ "author": "SWP",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./teams": {
+ "import": "./dist/extensions/teams/index.js",
+ "types": "./dist/extensions/teams/index.d.ts"
+ },
+ "./departments": {
+ "import": "./dist/extensions/departments/index.js",
+ "types": "./dist/extensions/departments/index.d.ts"
+ },
+ "./bookings": {
+ "import": "./dist/extensions/bookings/index.js",
+ "types": "./dist/extensions/bookings/index.d.ts"
+ },
+ "./customers": {
+ "import": "./dist/extensions/customers/index.js",
+ "types": "./dist/extensions/customers/index.d.ts"
+ },
+ "./schedules": {
+ "import": "./dist/extensions/schedules/index.js",
+ "types": "./dist/extensions/schedules/index.d.ts"
+ },
+ "./audit": {
+ "import": "./dist/extensions/audit/index.js",
+ "types": "./dist/extensions/audit/index.d.ts"
+ },
+ "./styles": "./dist/css/calendar.css"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "node build.js && npm run build:types",
+ "build:types": "tsc --emitDeclarationOnly --outDir dist"
+ },
+ "peerDependencies": {
+ "@novadi/core": "^0.6.0"
+ },
+ "dependencies": {
+ "dayjs": "^1.11.0"
+ },
+ "devDependencies": {
+ "esbuild": "^0.24.0",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/packages/calendar/src/CompositionRoot.ts b/packages/calendar/src/CompositionRoot.ts
new file mode 100644
index 0000000..e2b429a
--- /dev/null
+++ b/packages/calendar/src/CompositionRoot.ts
@@ -0,0 +1,163 @@
+import { Container, Builder } from '@novadi/core';
+
+// Core
+import { EventBus } from './core/EventBus';
+import { DateService } from './core/DateService';
+import { CalendarOrchestrator } from './core/CalendarOrchestrator';
+import { CalendarApp } from './core/CalendarApp';
+import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
+import { ScrollManager } from './core/ScrollManager';
+import { HeaderDrawerManager } from './core/HeaderDrawerManager';
+import { ITimeFormatConfig } from './core/ITimeFormatConfig';
+import { IGridConfig } from './core/IGridConfig';
+
+// Types
+import { IEventBus, ICalendarEvent, ISync, IResource } from './types/CalendarTypes';
+import { TenantSetting } from './types/SettingsTypes';
+import { ViewConfig } from './core/ViewConfig';
+
+// Renderers
+import { IRenderer } from './core/IGroupingRenderer';
+import { DateRenderer } from './features/date/DateRenderer';
+import { ResourceRenderer } from './features/resource/ResourceRenderer';
+import { EventRenderer } from './features/event/EventRenderer';
+import { ScheduleRenderer } from './features/schedule/ScheduleRenderer';
+import { HeaderDrawerRenderer } from './features/headerdrawer/HeaderDrawerRenderer';
+
+// Storage
+import { IndexedDBContext, IDBConfig, defaultDBConfig } from './storage/IndexedDBContext';
+import { IStore } from './storage/IStore';
+import { IEntityService } from './storage/IEntityService';
+import { EventStore } from './storage/events/EventStore';
+import { EventService } from './storage/events/EventService';
+import { ResourceStore } from './storage/resources/ResourceStore';
+import { ResourceService } from './storage/resources/ResourceService';
+import { SettingsStore } from './storage/settings/SettingsStore';
+import { SettingsService } from './storage/settings/SettingsService';
+import { ViewConfigStore } from './storage/viewconfigs/ViewConfigStore';
+import { ViewConfigService } from './storage/viewconfigs/ViewConfigService';
+
+// Managers
+import { DragDropManager } from './managers/DragDropManager';
+import { EdgeScrollManager } from './managers/EdgeScrollManager';
+import { ResizeManager } from './managers/ResizeManager';
+import { EventPersistenceManager } from './managers/EventPersistenceManager';
+
+/**
+ * Default configuration values
+ */
+export const defaultTimeFormatConfig: ITimeFormatConfig = {
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ use24HourFormat: true,
+ locale: 'da-DK',
+ dateFormat: 'locale',
+ showSeconds: false
+};
+
+export const defaultGridConfig: IGridConfig = {
+ hourHeight: 64,
+ dayStartHour: 6,
+ dayEndHour: 18,
+ snapInterval: 15,
+ gridStartThresholdMinutes: 30
+};
+
+/**
+ * Calendar configuration options
+ */
+export interface ICalendarOptions {
+ timeConfig?: ITimeFormatConfig;
+ gridConfig?: IGridConfig;
+ dbConfig?: IDBConfig;
+}
+
+/**
+ * Creates a configured DI container with all core calendar services registered.
+ * Call this to get a ready-to-use container for the calendar.
+ *
+ * @param options - Optional calendar configuration options
+ * @returns Configured Container instance
+ */
+export function createCalendarContainer(options?: ICalendarOptions): Container {
+ const container = new Container();
+ const builder = container.builder();
+
+ registerCoreServices(builder, options);
+
+ return builder.build();
+}
+
+/**
+ * Registers all core calendar services with the DI container builder.
+ * Use this when you need to customize the container or add extensions.
+ *
+ * @param builder - ContainerBuilder to register services with
+ * @param options - Optional calendar configuration options
+ */
+export function registerCoreServices(
+ builder: Builder,
+ options?: ICalendarOptions
+): void {
+ const timeConfig = options?.timeConfig ?? defaultTimeFormatConfig;
+ const gridConfig = options?.gridConfig ?? defaultGridConfig;
+ const dbConfig = options?.dbConfig ?? defaultDBConfig;
+ // Configuration instances
+ builder.registerInstance(timeConfig).as();
+ builder.registerInstance(gridConfig).as();
+ builder.registerInstance(dbConfig).as();
+
+ // Core - EventBus (singleton pattern via dual registration)
+ builder.registerType(EventBus).as();
+ builder.registerType(EventBus).as();
+
+ // Core Services
+ builder.registerType(DateService).as();
+
+ // Storage infrastructure
+ builder.registerType(IndexedDBContext).as();
+
+ // Core Stores (for IndexedDB schema creation via IStore[] array injection)
+ builder.registerType(EventStore).as();
+ builder.registerType(ResourceStore).as();
+ builder.registerType(SettingsStore).as();
+ builder.registerType(ViewConfigStore).as();
+
+ // Core Entity Services (polymorphic via IEntityService)
+ builder.registerType(EventService).as>();
+ builder.registerType(EventService).as>();
+ builder.registerType(EventService).as();
+
+ builder.registerType(ResourceService).as>();
+ builder.registerType(ResourceService).as>();
+ builder.registerType(ResourceService).as();
+
+ builder.registerType(SettingsService).as>();
+ builder.registerType(SettingsService).as>();
+ builder.registerType(SettingsService).as();
+
+ builder.registerType(ViewConfigService).as>();
+ builder.registerType(ViewConfigService).as>();
+ builder.registerType(ViewConfigService).as();
+
+ // Core Renderers
+ builder.registerType(EventRenderer).as();
+ builder.registerType(ScheduleRenderer).as();
+ builder.registerType(HeaderDrawerRenderer).as();
+ builder.registerType(TimeAxisRenderer).as();
+
+ // Grouping Renderers (registered as IRenderer[] for CalendarOrchestrator)
+ builder.registerType(DateRenderer).as();
+ builder.registerType(ResourceRenderer).as();
+
+ // Core Managers
+ builder.registerType(ScrollManager).as();
+ builder.registerType(HeaderDrawerManager).as();
+ builder.registerType(DragDropManager).as();
+ builder.registerType(EdgeScrollManager).as();
+ builder.registerType(ResizeManager).as();
+ builder.registerType(EventPersistenceManager).as();
+
+ // Orchestrator and App
+ builder.registerType(CalendarOrchestrator).as();
+ builder.registerType(CalendarApp).as();
+}
diff --git a/packages/calendar/src/constants/CoreEvents.ts b/packages/calendar/src/constants/CoreEvents.ts
new file mode 100644
index 0000000..7363138
--- /dev/null
+++ b/packages/calendar/src/constants/CoreEvents.ts
@@ -0,0 +1,71 @@
+/**
+ * CoreEvents - Consolidated essential events for the calendar
+ */
+export const CoreEvents = {
+ // Lifecycle events
+ INITIALIZED: 'core:initialized',
+ READY: 'core:ready',
+ DESTROYED: 'core:destroyed',
+
+ // View events
+ VIEW_CHANGED: 'view:changed',
+ VIEW_RENDERED: 'view:rendered',
+
+ // Navigation events
+ DATE_CHANGED: 'nav:date-changed',
+ NAVIGATION_COMPLETED: 'nav:navigation-completed',
+
+ // Data events
+ DATA_LOADING: 'data:loading',
+ DATA_LOADED: 'data:loaded',
+ DATA_ERROR: 'data:error',
+
+ // Grid events
+ GRID_RENDERED: 'grid:rendered',
+ GRID_CLICKED: 'grid:clicked',
+
+ // Event management
+ EVENT_CREATED: 'event:created',
+ EVENT_UPDATED: 'event:updated',
+ EVENT_DELETED: 'event:deleted',
+ EVENT_SELECTED: 'event:selected',
+
+ // Event drag-drop
+ EVENT_DRAG_START: 'event:drag-start',
+ EVENT_DRAG_MOVE: 'event:drag-move',
+ EVENT_DRAG_END: 'event:drag-end',
+ EVENT_DRAG_CANCEL: 'event:drag-cancel',
+ EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change',
+
+ // Header drag (timed → header conversion)
+ EVENT_DRAG_ENTER_HEADER: 'event:drag-enter-header',
+ EVENT_DRAG_MOVE_HEADER: 'event:drag-move-header',
+ EVENT_DRAG_LEAVE_HEADER: 'event:drag-leave-header',
+
+ // Event resize
+ EVENT_RESIZE_START: 'event:resize-start',
+ EVENT_RESIZE_END: 'event:resize-end',
+
+ // Edge scroll
+ EDGE_SCROLL_TICK: 'edge-scroll:tick',
+ EDGE_SCROLL_STARTED: 'edge-scroll:started',
+ EDGE_SCROLL_STOPPED: 'edge-scroll:stopped',
+
+ // System events
+ ERROR: 'system:error',
+
+ // Sync events
+ SYNC_STARTED: 'sync:started',
+ SYNC_COMPLETED: 'sync:completed',
+ SYNC_FAILED: 'sync:failed',
+
+ // Entity events - for audit and sync
+ ENTITY_SAVED: 'entity:saved',
+ ENTITY_DELETED: 'entity:deleted',
+
+ // Audit events
+ AUDIT_LOGGED: 'audit:logged',
+
+ // Rendering events
+ EVENTS_RENDERED: 'events:rendered'
+} as const;
diff --git a/packages/calendar/src/core/BaseGroupingRenderer.ts b/packages/calendar/src/core/BaseGroupingRenderer.ts
new file mode 100644
index 0000000..60c9abf
--- /dev/null
+++ b/packages/calendar/src/core/BaseGroupingRenderer.ts
@@ -0,0 +1,91 @@
+import { IRenderer, IRenderContext } from './IGroupingRenderer';
+
+/**
+ * Entity must have id
+ */
+export interface IGroupingEntity {
+ id: string;
+}
+
+/**
+ * Configuration for a grouping renderer
+ */
+export interface IGroupingRendererConfig {
+ elementTag: string; // e.g., 'swp-team-header'
+ idAttribute: string; // e.g., 'teamId' -> data-team-id
+ colspanVar: string; // e.g., '--team-cols'
+}
+
+/**
+ * Abstract base class for grouping renderers
+ *
+ * Handles:
+ * - Fetching entities by IDs
+ * - Calculating colspan from parentChildMap
+ * - Creating header elements
+ * - Appending to container
+ *
+ * Subclasses override:
+ * - renderHeader() for custom content
+ * - getDisplayName() for entity display text
+ */
+export abstract class BaseGroupingRenderer implements IRenderer {
+ abstract readonly type: string;
+ protected abstract readonly config: IGroupingRendererConfig;
+
+ /**
+ * Fetch entities from service
+ */
+ protected abstract getEntities(ids: string[]): Promise;
+
+ /**
+ * Get display name for entity
+ */
+ protected abstract getDisplayName(entity: T): string;
+
+ /**
+ * Main render method - handles common logic
+ */
+ async render(context: IRenderContext): Promise {
+ const allowedIds = context.filter[this.type] || [];
+ if (allowedIds.length === 0) return;
+
+ const entities = await this.getEntities(allowedIds);
+ const dateCount = context.filter['date']?.length || 1;
+ const childIds = context.childType ? context.filter[context.childType] || [] : [];
+
+ for (const entity of entities) {
+ const entityChildIds = context.parentChildMap?.[entity.id] || [];
+ const childCount = entityChildIds.filter(id => childIds.includes(id)).length;
+ const colspan = childCount * dateCount;
+
+ const header = document.createElement(this.config.elementTag);
+ header.dataset[this.config.idAttribute] = entity.id;
+ header.style.setProperty(this.config.colspanVar, String(colspan));
+
+ // Allow subclass to customize header content
+ this.renderHeader(entity, header, context);
+
+ context.headerContainer.appendChild(header);
+ }
+ }
+
+ /**
+ * Override this method for custom header rendering
+ * Default: just sets textContent to display name
+ */
+ protected renderHeader(entity: T, header: HTMLElement, _context: IRenderContext): void {
+ header.textContent = this.getDisplayName(entity);
+ }
+
+ /**
+ * Helper to render a single entity header.
+ * Can be used by subclasses that override render() but want consistent header creation.
+ */
+ protected createHeader(entity: T, context: IRenderContext): HTMLElement {
+ const header = document.createElement(this.config.elementTag);
+ header.dataset[this.config.idAttribute] = entity.id;
+ this.renderHeader(entity, header, context);
+ return header;
+ }
+}
diff --git a/packages/calendar/src/core/CalendarApp.ts b/packages/calendar/src/core/CalendarApp.ts
new file mode 100644
index 0000000..246b257
--- /dev/null
+++ b/packages/calendar/src/core/CalendarApp.ts
@@ -0,0 +1,201 @@
+import { CalendarOrchestrator } from './CalendarOrchestrator';
+import { TimeAxisRenderer } from '../features/timeaxis/TimeAxisRenderer';
+import { NavigationAnimator } from './NavigationAnimator';
+import { DateService } from './DateService';
+import { ScrollManager } from './ScrollManager';
+import { HeaderDrawerManager } from './HeaderDrawerManager';
+import { ViewConfig } from './ViewConfig';
+import { DragDropManager } from '../managers/DragDropManager';
+import { EdgeScrollManager } from '../managers/EdgeScrollManager';
+import { ResizeManager } from '../managers/ResizeManager';
+import { EventPersistenceManager } from '../managers/EventPersistenceManager';
+import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
+import { SettingsService } from '../storage/settings/SettingsService';
+import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService';
+import { IWorkweekPreset } from '../types/SettingsTypes';
+import { IEventBus } from '../types/CalendarTypes';
+import {
+ CalendarEvents,
+ RenderPayload,
+ WorkweekChangePayload,
+ ViewUpdatePayload
+} from './CalendarEvents';
+
+export class CalendarApp {
+ private animator!: NavigationAnimator;
+ private container!: HTMLElement;
+ private dayOffset = 0;
+ private currentViewId = 'simple';
+ private workweekPreset: IWorkweekPreset | null = null;
+ private groupingOverrides: Map = new Map();
+
+ constructor(
+ private orchestrator: CalendarOrchestrator,
+ private timeAxisRenderer: TimeAxisRenderer,
+ private dateService: DateService,
+ private scrollManager: ScrollManager,
+ private headerDrawerManager: HeaderDrawerManager,
+ private dragDropManager: DragDropManager,
+ private edgeScrollManager: EdgeScrollManager,
+ private resizeManager: ResizeManager,
+ private headerDrawerRenderer: HeaderDrawerRenderer,
+ private eventPersistenceManager: EventPersistenceManager,
+ private settingsService: SettingsService,
+ private viewConfigService: ViewConfigService,
+ private eventBus: IEventBus
+ ) {}
+
+ async init(container: HTMLElement): Promise {
+ this.container = container;
+
+ // Load settings
+ const gridSettings = await this.settingsService.getGridSettings();
+ if (!gridSettings) {
+ throw new Error('GridSettings not found');
+ }
+
+ this.workweekPreset = await this.settingsService.getDefaultWorkweekPreset();
+
+ // Create NavigationAnimator with DOM elements
+ this.animator = new NavigationAnimator(
+ container.querySelector('swp-header-track') as HTMLElement,
+ container.querySelector('swp-content-track') as HTMLElement,
+ container.querySelector('swp-header-drawer')
+ );
+
+ // Render time axis from settings
+ this.timeAxisRenderer.render(
+ container.querySelector('#time-axis') as HTMLElement,
+ gridSettings.dayStartHour,
+ gridSettings.dayEndHour
+ );
+
+ // Init managers
+ this.scrollManager.init(container);
+ this.headerDrawerManager.init(container);
+ this.dragDropManager.init(container);
+ this.resizeManager.init(container);
+
+ const scrollableContent = container.querySelector('swp-scrollable-content') as HTMLElement;
+ this.edgeScrollManager.init(scrollableContent);
+
+ // Setup command event listeners
+ this.setupEventListeners();
+
+ // Emit ready status
+ this.emitStatus('ready');
+ }
+
+ private setupEventListeners(): void {
+ // Navigation commands via EventBus
+ this.eventBus.on(CalendarEvents.CMD_NAVIGATE_PREV, () => {
+ this.handleNavigatePrev();
+ });
+
+ this.eventBus.on(CalendarEvents.CMD_NAVIGATE_NEXT, () => {
+ this.handleNavigateNext();
+ });
+
+ // Drawer toggle via EventBus
+ this.eventBus.on(CalendarEvents.CMD_DRAWER_TOGGLE, () => {
+ this.headerDrawerManager.toggle();
+ });
+
+ // Render command via EventBus
+ this.eventBus.on(CalendarEvents.CMD_RENDER, (e: Event) => {
+ const { viewId } = (e as CustomEvent).detail;
+ this.handleRenderCommand(viewId);
+ });
+
+ // Workweek change via EventBus
+ this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, (e: Event) => {
+ const { presetId } = (e as CustomEvent).detail;
+ this.handleWorkweekChange(presetId);
+ });
+
+ // View update via EventBus
+ this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, (e: Event) => {
+ const { type, values } = (e as CustomEvent).detail;
+ this.handleViewUpdate(type, values);
+ });
+ }
+
+ private async handleRenderCommand(viewId: string): Promise {
+ this.currentViewId = viewId;
+ await this.render();
+ this.emitStatus('rendered', { viewId });
+ }
+
+ private async handleNavigatePrev(): Promise {
+ const step = this.workweekPreset?.periodDays ?? 7;
+ this.dayOffset -= step;
+ await this.animator.slide('right', () => this.render());
+ this.emitStatus('rendered', { viewId: this.currentViewId });
+ }
+
+ private async handleNavigateNext(): Promise {
+ const step = this.workweekPreset?.periodDays ?? 7;
+ this.dayOffset += step;
+ await this.animator.slide('left', () => this.render());
+ this.emitStatus('rendered', { viewId: this.currentViewId });
+ }
+
+ private async handleWorkweekChange(presetId: string): Promise {
+ const preset = await this.settingsService.getWorkweekPreset(presetId);
+ if (preset) {
+ this.workweekPreset = preset;
+ await this.render();
+ this.emitStatus('rendered', { viewId: this.currentViewId });
+ }
+ }
+
+ private async handleViewUpdate(type: string, values: string[]): Promise {
+ this.groupingOverrides.set(type, values);
+ await this.render();
+ this.emitStatus('rendered', { viewId: this.currentViewId });
+ }
+
+ private async render(): Promise {
+ const storedConfig = await this.viewConfigService.getById(this.currentViewId);
+ if (!storedConfig) {
+ this.emitStatus('error', { message: `ViewConfig not found: ${this.currentViewId}` });
+ return;
+ }
+
+ // Populate date values based on workweek preset and day offset
+ const workDays = this.workweekPreset?.workDays || [1, 2, 3, 4, 5];
+ const periodDays = this.workweekPreset?.periodDays ?? 7;
+
+ // For single-day navigation (periodDays=1), show consecutive days from offset
+ // For week navigation (periodDays=7), show workDays from the week containing offset
+ const dates = periodDays === 1
+ ? this.dateService.getDatesFromOffset(this.dayOffset, workDays.length)
+ : this.dateService.getWorkDaysFromOffset(this.dayOffset, workDays);
+
+ // Clone config and apply overrides
+ const viewConfig: ViewConfig = {
+ ...storedConfig,
+ groupings: storedConfig.groupings.map(g => {
+ // Apply date values
+ if (g.type === 'date') {
+ return { ...g, values: dates };
+ }
+ // Apply grouping overrides
+ const override = this.groupingOverrides.get(g.type);
+ if (override) {
+ return { ...g, values: override };
+ }
+ return g;
+ })
+ };
+
+ await this.orchestrator.render(viewConfig, this.container);
+ }
+
+ private emitStatus(status: string, detail?: object): void {
+ this.container.dispatchEvent(new CustomEvent(`calendar:status:${status}`, {
+ detail,
+ bubbles: true
+ }));
+ }
+}
diff --git a/packages/calendar/src/core/CalendarEvents.ts b/packages/calendar/src/core/CalendarEvents.ts
new file mode 100644
index 0000000..4cf553e
--- /dev/null
+++ b/packages/calendar/src/core/CalendarEvents.ts
@@ -0,0 +1,28 @@
+/**
+ * CalendarEvents - Command and status events for CalendarApp
+ */
+export const CalendarEvents = {
+ // Command events (host → calendar)
+ CMD_NAVIGATE_PREV: 'calendar:cmd:navigate:prev',
+ CMD_NAVIGATE_NEXT: 'calendar:cmd:navigate:next',
+ CMD_DRAWER_TOGGLE: 'calendar:cmd:drawer:toggle',
+ CMD_RENDER: 'calendar:cmd:render',
+ CMD_WORKWEEK_CHANGE: 'calendar:cmd:workweek:change',
+ CMD_VIEW_UPDATE: 'calendar:cmd:view:update'
+} as const;
+
+/**
+ * Payload interfaces for CalendarEvents
+ */
+export interface RenderPayload {
+ viewId: string;
+}
+
+export interface WorkweekChangePayload {
+ presetId: string;
+}
+
+export interface ViewUpdatePayload {
+ type: string;
+ values: string[];
+}
diff --git a/packages/calendar/src/core/CalendarOrchestrator.ts b/packages/calendar/src/core/CalendarOrchestrator.ts
new file mode 100644
index 0000000..933e8a5
--- /dev/null
+++ b/packages/calendar/src/core/CalendarOrchestrator.ts
@@ -0,0 +1,124 @@
+import { IRenderer, IRenderContext } from './IGroupingRenderer';
+import { buildPipeline } from './RenderBuilder';
+import { EventRenderer } from '../features/event/EventRenderer';
+import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer';
+import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
+import { ViewConfig, GroupingConfig } from './ViewConfig';
+import { FilterTemplate } from './FilterTemplate';
+import { DateService } from './DateService';
+import { IEntityService } from '../storage/IEntityService';
+import { ISync } from '../types/CalendarTypes';
+
+export class CalendarOrchestrator {
+ constructor(
+ private allRenderers: IRenderer[],
+ private eventRenderer: EventRenderer,
+ private scheduleRenderer: ScheduleRenderer,
+ private headerDrawerRenderer: HeaderDrawerRenderer,
+ private dateService: DateService,
+ private entityServices: IEntityService[]
+ ) {}
+
+ async render(viewConfig: ViewConfig, container: HTMLElement): Promise {
+ const headerContainer = container.querySelector('swp-calendar-header') as HTMLElement;
+ const columnContainer = container.querySelector('swp-day-columns') as HTMLElement;
+ if (!headerContainer || !columnContainer) {
+ throw new Error('Missing swp-calendar-header or swp-day-columns');
+ }
+
+ // Byg filter fra viewConfig
+ const filter: Record = {};
+ for (const grouping of viewConfig.groupings) {
+ filter[grouping.type] = grouping.values;
+ }
+
+ // Byg FilterTemplate fra viewConfig groupings (kun de med idProperty)
+ const filterTemplate = new FilterTemplate(this.dateService);
+ for (const grouping of viewConfig.groupings) {
+ if (grouping.idProperty) {
+ filterTemplate.addField(grouping.idProperty, grouping.derivedFrom);
+ }
+ }
+
+ // Resolve belongsTo relations (e.g., team.resourceIds)
+ const { parentChildMap, childType } = await this.resolveBelongsTo(viewConfig.groupings, filter);
+
+ const context: IRenderContext = { headerContainer, columnContainer, filter, groupings: viewConfig.groupings, parentChildMap, childType };
+
+ // Clear
+ headerContainer.innerHTML = '';
+ columnContainer.innerHTML = '';
+
+ // Sæt data-levels attribut for CSS grid-row styling
+ const levels = viewConfig.groupings.map(g => g.type).join(' ');
+ headerContainer.dataset.levels = levels;
+
+ // Vælg renderers baseret på groupings types
+ const activeRenderers = this.selectRenderers(viewConfig);
+
+ // Byg og kør pipeline
+ const pipeline = buildPipeline(activeRenderers);
+ await pipeline.run(context);
+
+ // Render schedule unavailable zones (før events)
+ await this.scheduleRenderer.render(container, filter);
+
+ // Render timed events in grid (med filterTemplate til matching)
+ await this.eventRenderer.render(container, filter, filterTemplate);
+
+ // Render allDay events in header drawer (med filterTemplate til matching)
+ await this.headerDrawerRenderer.render(container, filter, filterTemplate);
+ }
+
+ private selectRenderers(viewConfig: ViewConfig): IRenderer[] {
+ const types = viewConfig.groupings.map(g => g.type);
+ // Sortér renderers i samme rækkefølge som viewConfig.groupings
+ return types
+ .map(type => this.allRenderers.find(r => r.type === type))
+ .filter((r): r is IRenderer => r !== undefined);
+ }
+
+ /**
+ * Resolve belongsTo relations to build parent-child map
+ * e.g., belongsTo: 'team.resourceIds' → { team1: ['EMP001', 'EMP002'], team2: [...] }
+ * Also returns the childType (the grouping type that has belongsTo)
+ */
+ private async resolveBelongsTo(
+ groupings: GroupingConfig[],
+ filter: Record
+ ): Promise<{ parentChildMap?: Record; childType?: string }> {
+ // Find grouping with belongsTo
+ const childGrouping = groupings.find(g => g.belongsTo);
+ if (!childGrouping?.belongsTo) return {};
+
+ // Parse belongsTo: 'team.resourceIds'
+ const [entityType, property] = childGrouping.belongsTo.split('.');
+ if (!entityType || !property) return {};
+
+ // Get parent IDs from filter
+ const parentIds = filter[entityType] || [];
+ if (parentIds.length === 0) return {};
+
+ // Find service dynamisk baseret på entityType (ingen hardcoded type check)
+ const service = this.entityServices.find(s =>
+ s.entityType.toLowerCase() === entityType
+ );
+ if (!service) return {};
+
+ // Hent alle entities og filtrer på parentIds
+ const allEntities = await service.getAll();
+ const entities = allEntities.filter(e =>
+ parentIds.includes((e as unknown as Record).id as string)
+ );
+
+ // Byg parent-child map
+ const map: Record = {};
+ for (const entity of entities) {
+ const entityRecord = entity as unknown as Record;
+ const children = (entityRecord[property] as string[]) || [];
+ map[entityRecord.id as string] = children;
+ }
+
+ return { parentChildMap: map, childType: childGrouping.type };
+ }
+}
diff --git a/packages/calendar/src/core/DateService.ts b/packages/calendar/src/core/DateService.ts
new file mode 100644
index 0000000..1d3c44d
--- /dev/null
+++ b/packages/calendar/src/core/DateService.ts
@@ -0,0 +1,195 @@
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import isoWeek from 'dayjs/plugin/isoWeek';
+import { ITimeFormatConfig } from './ITimeFormatConfig';
+
+// Enable dayjs plugins
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(isoWeek);
+
+export class DateService {
+ private timezone: string;
+ private baseDate: dayjs.Dayjs;
+
+ constructor(private config: ITimeFormatConfig, baseDate?: Date) {
+ this.timezone = config.timezone;
+ // Allow setting a fixed base date for demo/testing purposes
+ this.baseDate = baseDate ? dayjs(baseDate) : dayjs();
+ }
+
+ /**
+ * Set a fixed base date (useful for demos with static mock data)
+ */
+ setBaseDate(date: Date): void {
+ this.baseDate = dayjs(date);
+ }
+
+ /**
+ * Get the current base date (either fixed or today)
+ */
+ getBaseDate(): Date {
+ return this.baseDate.toDate();
+ }
+
+ parseISO(isoString: string): Date {
+ return dayjs(isoString).toDate();
+ }
+
+ getDayName(date: Date, format: 'short' | 'long' = 'short'): string {
+ return new Intl.DateTimeFormat(this.config.locale, { weekday: format }).format(date);
+ }
+
+ /**
+ * Get dates starting from a day offset
+ * @param dayOffset - Day offset from base date
+ * @param count - Number of consecutive days to return
+ * @returns Array of date strings in YYYY-MM-DD format
+ */
+ getDatesFromOffset(dayOffset: number, count: number): string[] {
+ const startDate = this.baseDate.add(dayOffset, 'day');
+ return Array.from({ length: count }, (_, i) =>
+ startDate.add(i, 'day').format('YYYY-MM-DD')
+ );
+ }
+
+ /**
+ * Get specific weekdays from the week containing the offset date
+ * @param dayOffset - Day offset from base date
+ * @param workDays - Array of ISO weekday numbers (1=Monday, 7=Sunday)
+ * @returns Array of date strings in YYYY-MM-DD format
+ */
+ getWorkDaysFromOffset(dayOffset: number, workDays: number[]): string[] {
+ // Get the date at offset, then find its week's Monday
+ const targetDate = this.baseDate.add(dayOffset, 'day');
+ const monday = targetDate.startOf('week').add(1, 'day');
+
+ return workDays.map(isoDay => {
+ // ISO: 1=Monday, 7=Sunday → days from Monday: 0-6
+ const daysFromMonday = isoDay === 7 ? 6 : isoDay - 1;
+ return monday.add(daysFromMonday, 'day').format('YYYY-MM-DD');
+ });
+ }
+
+ // Legacy methods for backwards compatibility
+ getWeekDates(weekOffset = 0, days = 7): string[] {
+ return this.getDatesFromOffset(weekOffset * 7, days);
+ }
+
+ getWorkWeekDates(weekOffset: number, workDays: number[]): string[] {
+ return this.getWorkDaysFromOffset(weekOffset * 7, workDays);
+ }
+
+ // ============================================
+ // FORMATTING
+ // ============================================
+
+ formatTime(date: Date, showSeconds = false): string {
+ const pattern = showSeconds ? 'HH:mm:ss' : 'HH:mm';
+ return dayjs(date).format(pattern);
+ }
+
+ formatTimeRange(start: Date, end: Date): string {
+ return `${this.formatTime(start)} - ${this.formatTime(end)}`;
+ }
+
+ formatDate(date: Date): string {
+ return dayjs(date).format('YYYY-MM-DD');
+ }
+
+ getDateKey(date: Date): string {
+ return this.formatDate(date);
+ }
+
+ // ============================================
+ // COLUMN KEY
+ // ============================================
+
+ /**
+ * Build a uniform columnKey from grouping segments
+ * Handles any combination of date, resource, team, etc.
+ *
+ * @example
+ * buildColumnKey({ date: '2025-12-09' }) → "2025-12-09"
+ * buildColumnKey({ date: '2025-12-09', resource: 'EMP001' }) → "2025-12-09:EMP001"
+ */
+ buildColumnKey(segments: Record): string {
+ // Always put date first if present, then other segments alphabetically
+ const date = segments.date;
+ const others = Object.entries(segments)
+ .filter(([k]) => k !== 'date')
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([, v]) => v);
+
+ return date ? [date, ...others].join(':') : others.join(':');
+ }
+
+ /**
+ * Parse a columnKey back into segments
+ * Assumes format: "date:resource:..." or just "date"
+ */
+ parseColumnKey(columnKey: string): { date: string; resource?: string } {
+ const parts = columnKey.split(':');
+ return {
+ date: parts[0],
+ resource: parts[1]
+ };
+ }
+
+ /**
+ * Extract dateKey from columnKey (first segment)
+ */
+ getDateFromColumnKey(columnKey: string): string {
+ return columnKey.split(':')[0];
+ }
+
+ // ============================================
+ // TIME CALCULATIONS
+ // ============================================
+
+ timeToMinutes(timeString: string): number {
+ const parts = timeString.split(':').map(Number);
+ const hours = parts[0] || 0;
+ const minutes = parts[1] || 0;
+ return hours * 60 + minutes;
+ }
+
+ minutesToTime(totalMinutes: number): string {
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ return dayjs().hour(hours).minute(minutes).format('HH:mm');
+ }
+
+ getMinutesSinceMidnight(date: Date): number {
+ const d = dayjs(date);
+ return d.hour() * 60 + d.minute();
+ }
+
+ // ============================================
+ // UTC CONVERSIONS
+ // ============================================
+
+ toUTC(localDate: Date): string {
+ return dayjs.tz(localDate, this.timezone).utc().toISOString();
+ }
+
+ fromUTC(utcString: string): Date {
+ return dayjs.utc(utcString).tz(this.timezone).toDate();
+ }
+
+ // ============================================
+ // DATE CREATION
+ // ============================================
+
+ createDateAtTime(baseDate: Date | string, timeString: string): Date {
+ const totalMinutes = this.timeToMinutes(timeString);
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ return dayjs(baseDate).startOf('day').hour(hours).minute(minutes).toDate();
+ }
+
+ getISOWeekDay(date: Date | string): number {
+ return dayjs(date).isoWeekday(); // 1=Monday, 7=Sunday
+ }
+}
diff --git a/packages/calendar/src/core/EntityResolver.ts b/packages/calendar/src/core/EntityResolver.ts
new file mode 100644
index 0000000..7161c30
--- /dev/null
+++ b/packages/calendar/src/core/EntityResolver.ts
@@ -0,0 +1,48 @@
+import { IEntityResolver } from './IEntityResolver';
+
+/**
+ * EntityResolver - Resolves entities from pre-loaded cache
+ *
+ * Entities must be loaded before use (typically at render time).
+ * This allows synchronous lookups during filtering.
+ */
+export class EntityResolver implements IEntityResolver {
+ private cache: Map>> = new Map();
+
+ /**
+ * Load entities into cache for a given type
+ * @param entityType - The entity type (e.g., 'resource')
+ * @param entities - Array of entities with 'id' property
+ */
+ load(entityType: string, entities: T[]): void {
+ const typeCache = new Map>();
+ for (const entity of entities) {
+ // Cast to Record for storage while preserving original data
+ typeCache.set(entity.id, entity as unknown as Record);
+ }
+ this.cache.set(entityType, typeCache);
+ }
+
+ /**
+ * Resolve an entity by type and ID
+ */
+ resolve(entityType: string, id: string): Record | undefined {
+ const typeCache = this.cache.get(entityType);
+ if (!typeCache) return undefined;
+ return typeCache.get(id);
+ }
+
+ /**
+ * Clear all cached entities
+ */
+ clear(): void {
+ this.cache.clear();
+ }
+
+ /**
+ * Clear cache for a specific entity type
+ */
+ clearType(entityType: string): void {
+ this.cache.delete(entityType);
+ }
+}
diff --git a/packages/calendar/src/core/EventBus.ts b/packages/calendar/src/core/EventBus.ts
new file mode 100644
index 0000000..469a73e
--- /dev/null
+++ b/packages/calendar/src/core/EventBus.ts
@@ -0,0 +1,174 @@
+import { IEventLogEntry, IListenerEntry, IEventBus } from '../types/CalendarTypes';
+
+/**
+ * Central event dispatcher for calendar using DOM CustomEvents
+ * Provides logging and debugging capabilities
+ */
+export class EventBus implements IEventBus {
+ private eventLog: IEventLogEntry[] = [];
+ private debug: boolean = false;
+ private listeners: Set = new Set();
+
+ // Log configuration for different categories
+ private logConfig: { [key: string]: boolean } = {
+ calendar: true,
+ grid: true,
+ event: true,
+ scroll: true,
+ navigation: true,
+ view: true,
+ default: true
+ };
+
+ /**
+ * Subscribe to an event via DOM addEventListener
+ */
+ on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void {
+ document.addEventListener(eventType, handler, options);
+
+ // Track for cleanup
+ this.listeners.add({ eventType, handler, options });
+
+ // Return unsubscribe function
+ return () => this.off(eventType, handler);
+ }
+
+ /**
+ * Subscribe to an event once
+ */
+ once(eventType: string, handler: EventListener): () => void {
+ return this.on(eventType, handler, { once: true });
+ }
+
+ /**
+ * Unsubscribe from an event
+ */
+ off(eventType: string, handler: EventListener): void {
+ document.removeEventListener(eventType, handler);
+
+ // Remove from tracking
+ for (const listener of this.listeners) {
+ if (listener.eventType === eventType && listener.handler === handler) {
+ this.listeners.delete(listener);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Emit an event via DOM CustomEvent
+ */
+ emit(eventType: string, detail: unknown = {}): boolean {
+ // Validate eventType
+ if (!eventType) {
+ return false;
+ }
+
+ const event = new CustomEvent(eventType, {
+ detail: detail ?? {},
+ bubbles: true,
+ cancelable: true
+ });
+
+ // Log event with grouping
+ if (this.debug) {
+ this.logEventWithGrouping(eventType, detail);
+ }
+
+ this.eventLog.push({
+ type: eventType,
+ detail: detail ?? {},
+ timestamp: Date.now()
+ });
+
+ // Emit on document (only DOM events now)
+ return !document.dispatchEvent(event);
+ }
+
+ /**
+ * Log event with console grouping
+ */
+ private logEventWithGrouping(eventType: string, _detail: unknown): void {
+ // Extract category from event type (e.g., 'calendar:datechanged' → 'calendar')
+ const category = this.extractCategory(eventType);
+
+ // Only log if category is enabled
+ if (!this.logConfig[category]) {
+ return;
+ }
+
+ // Get category emoji and color (used for future console styling)
+ this.getCategoryStyle(category);
+ }
+
+ /**
+ * Extract category from event type
+ */
+ private extractCategory(eventType: string): string {
+ if (!eventType) {
+ return 'unknown';
+ }
+
+ if (eventType.includes(':')) {
+ return eventType.split(':')[0];
+ }
+
+ // Fallback: try to detect category from event name patterns
+ const lowerType = eventType.toLowerCase();
+ if (lowerType.includes('grid') || lowerType.includes('rendered')) return 'grid';
+ if (lowerType.includes('event') || lowerType.includes('sync')) return 'event';
+ if (lowerType.includes('scroll')) return 'scroll';
+ if (lowerType.includes('nav') || lowerType.includes('date')) return 'navigation';
+ if (lowerType.includes('view')) return 'view';
+
+ return 'default';
+ }
+
+ /**
+ * Get styling for different categories
+ */
+ private getCategoryStyle(category: string): { emoji: string; color: string } {
+ const styles: { [key: string]: { emoji: string; color: string } } = {
+ calendar: { emoji: '📅', color: '#2196F3' },
+ grid: { emoji: '📊', color: '#4CAF50' },
+ event: { emoji: '📌', color: '#FF9800' },
+ scroll: { emoji: '📜', color: '#9C27B0' },
+ navigation: { emoji: '🧭', color: '#F44336' },
+ view: { emoji: '👁', color: '#00BCD4' },
+ default: { emoji: '📢', color: '#607D8B' }
+ };
+
+ return styles[category] || styles.default;
+ }
+
+ /**
+ * Configure logging for specific categories
+ */
+ setLogConfig(config: { [key: string]: boolean }): void {
+ this.logConfig = { ...this.logConfig, ...config };
+ }
+
+ /**
+ * Get current log configuration
+ */
+ getLogConfig(): { [key: string]: boolean } {
+ return { ...this.logConfig };
+ }
+
+ /**
+ * Get event history
+ */
+ getEventLog(eventType?: string): IEventLogEntry[] {
+ if (eventType) {
+ return this.eventLog.filter(e => e.type === eventType);
+ }
+ return this.eventLog;
+ }
+
+ /**
+ * Enable/disable debug mode
+ */
+ setDebug(enabled: boolean): void {
+ this.debug = enabled;
+ }
+}
diff --git a/packages/calendar/src/core/FilterTemplate.ts b/packages/calendar/src/core/FilterTemplate.ts
new file mode 100644
index 0000000..00451b1
--- /dev/null
+++ b/packages/calendar/src/core/FilterTemplate.ts
@@ -0,0 +1,149 @@
+import { ICalendarEvent } from '../types/CalendarTypes';
+import { DateService } from './DateService';
+import { IEntityResolver } from './IEntityResolver';
+
+/**
+ * Field definition for FilterTemplate
+ */
+interface IFilterField {
+ idProperty: string;
+ derivedFrom?: string;
+}
+
+/**
+ * Parsed dot-notation reference
+ */
+interface IDotNotation {
+ entityType: string; // e.g., 'resource'
+ property: string; // e.g., 'teamId'
+ foreignKey: string; // e.g., 'resourceId'
+}
+
+/**
+ * FilterTemplate - Bygger nøgler til event-kolonne matching
+ *
+ * ViewConfig definerer hvilke felter (idProperties) der indgår i kolonnens nøgle.
+ * Samme template bruges til at bygge nøgle for både kolonne og event.
+ *
+ * Supports dot-notation for hierarchical relations:
+ * - 'resource.teamId' → looks up event.resourceId → resource entity → teamId
+ *
+ * Princip: Kolonnens nøgle-template bestemmer hvad der matches på.
+ *
+ * @see docs/filter-template.md
+ */
+export class FilterTemplate {
+ private fields: IFilterField[] = [];
+
+ constructor(
+ private dateService: DateService,
+ private entityResolver?: IEntityResolver
+ ) {}
+
+ /**
+ * Tilføj felt til template
+ * @param idProperty - Property-navn (bruges på både event og column.dataset)
+ * @param derivedFrom - Hvis feltet udledes fra anden property (f.eks. date fra start)
+ */
+ addField(idProperty: string, derivedFrom?: string): this {
+ this.fields.push({ idProperty, derivedFrom });
+ return this;
+ }
+
+ /**
+ * Parse dot-notation string into components
+ * @example 'resource.teamId' → { entityType: 'resource', property: 'teamId', foreignKey: 'resourceId' }
+ */
+ private parseDotNotation(idProperty: string): IDotNotation | null {
+ if (!idProperty.includes('.')) return null;
+ const [entityType, property] = idProperty.split('.');
+ return {
+ entityType,
+ property,
+ foreignKey: entityType + 'Id' // Convention: resource → resourceId
+ };
+ }
+
+ /**
+ * Get dataset key for column lookup
+ * For dot-notation 'resource.teamId', we look for 'teamId' in dataset
+ */
+ private getDatasetKey(idProperty: string): string {
+ const dotNotation = this.parseDotNotation(idProperty);
+ if (dotNotation) {
+ return dotNotation.property; // 'teamId'
+ }
+ return idProperty;
+ }
+
+ /**
+ * Byg nøgle fra kolonne
+ * Læser værdier fra column.dataset[idProperty]
+ * For dot-notation, uses the property part (resource.teamId → teamId)
+ */
+ buildKeyFromColumn(column: HTMLElement): string {
+ return this.fields
+ .map(f => {
+ const key = this.getDatasetKey(f.idProperty);
+ return column.dataset[key] || '';
+ })
+ .join(':');
+ }
+
+ /**
+ * Byg nøgle fra event
+ * Læser værdier fra event[idProperty] eller udleder fra derivedFrom
+ * For dot-notation, resolves via EntityResolver
+ */
+ buildKeyFromEvent(event: ICalendarEvent): string {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const eventRecord = event as any;
+ return this.fields
+ .map(f => {
+ // Check for dot-notation (e.g., 'resource.teamId')
+ const dotNotation = this.parseDotNotation(f.idProperty);
+ if (dotNotation) {
+ return this.resolveDotNotation(eventRecord, dotNotation);
+ }
+
+ if (f.derivedFrom) {
+ // Udled værdi (f.eks. date fra start)
+ const sourceValue = eventRecord[f.derivedFrom];
+ if (sourceValue instanceof Date) {
+ return this.dateService.getDateKey(sourceValue);
+ }
+ return String(sourceValue || '');
+ }
+ return String(eventRecord[f.idProperty] || '');
+ })
+ .join(':');
+ }
+
+ /**
+ * Resolve dot-notation reference via EntityResolver
+ */
+ private resolveDotNotation(eventRecord: Record, dotNotation: IDotNotation): string {
+ if (!this.entityResolver) {
+ console.warn(`FilterTemplate: EntityResolver required for dot-notation '${dotNotation.entityType}.${dotNotation.property}'`);
+ return '';
+ }
+
+ // Get foreign key value from event (e.g., resourceId)
+ const foreignId = eventRecord[dotNotation.foreignKey];
+ if (!foreignId) return '';
+
+ // Resolve entity
+ const entity = this.entityResolver.resolve(dotNotation.entityType, String(foreignId));
+ if (!entity) return '';
+
+ // Return property value from entity
+ return String(entity[dotNotation.property] || '');
+ }
+
+ /**
+ * Match event mod kolonne
+ */
+ matches(event: ICalendarEvent, column: HTMLElement): boolean {
+ return this.buildKeyFromEvent(event) === this.buildKeyFromColumn(column);
+ }
+}
diff --git a/packages/calendar/src/core/HeaderDrawerManager.ts b/packages/calendar/src/core/HeaderDrawerManager.ts
new file mode 100644
index 0000000..445bb23
--- /dev/null
+++ b/packages/calendar/src/core/HeaderDrawerManager.ts
@@ -0,0 +1,70 @@
+export class HeaderDrawerManager {
+ private drawer!: HTMLElement;
+ private expanded = false;
+ private currentRows = 0;
+ private readonly rowHeight = 25;
+ private readonly duration = 200;
+
+ init(container: HTMLElement): void {
+ this.drawer = container.querySelector('swp-header-drawer')!;
+
+ if (!this.drawer) console.error('HeaderDrawerManager: swp-header-drawer not found');
+ }
+
+ toggle(): void {
+ this.expanded ? this.collapse() : this.expand();
+ }
+
+ /**
+ * Expand drawer to single row (legacy support)
+ */
+ expand(): void {
+ this.expandToRows(1);
+ }
+
+ /**
+ * Expand drawer to fit specified number of rows
+ */
+ expandToRows(rowCount: number): void {
+ const targetHeight = rowCount * this.rowHeight;
+ const currentHeight = this.expanded ? this.currentRows * this.rowHeight : 0;
+
+ // Skip if already at target
+ if (this.expanded && this.currentRows === rowCount) return;
+
+ this.currentRows = rowCount;
+ this.expanded = true;
+ this.animate(currentHeight, targetHeight);
+ }
+
+ collapse(): void {
+ if (!this.expanded) return;
+ const currentHeight = this.currentRows * this.rowHeight;
+ this.expanded = false;
+ this.currentRows = 0;
+ this.animate(currentHeight, 0);
+ }
+
+ private animate(from: number, to: number): void {
+ const keyframes = [
+ { height: `${from}px` },
+ { height: `${to}px` }
+ ];
+ const options: KeyframeAnimationOptions = {
+ duration: this.duration,
+ easing: 'ease',
+ fill: 'forwards'
+ };
+
+ // Kun animér drawer - ScrollManager synkroniserer header-spacer via ResizeObserver
+ this.drawer.animate(keyframes, options);
+ }
+
+ isExpanded(): boolean {
+ return this.expanded;
+ }
+
+ getRowCount(): number {
+ return this.currentRows;
+ }
+}
diff --git a/packages/calendar/src/core/IEntityResolver.ts b/packages/calendar/src/core/IEntityResolver.ts
new file mode 100644
index 0000000..b825c0f
--- /dev/null
+++ b/packages/calendar/src/core/IEntityResolver.ts
@@ -0,0 +1,15 @@
+/**
+ * IEntityResolver - Resolves entities by type and ID
+ *
+ * Used by FilterTemplate to resolve dot-notation references like 'resource.teamId'
+ * where the value needs to be looked up from a related entity.
+ */
+export interface IEntityResolver {
+ /**
+ * Resolve an entity by type and ID
+ * @param entityType - The entity type (e.g., 'resource', 'booking', 'customer')
+ * @param id - The entity ID
+ * @returns The entity record or undefined if not found
+ */
+ resolve(entityType: string, id: string): Record | undefined;
+}
diff --git a/packages/calendar/src/core/IGridConfig.ts b/packages/calendar/src/core/IGridConfig.ts
new file mode 100644
index 0000000..03c6a2f
--- /dev/null
+++ b/packages/calendar/src/core/IGridConfig.ts
@@ -0,0 +1,7 @@
+export interface IGridConfig {
+ hourHeight: number; // pixels per hour
+ dayStartHour: number; // e.g. 6
+ dayEndHour: number; // e.g. 18
+ snapInterval: number; // minutes, e.g. 15
+ gridStartThresholdMinutes?: number; // threshold for GRID grouping (default 10)
+}
diff --git a/packages/calendar/src/core/IGroupingRenderer.ts b/packages/calendar/src/core/IGroupingRenderer.ts
new file mode 100644
index 0000000..a1bc507
--- /dev/null
+++ b/packages/calendar/src/core/IGroupingRenderer.ts
@@ -0,0 +1,15 @@
+import { GroupingConfig } from './ViewConfig';
+
+export interface IRenderContext {
+ headerContainer: HTMLElement;
+ columnContainer: HTMLElement;
+ filter: Record; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] }
+ groupings?: GroupingConfig[]; // Full grouping configs (for hideHeader etc.)
+ parentChildMap?: Record; // { team1: ['EMP001', 'EMP002'], team2: ['EMP003', 'EMP004'] }
+ childType?: string; // The type of the child grouping (e.g., 'resource' when team has belongsTo)
+}
+
+export interface IRenderer {
+ readonly type: string;
+ render(context: IRenderContext): void | Promise;
+}
diff --git a/packages/calendar/src/core/IGroupingStore.ts b/packages/calendar/src/core/IGroupingStore.ts
new file mode 100644
index 0000000..8abc837
--- /dev/null
+++ b/packages/calendar/src/core/IGroupingStore.ts
@@ -0,0 +1,4 @@
+export interface IGroupingStore {
+ readonly type: string;
+ getByIds(ids: string[]): T[];
+}
diff --git a/src/configurations/TimeFormatConfig.ts b/packages/calendar/src/core/ITimeFormatConfig.ts
similarity index 78%
rename from src/configurations/TimeFormatConfig.ts
rename to packages/calendar/src/core/ITimeFormatConfig.ts
index 2bb9207..1a401d5 100644
--- a/src/configurations/TimeFormatConfig.ts
+++ b/packages/calendar/src/core/ITimeFormatConfig.ts
@@ -1,10 +1,7 @@
-/**
- * Time format configuration settings
- */
-export interface ITimeFormatConfig {
- timezone: string;
- use24HourFormat: boolean;
- locale: string;
- dateFormat: 'locale' | 'technical';
- showSeconds: boolean;
-}
+export interface ITimeFormatConfig {
+ timezone: string;
+ use24HourFormat: boolean;
+ locale: string;
+ dateFormat: 'locale' | 'technical';
+ showSeconds: boolean;
+}
diff --git a/packages/calendar/src/core/NavigationAnimator.ts b/packages/calendar/src/core/NavigationAnimator.ts
new file mode 100644
index 0000000..cf173ad
--- /dev/null
+++ b/packages/calendar/src/core/NavigationAnimator.ts
@@ -0,0 +1,64 @@
+export class NavigationAnimator {
+ constructor(
+ private headerTrack: HTMLElement,
+ private contentTrack: HTMLElement,
+ private headerDrawer: HTMLElement | null
+ ) {}
+
+ async slide(direction: 'left' | 'right', renderFn: () => Promise): Promise {
+ const out = direction === 'left' ? '-100%' : '100%';
+ const into = direction === 'left' ? '100%' : '-100%';
+
+ await this.animateOut(out);
+ await renderFn();
+ await this.animateIn(into);
+ }
+
+ private async animateOut(translate: string): Promise {
+ const animations = [
+ this.headerTrack.animate(
+ [{ transform: 'translateX(0)' }, { transform: `translateX(${translate})` }],
+ { duration: 200, easing: 'ease-in' }
+ ).finished,
+ this.contentTrack.animate(
+ [{ transform: 'translateX(0)' }, { transform: `translateX(${translate})` }],
+ { duration: 200, easing: 'ease-in' }
+ ).finished
+ ];
+
+ if (this.headerDrawer) {
+ animations.push(
+ this.headerDrawer.animate(
+ [{ transform: 'translateX(0)' }, { transform: `translateX(${translate})` }],
+ { duration: 200, easing: 'ease-in' }
+ ).finished
+ );
+ }
+
+ await Promise.all(animations);
+ }
+
+ private async animateIn(translate: string): Promise {
+ const animations = [
+ this.headerTrack.animate(
+ [{ transform: `translateX(${translate})` }, { transform: 'translateX(0)' }],
+ { duration: 200, easing: 'ease-out' }
+ ).finished,
+ this.contentTrack.animate(
+ [{ transform: `translateX(${translate})` }, { transform: 'translateX(0)' }],
+ { duration: 200, easing: 'ease-out' }
+ ).finished
+ ];
+
+ if (this.headerDrawer) {
+ animations.push(
+ this.headerDrawer.animate(
+ [{ transform: `translateX(${translate})` }, { transform: 'translateX(0)' }],
+ { duration: 200, easing: 'ease-out' }
+ ).finished
+ );
+ }
+
+ await Promise.all(animations);
+ }
+}
diff --git a/packages/calendar/src/core/RenderBuilder.ts b/packages/calendar/src/core/RenderBuilder.ts
new file mode 100644
index 0000000..68f0ee3
--- /dev/null
+++ b/packages/calendar/src/core/RenderBuilder.ts
@@ -0,0 +1,15 @@
+import { IRenderer, IRenderContext } from './IGroupingRenderer';
+
+export interface Pipeline {
+ run(context: IRenderContext): Promise;
+}
+
+export function buildPipeline(renderers: IRenderer[]): Pipeline {
+ return {
+ async run(context: IRenderContext) {
+ for (const renderer of renderers) {
+ await renderer.render(context);
+ }
+ }
+ };
+}
diff --git a/packages/calendar/src/core/ScrollManager.ts b/packages/calendar/src/core/ScrollManager.ts
new file mode 100644
index 0000000..bb4f490
--- /dev/null
+++ b/packages/calendar/src/core/ScrollManager.ts
@@ -0,0 +1,42 @@
+export class ScrollManager {
+ private scrollableContent!: HTMLElement;
+ private timeAxisContent!: HTMLElement;
+ private calendarHeader!: HTMLElement;
+ private headerDrawer!: HTMLElement;
+ private headerViewport!: HTMLElement;
+ private headerSpacer!: HTMLElement;
+ private resizeObserver!: ResizeObserver;
+
+ init(container: HTMLElement): void {
+ this.scrollableContent = container.querySelector('swp-scrollable-content')!;
+ this.timeAxisContent = container.querySelector('swp-time-axis-content')!;
+ this.calendarHeader = container.querySelector('swp-calendar-header')!;
+ this.headerDrawer = container.querySelector('swp-header-drawer')!;
+ this.headerViewport = container.querySelector('swp-header-viewport')!;
+ this.headerSpacer = container.querySelector('swp-header-spacer')!;
+
+ this.scrollableContent.addEventListener('scroll', () => this.onScroll());
+
+ // Synkroniser header-spacer højde med header-viewport
+ this.resizeObserver = new ResizeObserver(() => this.syncHeaderSpacerHeight());
+ this.resizeObserver.observe(this.headerViewport);
+ this.syncHeaderSpacerHeight();
+ }
+
+ private syncHeaderSpacerHeight(): void {
+ // Kopier den faktiske computed height direkte fra header-viewport
+ const computedHeight = getComputedStyle(this.headerViewport).height;
+ this.headerSpacer.style.height = computedHeight;
+ }
+
+ private onScroll(): void {
+ const { scrollTop, scrollLeft } = this.scrollableContent;
+
+ // Synkroniser time-axis vertikalt
+ this.timeAxisContent.style.transform = `translateY(-${scrollTop}px)`;
+
+ // Synkroniser header og drawer horisontalt
+ this.calendarHeader.style.transform = `translateX(-${scrollLeft}px)`;
+ this.headerDrawer.style.transform = `translateX(-${scrollLeft}px)`;
+ }
+}
diff --git a/packages/calendar/src/core/ViewConfig.ts b/packages/calendar/src/core/ViewConfig.ts
new file mode 100644
index 0000000..8ecd79b
--- /dev/null
+++ b/packages/calendar/src/core/ViewConfig.ts
@@ -0,0 +1,21 @@
+import { ISync } from '../types/CalendarTypes';
+
+export interface ViewTemplate {
+ id: string;
+ name: string;
+ groupingTypes: string[];
+}
+
+export interface ViewConfig extends ISync {
+ id: string; // templateId (e.g. 'day', 'simple', 'resource')
+ groupings: GroupingConfig[];
+}
+
+export interface GroupingConfig {
+ type: string;
+ values: string[];
+ idProperty?: string; // Property-navn på event (f.eks. 'resourceId') - kun for event matching
+ derivedFrom?: string; // Hvis feltet udledes fra anden property (f.eks. 'date' fra 'start')
+ belongsTo?: string; // Parent-child relation (f.eks. 'team.resourceIds')
+ hideHeader?: boolean; // Skjul header-rækken for denne grouping (f.eks. dato i day-view)
+}
diff --git a/packages/calendar/src/extensions/audit/AuditService.ts b/packages/calendar/src/extensions/audit/AuditService.ts
new file mode 100644
index 0000000..ccdb2b4
--- /dev/null
+++ b/packages/calendar/src/extensions/audit/AuditService.ts
@@ -0,0 +1,167 @@
+import { BaseEntityService } from '../../storage/BaseEntityService';
+import { IndexedDBContext } from '../../storage/IndexedDBContext';
+import { IAuditEntry, IAuditLoggedPayload } from '../../types/AuditTypes';
+import { EntityType, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../../types/CalendarTypes';
+import { CoreEvents } from '../../constants/CoreEvents';
+
+/**
+ * AuditService - Entity service for audit entries
+ *
+ * RESPONSIBILITIES:
+ * - Store audit entries in IndexedDB
+ * - Listen for ENTITY_SAVED/ENTITY_DELETED events
+ * - Create audit entries for all entity changes
+ * - Emit AUDIT_LOGGED after saving (for SyncManager to listen)
+ *
+ * OVERRIDE PATTERN:
+ * - Overrides save() to NOT emit events (prevents infinite loops)
+ * - AuditService saves audit entries without triggering more audits
+ *
+ * EVENT CHAIN:
+ * Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager
+ */
+export class AuditService extends BaseEntityService {
+ readonly storeName = 'audit';
+ readonly entityType: EntityType = 'Audit';
+
+ // Hardcoded userId for now - will come from session later
+ private static readonly DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001';
+
+ constructor(context: IndexedDBContext, eventBus: IEventBus) {
+ super(context, eventBus);
+ this.setupEventListeners();
+ }
+
+ /**
+ * Setup listeners for ENTITY_SAVED and ENTITY_DELETED events
+ */
+ private setupEventListeners(): void {
+ // Listen for entity saves (create/update)
+ this.eventBus.on(CoreEvents.ENTITY_SAVED, (event: Event) => {
+ const detail = (event as CustomEvent).detail;
+ this.handleEntitySaved(detail);
+ });
+
+ // Listen for entity deletes
+ this.eventBus.on(CoreEvents.ENTITY_DELETED, (event: Event) => {
+ const detail = (event as CustomEvent).detail;
+ this.handleEntityDeleted(detail);
+ });
+ }
+
+ /**
+ * Handle ENTITY_SAVED event - create audit entry
+ */
+ private async handleEntitySaved(payload: IEntitySavedPayload): Promise {
+ // Don't audit audit entries (prevent infinite loops)
+ if (payload.entityType === 'Audit') return;
+
+ const auditEntry: IAuditEntry = {
+ id: crypto.randomUUID(),
+ entityType: payload.entityType,
+ entityId: payload.entityId,
+ operation: payload.operation,
+ userId: AuditService.DEFAULT_USER_ID,
+ timestamp: payload.timestamp,
+ changes: payload.changes,
+ synced: false,
+ syncStatus: 'pending'
+ };
+
+ await this.save(auditEntry);
+ }
+
+ /**
+ * Handle ENTITY_DELETED event - create audit entry
+ */
+ private async handleEntityDeleted(payload: IEntityDeletedPayload): Promise {
+ // Don't audit audit entries (prevent infinite loops)
+ if (payload.entityType === 'Audit') return;
+
+ const auditEntry: IAuditEntry = {
+ id: crypto.randomUUID(),
+ entityType: payload.entityType,
+ entityId: payload.entityId,
+ operation: 'delete',
+ userId: AuditService.DEFAULT_USER_ID,
+ timestamp: payload.timestamp,
+ changes: { id: payload.entityId }, // For delete, just store the ID
+ synced: false,
+ syncStatus: 'pending'
+ };
+
+ await this.save(auditEntry);
+ }
+
+ /**
+ * Override save to NOT trigger ENTITY_SAVED event
+ * Instead, emits AUDIT_LOGGED for SyncManager to listen
+ *
+ * This prevents infinite loops:
+ * - BaseEntityService.save() emits ENTITY_SAVED
+ * - AuditService listens to ENTITY_SAVED and creates audit
+ * - If AuditService.save() also emitted ENTITY_SAVED, it would loop
+ */
+ async save(entity: IAuditEntry): Promise {
+ const serialized = this.serialize(entity);
+
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
+ const store = transaction.objectStore(this.storeName);
+ const request = store.put(serialized);
+
+ request.onsuccess = () => {
+ // Emit AUDIT_LOGGED instead of ENTITY_SAVED
+ const payload: IAuditLoggedPayload = {
+ auditId: entity.id,
+ entityType: entity.entityType,
+ entityId: entity.entityId,
+ operation: entity.operation,
+ timestamp: entity.timestamp
+ };
+ this.eventBus.emit(CoreEvents.AUDIT_LOGGED, payload);
+ resolve();
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to save audit entry ${entity.id}: ${request.error}`));
+ };
+ });
+ }
+
+ /**
+ * Override delete to NOT trigger ENTITY_DELETED event
+ * Audit entries should never be deleted (compliance requirement)
+ */
+ async delete(_id: string): Promise {
+ throw new Error('Audit entries cannot be deleted (compliance requirement)');
+ }
+
+ /**
+ * Get pending audit entries (for sync)
+ */
+ async getPendingAudits(): Promise {
+ return this.getBySyncStatus('pending');
+ }
+
+ /**
+ * Get audit entries for a specific entity
+ */
+ async getByEntityId(entityId: string): Promise {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([this.storeName], 'readonly');
+ const store = transaction.objectStore(this.storeName);
+ const index = store.index('entityId');
+ const request = index.getAll(entityId);
+
+ request.onsuccess = () => {
+ const entries = request.result as IAuditEntry[];
+ resolve(entries);
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to get audit entries for entity ${entityId}: ${request.error}`));
+ };
+ });
+ }
+}
diff --git a/packages/calendar/src/extensions/audit/AuditStore.ts b/packages/calendar/src/extensions/audit/AuditStore.ts
new file mode 100644
index 0000000..769b3b9
--- /dev/null
+++ b/packages/calendar/src/extensions/audit/AuditStore.ts
@@ -0,0 +1,27 @@
+import { IStore } from '../../storage/IStore';
+
+/**
+ * AuditStore - IndexedDB store configuration for audit entries
+ *
+ * Stores all entity changes for:
+ * - Compliance and audit trail
+ * - Sync tracking with backend
+ * - Change history
+ *
+ * Indexes:
+ * - syncStatus: For finding pending entries to sync
+ * - synced: Boolean flag for quick sync queries
+ * - entityId: For getting all audits for a specific entity
+ * - timestamp: For chronological queries
+ */
+export class AuditStore implements IStore {
+ readonly storeName = 'audit';
+
+ create(db: IDBDatabase): void {
+ const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
+ store.createIndex('syncStatus', 'syncStatus', { unique: false });
+ store.createIndex('synced', 'synced', { unique: false });
+ store.createIndex('entityId', 'entityId', { unique: false });
+ store.createIndex('timestamp', 'timestamp', { unique: false });
+ }
+}
diff --git a/packages/calendar/src/extensions/audit/index.ts b/packages/calendar/src/extensions/audit/index.ts
new file mode 100644
index 0000000..48100e1
--- /dev/null
+++ b/packages/calendar/src/extensions/audit/index.ts
@@ -0,0 +1,14 @@
+export { AuditService } from './AuditService';
+export { AuditStore } from './AuditStore';
+export type { IAuditEntry, IAuditLoggedPayload } from '../../types/AuditTypes';
+
+// DI registration helper
+import type { Builder } from '@novadi/core';
+import { IStore } from '../../storage/IStore';
+import { AuditStore } from './AuditStore';
+import { AuditService } from './AuditService';
+
+export function registerAudit(builder: Builder): void {
+ builder.registerType(AuditStore).as();
+ builder.registerType(AuditService).as();
+}
diff --git a/packages/calendar/src/extensions/bookings/BookingService.ts b/packages/calendar/src/extensions/bookings/BookingService.ts
new file mode 100644
index 0000000..d12eb57
--- /dev/null
+++ b/packages/calendar/src/extensions/bookings/BookingService.ts
@@ -0,0 +1,75 @@
+import { IBooking, EntityType, IEventBus, BookingStatus } from '../../types/CalendarTypes';
+import { BookingStore } from './BookingStore';
+import { BaseEntityService } from '../../storage/BaseEntityService';
+import { IndexedDBContext } from '../../storage/IndexedDBContext';
+
+/**
+ * BookingService - CRUD operations for bookings in IndexedDB
+ */
+export class BookingService extends BaseEntityService {
+ readonly storeName = BookingStore.STORE_NAME;
+ readonly entityType: EntityType = 'Booking';
+
+ constructor(context: IndexedDBContext, eventBus: IEventBus) {
+ super(context, eventBus);
+ }
+
+ protected serialize(booking: IBooking): unknown {
+ return {
+ ...booking,
+ createdAt: booking.createdAt.toISOString()
+ };
+ }
+
+ protected deserialize(data: unknown): IBooking {
+ const raw = data as Record;
+ return {
+ ...raw,
+ createdAt: new Date(raw.createdAt as string)
+ } as IBooking;
+ }
+
+ /**
+ * Get bookings for a customer
+ */
+ async getByCustomer(customerId: string): Promise {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([this.storeName], 'readonly');
+ const store = transaction.objectStore(this.storeName);
+ const index = store.index('customerId');
+ const request = index.getAll(customerId);
+
+ request.onsuccess = () => {
+ const data = request.result as unknown[];
+ const bookings = data.map(item => this.deserialize(item));
+ resolve(bookings);
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to get bookings for customer ${customerId}: ${request.error}`));
+ };
+ });
+ }
+
+ /**
+ * Get bookings by status
+ */
+ async getByStatus(status: BookingStatus): Promise {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([this.storeName], 'readonly');
+ const store = transaction.objectStore(this.storeName);
+ const index = store.index('status');
+ const request = index.getAll(status);
+
+ request.onsuccess = () => {
+ const data = request.result as unknown[];
+ const bookings = data.map(item => this.deserialize(item));
+ resolve(bookings);
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to get bookings with status ${status}: ${request.error}`));
+ };
+ });
+ }
+}
diff --git a/packages/calendar/src/extensions/bookings/BookingStore.ts b/packages/calendar/src/extensions/bookings/BookingStore.ts
new file mode 100644
index 0000000..5e64ad3
--- /dev/null
+++ b/packages/calendar/src/extensions/bookings/BookingStore.ts
@@ -0,0 +1,18 @@
+import { IStore } from '../../storage/IStore';
+
+/**
+ * BookingStore - IndexedDB ObjectStore definition for bookings
+ */
+export class BookingStore implements IStore {
+ static readonly STORE_NAME = 'bookings';
+ readonly storeName = BookingStore.STORE_NAME;
+
+ create(db: IDBDatabase): void {
+ const store = db.createObjectStore(BookingStore.STORE_NAME, { keyPath: 'id' });
+
+ store.createIndex('customerId', 'customerId', { unique: false });
+ store.createIndex('status', 'status', { unique: false });
+ store.createIndex('syncStatus', 'syncStatus', { unique: false });
+ store.createIndex('createdAt', 'createdAt', { unique: false });
+ }
+}
diff --git a/packages/calendar/src/extensions/bookings/index.ts b/packages/calendar/src/extensions/bookings/index.ts
new file mode 100644
index 0000000..4bfe7d2
--- /dev/null
+++ b/packages/calendar/src/extensions/bookings/index.ts
@@ -0,0 +1,18 @@
+export { BookingService } from './BookingService';
+export { BookingStore } from './BookingStore';
+export type { IBooking, BookingStatus, IBookingService } from '../../types/CalendarTypes';
+
+// DI registration helper
+import type { Builder } from '@novadi/core';
+import { IStore } from '../../storage/IStore';
+import { IEntityService } from '../../storage/IEntityService';
+import type { IBooking, ISync } from '../../types/CalendarTypes';
+import { BookingStore } from './BookingStore';
+import { BookingService } from './BookingService';
+
+export function registerBookings(builder: Builder): void {
+ builder.registerType(BookingStore).as();
+ builder.registerType(BookingService).as>();
+ builder.registerType(BookingService).as>();
+ builder.registerType(BookingService).as();
+}
diff --git a/packages/calendar/src/extensions/customers/CustomerService.ts b/packages/calendar/src/extensions/customers/CustomerService.ts
new file mode 100644
index 0000000..d1225e1
--- /dev/null
+++ b/packages/calendar/src/extensions/customers/CustomerService.ts
@@ -0,0 +1,46 @@
+import { ICustomer, EntityType, IEventBus } from '../../types/CalendarTypes';
+import { CustomerStore } from './CustomerStore';
+import { BaseEntityService } from '../../storage/BaseEntityService';
+import { IndexedDBContext } from '../../storage/IndexedDBContext';
+
+/**
+ * CustomerService - CRUD operations for customers in IndexedDB
+ */
+export class CustomerService extends BaseEntityService {
+ readonly storeName = CustomerStore.STORE_NAME;
+ readonly entityType: EntityType = 'Customer';
+
+ constructor(context: IndexedDBContext, eventBus: IEventBus) {
+ super(context, eventBus);
+ }
+
+ /**
+ * Search customers by name (case-insensitive contains)
+ */
+ async searchByName(query: string): Promise {
+ const all = await this.getAll();
+ const lowerQuery = query.toLowerCase();
+ return all.filter(c => c.name.toLowerCase().includes(lowerQuery));
+ }
+
+ /**
+ * Find customer by phone
+ */
+ async getByPhone(phone: string): Promise {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([this.storeName], 'readonly');
+ const store = transaction.objectStore(this.storeName);
+ const index = store.index('phone');
+ const request = index.get(phone);
+
+ request.onsuccess = () => {
+ const data = request.result;
+ resolve(data ? (data as ICustomer) : null);
+ };
+
+ request.onerror = () => {
+ reject(new Error(`Failed to find customer by phone ${phone}: ${request.error}`));
+ };
+ });
+ }
+}
diff --git a/packages/calendar/src/extensions/customers/CustomerStore.ts b/packages/calendar/src/extensions/customers/CustomerStore.ts
new file mode 100644
index 0000000..b53cd7e
--- /dev/null
+++ b/packages/calendar/src/extensions/customers/CustomerStore.ts
@@ -0,0 +1,17 @@
+import { IStore } from '../../storage/IStore';
+
+/**
+ * CustomerStore - IndexedDB ObjectStore definition for customers
+ */
+export class CustomerStore implements IStore {
+ static readonly STORE_NAME = 'customers';
+ readonly storeName = CustomerStore.STORE_NAME;
+
+ create(db: IDBDatabase): void {
+ const store = db.createObjectStore(CustomerStore.STORE_NAME, { keyPath: 'id' });
+
+ store.createIndex('name', 'name', { unique: false });
+ store.createIndex('phone', 'phone', { unique: false });
+ store.createIndex('syncStatus', 'syncStatus', { unique: false });
+ }
+}
diff --git a/packages/calendar/src/extensions/customers/index.ts b/packages/calendar/src/extensions/customers/index.ts
new file mode 100644
index 0000000..6d47df5
--- /dev/null
+++ b/packages/calendar/src/extensions/customers/index.ts
@@ -0,0 +1,18 @@
+export { CustomerService } from './CustomerService';
+export { CustomerStore } from './CustomerStore';
+export type { ICustomer } from '../../types/CalendarTypes';
+
+// DI registration helper
+import type { Builder } from '@novadi/core';
+import { IStore } from '../../storage/IStore';
+import { IEntityService } from '../../storage/IEntityService';
+import type { ICustomer, ISync } from '../../types/CalendarTypes';
+import { CustomerStore } from './CustomerStore';
+import { CustomerService } from './CustomerService';
+
+export function registerCustomers(builder: Builder): void {
+ builder.registerType(CustomerStore).as();
+ builder.registerType(CustomerService).as>();
+ builder.registerType(CustomerService).as>();
+ builder.registerType(CustomerService).as();
+}
diff --git a/packages/calendar/src/extensions/departments/DepartmentRenderer.ts b/packages/calendar/src/extensions/departments/DepartmentRenderer.ts
new file mode 100644
index 0000000..16bb161
--- /dev/null
+++ b/packages/calendar/src/extensions/departments/DepartmentRenderer.ts
@@ -0,0 +1,25 @@
+import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
+import { DepartmentService } from './DepartmentService';
+import { IDepartment } from '../../types/CalendarTypes';
+
+export class DepartmentRenderer extends BaseGroupingRenderer {
+ readonly type = 'department';
+
+ protected readonly config: IGroupingRendererConfig = {
+ elementTag: 'swp-department-header',
+ idAttribute: 'departmentId',
+ colspanVar: '--department-cols'
+ };
+
+ constructor(private departmentService: DepartmentService) {
+ super();
+ }
+
+ protected getEntities(ids: string[]): Promise {
+ return this.departmentService.getByIds(ids);
+ }
+
+ protected getDisplayName(entity: IDepartment): string {
+ return entity.name;
+ }
+}
diff --git a/packages/calendar/src/extensions/departments/DepartmentService.ts b/packages/calendar/src/extensions/departments/DepartmentService.ts
new file mode 100644
index 0000000..4b4c8b9
--- /dev/null
+++ b/packages/calendar/src/extensions/departments/DepartmentService.ts
@@ -0,0 +1,25 @@
+import { IDepartment, EntityType, IEventBus } from '../../types/CalendarTypes';
+import { DepartmentStore } from './DepartmentStore';
+import { BaseEntityService } from '../../storage/BaseEntityService';
+import { IndexedDBContext } from '../../storage/IndexedDBContext';
+
+/**
+ * DepartmentService - CRUD operations for departments in IndexedDB
+ */
+export class DepartmentService extends BaseEntityService {
+ readonly storeName = DepartmentStore.STORE_NAME;
+ readonly entityType: EntityType = 'Department';
+
+ constructor(context: IndexedDBContext, eventBus: IEventBus) {
+ super(context, eventBus);
+ }
+
+ /**
+ * Get departments by IDs
+ */
+ async getByIds(ids: string[]): Promise {
+ if (ids.length === 0) return [];
+ const results = await Promise.all(ids.map(id => this.get(id)));
+ return results.filter((d): d is IDepartment => d !== null);
+ }
+}
diff --git a/packages/calendar/src/extensions/departments/DepartmentStore.ts b/packages/calendar/src/extensions/departments/DepartmentStore.ts
new file mode 100644
index 0000000..0e9c6a3
--- /dev/null
+++ b/packages/calendar/src/extensions/departments/DepartmentStore.ts
@@ -0,0 +1,13 @@
+import { IStore } from '../../storage/IStore';
+
+/**
+ * DepartmentStore - IndexedDB ObjectStore definition for departments
+ */
+export class DepartmentStore implements IStore {
+ static readonly STORE_NAME = 'departments';
+ readonly storeName = DepartmentStore.STORE_NAME;
+
+ create(db: IDBDatabase): void {
+ db.createObjectStore(DepartmentStore.STORE_NAME, { keyPath: 'id' });
+ }
+}
diff --git a/packages/calendar/src/extensions/departments/index.ts b/packages/calendar/src/extensions/departments/index.ts
new file mode 100644
index 0000000..d50130a
--- /dev/null
+++ b/packages/calendar/src/extensions/departments/index.ts
@@ -0,0 +1,22 @@
+export { DepartmentRenderer } from './DepartmentRenderer';
+export { DepartmentService } from './DepartmentService';
+export { DepartmentStore } from './DepartmentStore';
+export type { IDepartment } from '../../types/CalendarTypes';
+
+// DI registration helper
+import type { Builder } from '@novadi/core';
+import { IRenderer } from '../../core/IGroupingRenderer';
+import { IStore } from '../../storage/IStore';
+import { IEntityService } from '../../storage/IEntityService';
+import type { IDepartment, ISync } from '../../types/CalendarTypes';
+import { DepartmentStore } from './DepartmentStore';
+import { DepartmentService } from './DepartmentService';
+import { DepartmentRenderer } from './DepartmentRenderer';
+
+export function registerDepartments(builder: Builder): void {
+ builder.registerType(DepartmentStore).as();
+ builder.registerType(DepartmentService).as>();
+ builder.registerType(DepartmentService).as>();
+ builder.registerType(DepartmentService).as();
+ builder.registerType(DepartmentRenderer).as();
+}
diff --git a/packages/calendar/src/extensions/schedules/ResourceScheduleService.ts b/packages/calendar/src/extensions/schedules/ResourceScheduleService.ts
new file mode 100644
index 0000000..a548865
--- /dev/null
+++ b/packages/calendar/src/extensions/schedules/ResourceScheduleService.ts
@@ -0,0 +1,84 @@
+import { ITimeSlot } from '../../types/ScheduleTypes';
+import { ResourceService } from '../../storage/resources/ResourceService';
+import { ScheduleOverrideService } from './ScheduleOverrideService';
+import { DateService } from '../../core/DateService';
+
+/**
+ * ResourceScheduleService - Get effective schedule for a resource on a date
+ *
+ * Logic:
+ * 1. Check for override on this date
+ * 2. Fall back to default schedule for the weekday
+ */
+export class ResourceScheduleService {
+ constructor(
+ private resourceService: ResourceService,
+ private overrideService: ScheduleOverrideService,
+ private dateService: DateService
+ ) {}
+
+ /**
+ * Get effective schedule for a resource on a specific date
+ *
+ * @param resourceId - Resource ID
+ * @param date - Date string "YYYY-MM-DD"
+ * @returns ITimeSlot or null (fri/closed)
+ */
+ async getScheduleForDate(resourceId: string, date: string): Promise {
+ // 1. Check for override
+ const override = await this.overrideService.getOverride(resourceId, date);
+ if (override) {
+ return override.schedule;
+ }
+
+ // 2. Use default schedule for weekday
+ const resource = await this.resourceService.get(resourceId);
+ if (!resource || !resource.defaultSchedule) {
+ return null;
+ }
+
+ const weekDay = this.dateService.getISOWeekDay(date);
+ return resource.defaultSchedule[weekDay] || null;
+ }
+
+ /**
+ * Get schedules for multiple dates
+ *
+ * @param resourceId - Resource ID
+ * @param dates - Array of date strings "YYYY-MM-DD"
+ * @returns Map of date -> ITimeSlot | null
+ */
+ async getSchedulesForDates(resourceId: string, dates: string[]): Promise