Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
622 changed files with 112754 additions and 12736 deletions

View file

@ -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": []

4
.gitignore vendored
View file

@ -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/

Binary file not shown.

BIN
.workbench/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View file

@ -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?

View file

@ -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 Lopezs 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 projects 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.

View file

@ -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 = `
<swp-event-time>${formatTime(event.start)} - ${formatTime(event.end)}</swp-event-time>
<swp-event-title>${event.title}</swp-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 };

View file

@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>V2 Event Layout Engine - All Scenarios</title>
<link rel="stylesheet" href="scenario-styles.css">
<style>
.scenarios-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-top: 20px;
}
.scenario-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
}
.scenario-card h3 {
margin-top: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.calendar-column {
height: 600px;
margin-top: 10px;
}
.summary {
background: #f0f0f0;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.summary h2 {
margin-top: 0;
}
.pass-count { color: #4caf50; font-weight: bold; }
.fail-count { color: #f44336; font-weight: bold; }
.expected-info {
font-size: 12px;
color: #666;
background: #f8f9fa;
padding: 8px;
border-radius: 4px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="scenario-container" style="max-width: 1600px;">
<h1>V2 Event Layout Engine - Visual Tests</h1>
<div class="summary">
<h2>Test Summary</h2>
<p>
<span class="pass-count" id="pass-count">0</span> passed,
<span class="fail-count" id="fail-count">0</span> failed
</p>
<p>Using V2 EventLayoutEngine with gridStartThresholdMinutes: 30</p>
</div>
<div class="scenarios-grid" id="scenarios-container">
<!-- Scenarios will be rendered here -->
</div>
</div>
<script type="module">
import { renderScenario, calculateColumnLayout, gridConfig } from './v2-scenario-renderer.js';
import { ScenarioTestRunner } from './scenario-test-runner.js';
// ============================================
// Scenario Definitions
// ============================================
const scenarios = [
{
id: 'scenario-1',
title: 'Scenario 1: No Overlap',
description: 'Three sequential events with no time overlap',
events: [
{ id: 'S1A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S1B', title: 'Event B', start: new Date('2025-10-06T11:00'), end: new Date('2025-10-06T12:00') },
{ id: 'S1C', title: 'Event C', start: new Date('2025-10-06T12:00'), end: new Date('2025-10-06T13:00') }
],
expected: [
{ eventId: 'S1A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S1B', stackLevel: 0, type: 'stacked' },
{ eventId: 'S1C', stackLevel: 0, type: 'stacked' }
]
},
{
id: 'scenario-2',
title: 'Scenario 2: Column Sharing (Grid)',
description: 'Two events starting at exactly the same time',
events: [
{ id: 'S2A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S2B', title: 'Event B', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') }
],
expected: [
{ eventId: 'S2A', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S2B', stackLevel: 0, cols: 2, type: 'grid' }
]
},
{
id: 'scenario-3',
title: 'Scenario 3: Nested Stacking',
description: 'Progressive nesting: A contains B, B contains C, C overlaps D',
events: [
{ id: 'S3A', title: 'Event A', start: new Date('2025-10-06T09:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S3B', title: 'Event B', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T13:00') },
{ id: 'S3C', title: 'Event C', start: new Date('2025-10-06T11:00'), end: new Date('2025-10-06T12:00') },
{ id: 'S3D', title: 'Event D', start: new Date('2025-10-06T12:30'), end: new Date('2025-10-06T13:30') }
],
expected: [
{ eventId: 'S3A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S3B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S3C', stackLevel: 2, type: 'stacked' },
{ eventId: 'S3D', stackLevel: 2, type: 'stacked' }
]
},
{
id: 'scenario-4',
title: 'Scenario 4: Complex Stacking',
description: 'Long event A with nested B, C, D at different times',
events: [
{ id: 'S4A', title: 'Event A', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T20:00') },
{ id: 'S4B', title: 'Event B', start: new Date('2025-10-06T15:00'), end: new Date('2025-10-06T17:00') },
{ id: 'S4C', title: 'Event C', start: new Date('2025-10-06T15:30'), end: new Date('2025-10-06T16:30') },
{ id: 'S4D', title: 'Event D', start: new Date('2025-10-06T18:00'), end: new Date('2025-10-06T19:00') }
],
expected: [
{ eventId: 'S4A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S4B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S4C', stackLevel: 2, type: 'stacked' },
{ eventId: 'S4D', stackLevel: 1, type: 'stacked' }
]
},
{
id: 'scenario-5',
title: 'Scenario 5: Three Column Share',
description: 'Three events all starting at the same time',
events: [
{ id: 'S5A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S5B', title: 'Event B', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S5C', title: 'Event C', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') }
],
expected: [
{ eventId: 'S5A', stackLevel: 0, cols: 3, type: 'grid' },
{ eventId: 'S5B', stackLevel: 0, cols: 3, type: 'grid' },
{ eventId: 'S5C', stackLevel: 0, cols: 3, type: 'grid' }
]
},
{
id: 'scenario-6',
title: 'Scenario 6: Overlapping Pairs',
description: 'Two independent pairs of overlapping events',
events: [
{ id: 'S6A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T12:00') },
{ id: 'S6B', title: 'Event B', start: new Date('2025-10-06T11:00'), end: new Date('2025-10-06T12:00') },
{ id: 'S6C', title: 'Event C', start: new Date('2025-10-06T13:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S6D', title: 'Event D', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') }
],
expected: [
{ eventId: 'S6A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S6B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S6C', stackLevel: 0, type: 'stacked' },
{ eventId: 'S6D', stackLevel: 1, type: 'stacked' }
]
},
{
id: 'scenario-7',
title: 'Scenario 7: Long Event Container',
description: 'Long event containing two non-overlapping events',
events: [
{ id: 'S7A', title: 'Event A', start: new Date('2025-10-06T09:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S7B', title: 'Event B', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S7C', title: 'Event C', start: new Date('2025-10-06T12:00'), end: new Date('2025-10-06T13:00') }
],
expected: [
{ eventId: 'S7A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S7B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S7C', stackLevel: 1, type: 'stacked' }
]
},
{
id: 'scenario-8',
title: 'Scenario 8: Edge-Adjacent Events',
description: 'Events that touch but do not overlap (end === start)',
events: [
{ id: 'S8A', title: 'Event A', start: new Date('2025-10-06T10:00'), end: new Date('2025-10-06T11:00') },
{ id: 'S8B', title: 'Event B', start: new Date('2025-10-06T11:00'), end: new Date('2025-10-06T12:00') }
],
expected: [
{ eventId: 'S8A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S8B', stackLevel: 0, type: 'stacked' }
]
},
{
id: 'scenario-9',
title: 'Scenario 9: End-to-Start Chain',
description: 'Events linked by conflict chain: A->B->C',
events: [
{ id: 'S9A', title: 'Event A', start: new Date('2025-10-06T12:00'), end: new Date('2025-10-06T13:00') },
{ id: 'S9B', title: 'Event B', start: new Date('2025-10-06T12:30'), end: new Date('2025-10-06T13:30') },
{ id: 'S9C', title: 'Event C', start: new Date('2025-10-06T13:15'), end: new Date('2025-10-06T15:00') }
],
expected: [
{ eventId: 'S9A', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S9B', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S9C', stackLevel: 0, cols: 2, type: 'grid' }
]
},
{
id: 'scenario-10',
title: 'Scenario 10: Four Column Grid',
description: 'Four events all starting at the same time',
events: [
{ id: 'S10A', title: 'Event A', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S10B', title: 'Event B', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S10C', title: 'Event C', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') },
{ id: 'S10D', title: 'Event D', start: new Date('2025-10-06T14:00'), end: new Date('2025-10-06T15:00') }
],
expected: [
{ eventId: 'S10A', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10B', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10C', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10D', stackLevel: 0, cols: 4, type: 'grid' }
]
}
];
// ============================================
// Render All Scenarios
// ============================================
const container = document.getElementById('scenarios-container');
let passCount = 0;
let failCount = 0;
scenarios.forEach(scenario => {
// Create card
const card = document.createElement('div');
card.className = 'scenario-card';
card.innerHTML = `
<h3>
${scenario.title}
<span class="test-badge test-pending" id="badge-${scenario.id}">Testing...</span>
</h3>
<p>${scenario.description}</p>
<div class="expected-info">
Expected: ${scenario.expected.map(e =>
e.type === 'grid'
? `${e.eventId} (grid cols-${e.cols})`
: `${e.eventId} (level ${e.stackLevel})`
).join(', ')}
</div>
<div class="calendar-column" id="column-${scenario.id}"></div>
<div id="results-${scenario.id}"></div>
`;
container.appendChild(card);
// Render events
const columnEl = document.getElementById(`column-${scenario.id}`);
renderScenario(columnEl, scenario.events);
// Run validation
const results = ScenarioTestRunner.validateScenario(scenario.id, scenario.expected);
// Update badge
const badge = document.getElementById(`badge-${scenario.id}`);
badge.className = `test-badge ${results.passed ? 'test-passed' : 'test-failed'}`;
badge.textContent = results.passed ? 'PASSED' : 'FAILED';
if (results.passed) {
passCount++;
} else {
failCount++;
}
// Show detailed results if failed
if (!results.passed) {
const resultsEl = document.getElementById(`results-${scenario.id}`);
resultsEl.innerHTML = '<div class="test-details"><h4>Failed checks:</h4>' +
results.results
.filter(r => !r.passed)
.map(r => `<div class="test-result-line failed">${r.eventId}: ${r.message}</div>`)
.join('') +
'</div>';
}
});
// Update summary
document.getElementById('pass-count').textContent = passCount;
document.getElementById('fail-count').textContent = failCount;
</script>
</body>
</html>

564
.workbench/spec-salary.html Normal file
View file

@ -0,0 +1,564 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lønspecifikation Januar 2026 (2 sider)</title>
<style>
/* ===== Print setup ===== */
@page { size: A4; margin: 14mm; }
@media print {
.no-print { display: none !important; }
.page-break { break-after: page; page-break-after: always; }
a { color: inherit; text-decoration: none; }
}
/* ===== Base ===== */
:root{
--ink:#0f172a;
--muted:#475569;
--line:#e2e8f0;
--soft:#f8fafc;
--accent:#0ea5e9;
--accent-2:#22c55e;
}
*{ box-sizing:border-box; }
body{
margin:0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color:var(--ink);
background:#fff;
}
.sheet{
max-width: 210mm;
margin: 0 auto;
}
/* ===== Header ===== */
.hdr{
display:flex;
justify-content:space-between;
gap:16px;
padding: 14px 0 10px;
border-bottom: 2px solid var(--line);
margin-bottom: 12px;
}
.brand{
display:flex;
flex-direction:column;
gap:6px;
}
.title{
font-size: 20px;
font-weight: 800;
letter-spacing: .2px;
margin:0;
line-height:1.1;
}
.subtitle{
margin:0;
color:var(--muted);
font-size: 12px;
}
.meta{
text-align:right;
min-width: 260px;
}
.meta .pill{
display:inline-block;
font-size:12px;
padding:6px 10px;
border:1px solid var(--line);
border-radius:999px;
background:var(--soft);
margin-bottom:8px;
}
.meta .kv{
display:grid;
grid-template-columns: auto auto;
gap:4px 12px;
justify-content:end;
font-size:12px;
color:var(--muted);
}
.meta .kv b{ color:var(--ink); font-weight:600; }
/* ===== Blocks ===== */
.card{
border:1px solid var(--line);
border-radius: 14px;
overflow:hidden;
background:#fff;
margin-bottom: 12px;
}
.card .hd{
display:flex;
align-items:center;
justify-content:space-between;
padding: 10px 12px;
background: linear-gradient(0deg, var(--soft), #fff);
border-bottom: 1px solid var(--line);
}
.card .hd h2{
font-size: 13px;
margin:0;
letter-spacing:.2px;
text-transform: uppercase;
color:var(--muted);
}
.card .bd{ padding: 12px; }
.grid{
display:grid;
grid-template-columns: 1.2fr .8fr;
gap: 12px;
}
/* ===== Total ===== */
.total{
display:flex;
align-items:baseline;
justify-content:space-between;
gap:12px;
padding: 14px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: linear-gradient(135deg, rgba(14,165,233,.10), rgba(34,197,94,.08));
margin-bottom: 12px;
}
.total .label{
color:var(--muted);
font-size:12px;
text-transform:uppercase;
letter-spacing:.25px;
margin-bottom:6px;
}
.total .big{
font-size: 28px;
font-weight: 900;
margin:0;
line-height:1.05;
}
.total .big small{
font-size: 14px;
font-weight: 700;
color:var(--muted);
}
.badge{
display:inline-block;
font-size:11px;
padding:4px 8px;
border-radius:999px;
border:1px solid var(--line);
background:var(--soft);
color:var(--muted);
}
/* ===== Tables ===== */
table{
width:100%;
border-collapse: collapse;
font-size: 12px;
}
th, td{
padding: 8px 8px;
border-bottom:1px solid var(--line);
vertical-align:top;
}
th{
text-align:left;
color:var(--muted);
font-weight:700;
font-size:11px;
text-transform:uppercase;
letter-spacing:.2px;
background: var(--soft);
}
td.num, th.num{ text-align:right; }
tr:last-child td{ border-bottom:none; }
.note{
margin:8px 0 0;
color:var(--muted);
font-size:11px;
line-height:1.35;
}
/* ===== Footer ===== */
.ftr{
margin-top: 10px;
padding-top: 10px;
border-top:1px solid var(--line);
display:flex;
justify-content:space-between;
gap:12px;
font-size: 10.5px;
color:var(--muted);
}
/* ===== Ensure “side 1” content doesn't get split awkwardly ===== */
.avoid-break { break-inside: avoid; page-break-inside: avoid; }
</style>
</head>
<body>
<!-- ===================== PAGE 1: OVERBLIK ===================== -->
<div class="sheet">
<div class="hdr">
<div class="brand">
<p class="title">Lønspecifikation</p>
<p class="subtitle">Periode: <b>Januar 2026</b></p>
</div>
<div class="meta">
<div class="pill">Medarbejdernr.: <b>EMP-001</b></div>
<div class="kv">
<span>Medarbejder:</span><b>Emma Larsen</b>
<span>Afdeling:</span><b>Frisør</b>
<span>Ansættelse:</span><b>Fuldtid (37 t/uge)</b>
</div>
</div>
</div>
<section class="total avoid-break">
<div>
<div class="label">Bruttoløn (Januar 2026)</div>
<p class="big">34.063,50 <small>kr</small></p>
</div>
<div style="text-align:right">
<div><span class="badge">Side 1: Overblik</span></div>
<div style="margin-top:6px; color:var(--muted); font-size:12px; line-height:1.35">
Kort opsummering til udlevering.<br/>
Detaljer findes på side 2.
</div>
</div>
</section>
<section class="grid">
<div class="card avoid-break">
<div class="hd">
<h2>Samlet lønopgørelse</h2>
<span class="badge">Alle beløb i DKK</span>
</div>
<div class="bd">
<table>
<thead>
<tr>
<th>Løndel</th>
<th class="num">Beløb</th>
</tr>
</thead>
<tbody>
<tr>
<td>Grundløn inkl. overarbejde</td>
<td class="num">29.322,50 kr</td>
</tr>
<tr>
<td>Provision i alt</td>
<td class="num">3.685,00 kr</td>
</tr>
<tr>
<td>Tillæg i alt</td>
<td class="num">1.056,00 kr</td>
</tr>
<tr>
<td><b>Bruttoløn</b></td>
<td class="num"><b>34.063,50 kr</b></td>
</tr>
</tbody>
</table>
<p class="note">
(Hvis du senere vil have skat/AM-bidrag/nettoløn med, kan det tilføjes som ekstra blok her.)
</p>
</div>
</div>
<div class="card avoid-break">
<div class="hd">
<h2>Saldi</h2>
<span class="badge">Ved periodens slut</span>
</div>
<div class="bd">
<table>
<thead>
<tr>
<th>Type</th>
<th class="num">Optjent</th>
<th class="num">Afholdt</th>
<th class="num">Rest</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ferie (dage)</td>
<td class="num">18,5</td>
<td class="num">6,0</td>
<td class="num">12,5</td>
</tr>
<tr>
<td>Afspadsering (timer)</td>
<td class="num">12,0</td>
<td class="num">4,0</td>
<td class="num">8,0</td>
</tr>
</tbody>
</table>
<p class="note">Saldi er opgjort som angivet på lønspecifikationen.</p>
</div>
</div>
</section>
<section class="card avoid-break">
<div class="hd">
<h2>Hurtigt resumé</h2>
<span class="badge">Det vigtigste</span>
</div>
<div class="bd">
<table>
<thead>
<tr>
<th>Nøglepunkt</th>
<th class="num">Værdi</th>
</tr>
</thead>
<tbody>
<tr>
<td>Normaltimer</td>
<td class="num">148,0 t</td>
</tr>
<tr>
<td>Overarbejde</td>
<td class="num">7,0 t</td>
</tr>
<tr>
<td>Provision (services + produkter)</td>
<td class="num">3.685,00 kr</td>
</tr>
<tr>
<td>Tillæg (aften + lørdag + søndag)</td>
<td class="num">1.056,00 kr</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="ftr">
<div><b>Side 1/2</b> · Overblik</div>
<div style="text-align:right">Lønspecifikation · Januar 2026</div>
</div>
<div class="page-break"></div>
<!-- ===================== PAGE 2: DETALJER ===================== -->
<div class="hdr">
<div class="brand">
<p class="title">Lønspecifikation Detaljer</p>
<p class="subtitle">Periode: <b>Januar 2026</b> · Medarbejder: <b>Emma Larsen</b></p>
</div>
<div class="meta">
<div class="pill">Bruttoløn: <b>34.063,50 kr</b></div>
<div class="kv">
<span>Ansættelse:</span><b>Fuldtid (37 t/uge)</b>
<span>Afdeling:</span><b>Frisør</b>
<span>Medarb.nr.:</span><b>EMP-001</b>
</div>
</div>
</div>
<section class="card">
<div class="hd">
<h2>Arbejdstid pr. uge</h2>
<span class="badge">Normal + overtid</span>
</div>
<div class="bd">
<table>
<thead>
<tr>
<th>Uge</th>
<th class="num">Normaltimer</th>
<th class="num">Overtid</th>
<th class="num">Beløb</th>
</tr>
</thead>
<tbody>
<tr>
<td>Uge 1 (30. dec 5. jan)</td>
<td class="num">37,0 t</td>
<td class="num">2,0 t</td>
<td class="num">7.400,00 kr</td>
</tr>
<tr>
<td>Uge 2 (6. 12. jan)</td>
<td class="num">37,0 t</td>
<td class="num">3,5 t</td>
<td class="num">7.816,25 kr</td>
</tr>
<tr>
<td>Uge 3 (13. 19. jan)</td>
<td class="num">37,0 t</td>
<td class="num">0,0 t</td>
<td class="num">6.845,00 kr</td>
</tr>
<tr>
<td>Uge 4 (20. 26. jan)</td>
<td class="num">37,0 t</td>
<td class="num">1,5 t</td>
<td class="num">7.261,25 kr</td>
</tr>
<tr>
<td><b>I alt</b></td>
<td class="num"><b>148,0 t</b></td>
<td class="num"><b>7,0 t</b></td>
<td class="num"><b>29.322,50 kr</b></td>
</tr>
</tbody>
</table>
<p class="note">
Satser: Normal 185,00 kr/time. Overtid (50%) 277,50 kr/time.
</p>
</div>
</section>
<section class="card">
<div class="hd">
<h2>Provision</h2>
<span class="badge">Services & produkter</span>
</div>
<div class="bd">
<p class="note" style="margin-top:0">
<b>Services:</b> 15% af omsætning over minimum (220 kr/time).<br/>
<b>Produkter:</b> 10% af salg.
</p>
<table>
<thead>
<tr>
<th>Uge</th>
<th class="num">Service prov.</th>
<th class="num">Produkt prov.</th>
<th class="num">I alt</th>
</tr>
</thead>
<tbody>
<tr>
<td>Uge 1</td>
<td class="num">573,00 kr</td>
<td class="num">210,00 kr</td>
<td class="num">783,00 kr</td>
</tr>
<tr>
<td>Uge 2</td>
<td class="num">883,50 kr</td>
<td class="num">320,00 kr</td>
<td class="num">1.203,50 kr</td>
</tr>
<tr>
<td>Uge 3</td>
<td class="num">459,00 kr</td>
<td class="num">180,00 kr</td>
<td class="num">639,00 kr</td>
</tr>
<tr>
<td>Uge 4</td>
<td class="num">769,50 kr</td>
<td class="num">290,00 kr</td>
<td class="num">1.059,50 kr</td>
</tr>
<tr>
<td><b>I alt</b></td>
<td class="num"><b>2.685,00 kr</b></td>
<td class="num"><b>1.000,00 kr</b></td>
<td class="num"><b>3.685,00 kr</b></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="card">
<div class="hd">
<h2>Tillæg & fravær</h2>
<span class="badge">Opsummering</span>
</div>
<div class="bd">
<div class="grid" style="grid-template-columns: 1fr 1fr;">
<div>
<table>
<thead>
<tr>
<th>Tillæg</th>
<th class="num">Timer</th>
<th class="num">Beløb</th>
</tr>
</thead>
<tbody>
<tr>
<td>Aftentillæg (hverdage 1821)</td>
<td class="num">12,0</td>
<td class="num">336,00 kr</td>
</tr>
<tr>
<td>Lørdagstillæg (før kl. 14)</td>
<td class="num">16,0</td>
<td class="num">720,00 kr</td>
</tr>
<tr>
<td>Søndagstillæg</td>
<td class="num">0,0</td>
<td class="num">0,00 kr</td>
</tr>
<tr>
<td><b>Tillæg i alt</b></td>
<td class="num"></td>
<td class="num"><b>1.056,00 kr</b></td>
</tr>
</tbody>
</table>
</div>
<div>
<table>
<thead>
<tr>
<th>Fravær</th>
<th class="num">Dage</th>
<th class="num">Beløb</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ferie med løn</td>
<td class="num">0</td>
<td class="num">0,00 kr</td>
</tr>
<tr>
<td>Sygdom</td>
<td class="num">0</td>
<td class="num">0,00 kr</td>
</tr>
<tr>
<td>Barns sygedag</td>
<td class="num">0</td>
<td class="num">0,00 kr</td>
</tr>
</tbody>
</table>
<p class="note">Ingen fravær registreret i perioden.</p>
</div>
</div>
</div>
</section>
<div class="ftr">
<div><b>Side 2/2</b> · Detaljer</div>
<div style="text-align:right">Lønspecifikation · Januar 2026</div>
</div>
<div class="no-print" style="margin:12px 0; color:var(--muted); font-size:12px;">
Tip: I Chrome/Edge: <b>Ctrl/Cmd + P</b> → Destination: <b>Gem som PDF</b> → slå “Headers and footers” fra.
</div>
</div>
</body>
</html>

129
CLAUDE.md
View file

@ -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 -- <test-file-name>
# 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<T>** 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<T>** (`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<IEntityService<any>>` 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 -- <test-file-name>`
## 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
<CRITICAL> 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.
</CRITICAL>

View file

@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View file

@ -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");

View file

@ -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 = {};

View file

@ -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);

408
docs/V2-ARCHITECTURE.md Normal file
View file

@ -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<void> {
// 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
```

View file

@ -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 |

221
docs/design-system.md Normal file
View file

@ -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
<swp-pagination>
<swp-page-btn><i class="ph ph-caret-left"></i></swp-page-btn>
<swp-page-btn class="active">1</swp-page-btn>
<swp-page-btn>2</swp-page-btn>
<swp-page-btn>...</swp-page-btn>
<swp-page-btn><i class="ph ph-caret-right"></i></swp-page-btn>
</swp-pagination>
```
---
## Ikoner
Bruger **Phosphor Icons** via CDN:
```html
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
<i class="ph ph-magnifying-glass"></i>
<i class="ph ph-caret-right"></i>
<i class="ph ph-credit-card"></i>
```
---
## 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

View file

@ -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
<!-- Simple view (kun date) -->
<swp-day-column data-date="2025-12-09"></swp-day-column>
<!-- Resource view (resource + date) -->
<swp-day-column
data-date="2025-12-09"
data-resource-id="EMP001">
</swp-day-column>
<!-- Team view (team + resource + date) -->
<swp-day-column
data-date="2025-12-09"
data-resource-id="EMP001"
data-team-id="team-1">
</swp-day-column>
```
### 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 |

180
docs/filter-template.md Normal file
View file

@ -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)

279
package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

620
packages/calendar/README.md Normal file
View file

@ -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
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="node_modules/calendar/dist/css/calendar.css">
</head>
<body>
<div class="calendar-wrapper">
<swp-calendar-container>
<swp-time-axis>
<swp-header-spacer></swp-header-spacer>
<swp-time-axis-content id="time-axis"></swp-time-axis-content>
</swp-time-axis>
<swp-grid-container>
<swp-header-viewport>
<swp-header-track>
<swp-calendar-header></swp-calendar-header>
</swp-header-track>
<swp-header-drawer></swp-header-drawer>
</swp-header-viewport>
<swp-content-viewport>
<swp-content-track>
<swp-scrollable-content>
<swp-time-grid>
<swp-grid-lines></swp-grid-lines>
<swp-day-columns></swp-day-columns>
</swp-time-grid>
</swp-scrollable-content>
</swp-content-track>
</swp-content-viewport>
</swp-grid-container>
</swp-calendar-container>
</div>
<script type="module" src="dist/bundle.js"></script>
</body>
</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<IndexedDBContext>();
await dbContext.initialize();
// 3. Seed required settings (first time only)
const settingsService = app.resolveType<SettingsService>();
const viewConfigService = app.resolveType<ViewConfigService>();
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<CalendarApp>();
const containerEl = document.querySelector('swp-calendar-container') as HTMLElement;
await calendarApp.init(containerEl);
// 5. Render a view
const eventBus = app.resolveType<EventBus>();
eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' });
}
init().catch(console.error);
```
### Step 3: Add Events
```typescript
const eventService = app.resolveType<EventService>();
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<EventBus>();
// 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<string, unknown>;
}
// 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<EventBus>();
// 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

View file

@ -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();

167
packages/calendar/package-lock.json generated Normal file
View file

@ -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
}
}
}

View file

@ -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"
}
}

View file

@ -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<ITimeFormatConfig>();
builder.registerInstance(gridConfig).as<IGridConfig>();
builder.registerInstance(dbConfig).as<IDBConfig>();
// Core - EventBus (singleton pattern via dual registration)
builder.registerType(EventBus).as<EventBus>();
builder.registerType(EventBus).as<IEventBus>();
// Core Services
builder.registerType(DateService).as<DateService>();
// Storage infrastructure
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
// Core Stores (for IndexedDB schema creation via IStore[] array injection)
builder.registerType(EventStore).as<IStore>();
builder.registerType(ResourceStore).as<IStore>();
builder.registerType(SettingsStore).as<IStore>();
builder.registerType(ViewConfigStore).as<IStore>();
// Core Entity Services (polymorphic via IEntityService<T>)
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
builder.registerType(EventService).as<IEntityService<ISync>>();
builder.registerType(EventService).as<EventService>();
builder.registerType(ResourceService).as<IEntityService<IResource>>();
builder.registerType(ResourceService).as<IEntityService<ISync>>();
builder.registerType(ResourceService).as<ResourceService>();
builder.registerType(SettingsService).as<IEntityService<TenantSetting>>();
builder.registerType(SettingsService).as<IEntityService<ISync>>();
builder.registerType(SettingsService).as<SettingsService>();
builder.registerType(ViewConfigService).as<IEntityService<ViewConfig>>();
builder.registerType(ViewConfigService).as<IEntityService<ISync>>();
builder.registerType(ViewConfigService).as<ViewConfigService>();
// Core Renderers
builder.registerType(EventRenderer).as<EventRenderer>();
builder.registerType(ScheduleRenderer).as<ScheduleRenderer>();
builder.registerType(HeaderDrawerRenderer).as<HeaderDrawerRenderer>();
builder.registerType(TimeAxisRenderer).as<TimeAxisRenderer>();
// Grouping Renderers (registered as IRenderer[] for CalendarOrchestrator)
builder.registerType(DateRenderer).as<IRenderer>();
builder.registerType(ResourceRenderer).as<IRenderer>();
// Core Managers
builder.registerType(ScrollManager).as<ScrollManager>();
builder.registerType(HeaderDrawerManager).as<HeaderDrawerManager>();
builder.registerType(DragDropManager).as<DragDropManager>();
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
builder.registerType(ResizeManager).as<ResizeManager>();
builder.registerType(EventPersistenceManager).as<EventPersistenceManager>();
// Orchestrator and App
builder.registerType(CalendarOrchestrator).as<CalendarOrchestrator>();
builder.registerType(CalendarApp).as<CalendarApp>();
}

View file

@ -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;

View file

@ -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<T extends IGroupingEntity> implements IRenderer {
abstract readonly type: string;
protected abstract readonly config: IGroupingRendererConfig;
/**
* Fetch entities from service
*/
protected abstract getEntities(ids: string[]): Promise<T[]>;
/**
* Get display name for entity
*/
protected abstract getDisplayName(entity: T): string;
/**
* Main render method - handles common logic
*/
async render(context: IRenderContext): Promise<void> {
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;
}
}

View file

@ -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<string, string[]> = 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<void> {
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<RenderPayload>).detail;
this.handleRenderCommand(viewId);
});
// Workweek change via EventBus
this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, (e: Event) => {
const { presetId } = (e as CustomEvent<WorkweekChangePayload>).detail;
this.handleWorkweekChange(presetId);
});
// View update via EventBus
this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, (e: Event) => {
const { type, values } = (e as CustomEvent<ViewUpdatePayload>).detail;
this.handleViewUpdate(type, values);
});
}
private async handleRenderCommand(viewId: string): Promise<void> {
this.currentViewId = viewId;
await this.render();
this.emitStatus('rendered', { viewId });
}
private async handleNavigatePrev(): Promise<void> {
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<void> {
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<void> {
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<void> {
this.groupingOverrides.set(type, values);
await this.render();
this.emitStatus('rendered', { viewId: this.currentViewId });
}
private async render(): Promise<void> {
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
}));
}
}

View file

@ -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[];
}

View file

@ -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<ISync>[]
) {}
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
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<string, string[]> = {};
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<string, string[]>
): Promise<{ parentChildMap?: Record<string, string[]>; 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<string, unknown>).id as string)
);
// Byg parent-child map
const map: Record<string, string[]> = {};
for (const entity of entities) {
const entityRecord = entity as unknown as Record<string, unknown>;
const children = (entityRecord[property] as string[]) || [];
map[entityRecord.id as string] = children;
}
return { parentChildMap: map, childType: childGrouping.type };
}
}

View file

@ -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, string>): 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
}
}

View file

@ -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<string, Map<string, Record<string, unknown>>> = 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<T extends { id: string }>(entityType: string, entities: T[]): void {
const typeCache = new Map<string, Record<string, unknown>>();
for (const entity of entities) {
// Cast to Record for storage while preserving original data
typeCache.set(entity.id, entity as unknown as Record<string, unknown>);
}
this.cache.set(entityType, typeCache);
}
/**
* Resolve an entity by type and ID
*/
resolve(entityType: string, id: string): Record<string, unknown> | 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);
}
}

View file

@ -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<IListenerEntry> = 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;
}
}

View file

@ -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 .
*
* @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 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<string, unknown>, 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);
}
}

View file

@ -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;
}
}

View file

@ -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<string, unknown> | undefined;
}

View file

@ -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)
}

View file

@ -0,0 +1,15 @@
import { GroupingConfig } from './ViewConfig';
export interface IRenderContext {
headerContainer: HTMLElement;
columnContainer: HTMLElement;
filter: Record<string, string[]>; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] }
groupings?: GroupingConfig[]; // Full grouping configs (for hideHeader etc.)
parentChildMap?: Record<string, string[]>; // { 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<void>;
}

View file

@ -0,0 +1,4 @@
export interface IGroupingStore<T = unknown> {
readonly type: string;
getByIds(ids: string[]): T[];
}

View file

@ -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;
}

View file

@ -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<void>): Promise<void> {
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<void> {
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<void> {
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);
}
}

View file

@ -0,0 +1,15 @@
import { IRenderer, IRenderContext } from './IGroupingRenderer';
export interface Pipeline {
run(context: IRenderContext): Promise<void>;
}
export function buildPipeline(renderers: IRenderer[]): Pipeline {
return {
async run(context: IRenderContext) {
for (const renderer of renderers) {
await renderer.render(context);
}
}
};
}

View file

@ -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)`;
}
}

View file

@ -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)
}

View file

@ -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<IAuditEntry> {
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<void> {
// 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<void> {
// 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<void> {
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<void> {
throw new Error('Audit entries cannot be deleted (compliance requirement)');
}
/**
* Get pending audit entries (for sync)
*/
async getPendingAudits(): Promise<IAuditEntry[]> {
return this.getBySyncStatus('pending');
}
/**
* Get audit entries for a specific entity
*/
async getByEntityId(entityId: string): Promise<IAuditEntry[]> {
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}`));
};
});
}
}

View file

@ -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 });
}
}

View file

@ -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<IStore>();
builder.registerType(AuditService).as<AuditService>();
}

View file

@ -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<IBooking> {
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<string, unknown>;
return {
...raw,
createdAt: new Date(raw.createdAt as string)
} as IBooking;
}
/**
* Get bookings for a customer
*/
async getByCustomer(customerId: string): Promise<IBooking[]> {
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<IBooking[]> {
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}`));
};
});
}
}

View file

@ -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 });
}
}

View file

@ -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<IStore>();
builder.registerType(BookingService).as<IEntityService<IBooking>>();
builder.registerType(BookingService).as<IEntityService<ISync>>();
builder.registerType(BookingService).as<BookingService>();
}

View file

@ -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<ICustomer> {
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<ICustomer[]> {
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<ICustomer | null> {
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}`));
};
});
}
}

View file

@ -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 });
}
}

View file

@ -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<IStore>();
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
builder.registerType(CustomerService).as<IEntityService<ISync>>();
builder.registerType(CustomerService).as<CustomerService>();
}

View file

@ -0,0 +1,25 @@
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { DepartmentService } from './DepartmentService';
import { IDepartment } from '../../types/CalendarTypes';
export class DepartmentRenderer extends BaseGroupingRenderer<IDepartment> {
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<IDepartment[]> {
return this.departmentService.getByIds(ids);
}
protected getDisplayName(entity: IDepartment): string {
return entity.name;
}
}

View file

@ -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<IDepartment> {
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<IDepartment[]> {
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);
}
}

View file

@ -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' });
}
}

View file

@ -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<IStore>();
builder.registerType(DepartmentService).as<IEntityService<IDepartment>>();
builder.registerType(DepartmentService).as<IEntityService<ISync>>();
builder.registerType(DepartmentService).as<DepartmentService>();
builder.registerType(DepartmentRenderer).as<IRenderer>();
}

View file

@ -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<ITimeSlot | null> {
// 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<Map<string, ITimeSlot | null>> {
const result = new Map<string, ITimeSlot | null>();
// Get resource once
const resource = await this.resourceService.get(resourceId);
// Get all overrides in date range
const overrides = dates.length > 0
? await this.overrideService.getByDateRange(resourceId, dates[0], dates[dates.length - 1])
: [];
// Build override map
const overrideMap = new Map(overrides.map(o => [o.date, o.schedule]));
// Resolve each date
for (const date of dates) {
// Check override first
if (overrideMap.has(date)) {
result.set(date, overrideMap.get(date)!);
continue;
}
// Fall back to default
if (resource?.defaultSchedule) {
const weekDay = this.dateService.getISOWeekDay(date);
result.set(date, resource.defaultSchedule[weekDay] || null);
} else {
result.set(date, null);
}
}
return result;
}
}

View file

@ -0,0 +1,100 @@
import { IScheduleOverride } from '../../types/ScheduleTypes';
import { IndexedDBContext } from '../../storage/IndexedDBContext';
import { ScheduleOverrideStore } from './ScheduleOverrideStore';
/**
* ScheduleOverrideService - CRUD for schedule overrides
*
* Provides access to date-specific schedule overrides for resources.
*/
export class ScheduleOverrideService {
private context: IndexedDBContext;
constructor(context: IndexedDBContext) {
this.context = context;
}
private get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* Get override for a specific resource and date
*/
async getOverride(resourceId: string, date: string): Promise<IScheduleOverride | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const index = store.index('resourceId_date');
const request = index.get([resourceId, date]);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error(`Failed to get override for ${resourceId} on ${date}: ${request.error}`));
};
});
}
/**
* Get all overrides for a resource
*/
async getByResource(resourceId: string): Promise<IScheduleOverride[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const index = store.index('resourceId');
const request = index.getAll(resourceId);
request.onsuccess = () => {
resolve(request.result || []);
};
request.onerror = () => {
reject(new Error(`Failed to get overrides for ${resourceId}: ${request.error}`));
};
});
}
/**
* Get overrides for a date range
*/
async getByDateRange(resourceId: string, startDate: string, endDate: string): Promise<IScheduleOverride[]> {
const all = await this.getByResource(resourceId);
return all.filter(o => o.date >= startDate && o.date <= endDate);
}
/**
* Save an override
*/
async save(override: IScheduleOverride): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const request = store.put(override);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(new Error(`Failed to save override ${override.id}: ${request.error}`));
};
});
}
/**
* Delete an override
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(new Error(`Failed to delete override ${id}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,21 @@
import { IStore } from '../../storage/IStore';
/**
* ScheduleOverrideStore - IndexedDB ObjectStore for schedule overrides
*
* Stores date-specific schedule overrides for resources.
* Indexes: resourceId, date, compound (resourceId + date)
*/
export class ScheduleOverrideStore implements IStore {
static readonly STORE_NAME = 'scheduleOverrides';
readonly storeName = ScheduleOverrideStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(ScheduleOverrideStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('resourceId', 'resourceId', { unique: false });
store.createIndex('date', 'date', { unique: false });
store.createIndex('resourceId_date', ['resourceId', 'date'], { unique: true });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -0,0 +1,17 @@
export { ScheduleOverrideService } from './ScheduleOverrideService';
export { ScheduleOverrideStore } from './ScheduleOverrideStore';
export { ResourceScheduleService } from './ResourceScheduleService';
export type { IScheduleOverride, ITimeSlot, IWeekSchedule, WeekDay } from '../../types/ScheduleTypes';
// DI registration helper
import type { Builder } from '@novadi/core';
import { IStore } from '../../storage/IStore';
import { ScheduleOverrideStore } from './ScheduleOverrideStore';
import { ScheduleOverrideService } from './ScheduleOverrideService';
import { ResourceScheduleService } from './ResourceScheduleService';
export function registerSchedules(builder: Builder): void {
builder.registerType(ScheduleOverrideStore).as<IStore>();
builder.registerType(ScheduleOverrideService).as<ScheduleOverrideService>();
builder.registerType(ResourceScheduleService).as<ResourceScheduleService>();
}

View file

@ -0,0 +1,25 @@
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { TeamService } from './TeamService';
import { ITeam } from '../../types/CalendarTypes';
export class TeamRenderer extends BaseGroupingRenderer<ITeam> {
readonly type = 'team';
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-team-header',
idAttribute: 'teamId',
colspanVar: '--team-cols'
};
constructor(private teamService: TeamService) {
super();
}
protected getEntities(ids: string[]): Promise<ITeam[]> {
return this.teamService.getByIds(ids);
}
protected getDisplayName(entity: ITeam): string {
return entity.name;
}
}

View file

@ -0,0 +1,44 @@
import { ITeam, EntityType, IEventBus } from '../../types/CalendarTypes';
import { TeamStore } from './TeamStore';
import { BaseEntityService } from '../../storage/BaseEntityService';
import { IndexedDBContext } from '../../storage/IndexedDBContext';
/**
* TeamService - CRUD operations for teams in IndexedDB
*
* Teams define which resources belong together for hierarchical grouping.
* Extends BaseEntityService for standard entity operations.
*/
export class TeamService extends BaseEntityService<ITeam> {
readonly storeName = TeamStore.STORE_NAME;
readonly entityType: EntityType = 'Team';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get teams by IDs
*/
async getByIds(ids: string[]): Promise<ITeam[]> {
if (ids.length === 0) return [];
const results = await Promise.all(ids.map(id => this.get(id)));
return results.filter((t): t is ITeam => t !== null);
}
/**
* Build reverse lookup: resourceId teamId
*/
async buildResourceToTeamMap(): Promise<Record<string, string>> {
const teams = await this.getAll();
const map: Record<string, string> = {};
for (const team of teams) {
for (const resourceId of team.resourceIds) {
map[resourceId] = team.id;
}
}
return map;
}
}

View file

@ -0,0 +1,13 @@
import { IStore } from '../../storage/IStore';
/**
* TeamStore - IndexedDB ObjectStore definition for teams
*/
export class TeamStore implements IStore {
static readonly STORE_NAME = 'teams';
readonly storeName = TeamStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(TeamStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -0,0 +1,22 @@
export { TeamRenderer } from './TeamRenderer';
export { TeamService } from './TeamService';
export { TeamStore } from './TeamStore';
export type { ITeam } 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 { ITeam, ISync } from '../../types/CalendarTypes';
import { TeamStore } from './TeamStore';
import { TeamService } from './TeamService';
import { TeamRenderer } from './TeamRenderer';
export function registerTeams(builder: Builder): void {
builder.registerType(TeamStore).as<IStore>();
builder.registerType(TeamService).as<IEntityService<ITeam>>();
builder.registerType(TeamService).as<IEntityService<ISync>>();
builder.registerType(TeamService).as<TeamService>();
builder.registerType(TeamRenderer).as<IRenderer>();
}

View file

@ -0,0 +1,68 @@
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
import { DateService } from '../../core/DateService';
export class DateRenderer implements IRenderer {
readonly type = 'date';
constructor(private dateService: DateService) {}
render(context: IRenderContext): void {
const dates = context.filter['date'] || [];
const resourceIds = context.filter['resource'] || [];
// Check if date headers should be hidden (e.g., in day view)
const dateGrouping = context.groupings?.find(g => g.type === 'date');
const hideHeader = dateGrouping?.hideHeader === true;
// Render dates for HVER resource (eller 1 gang hvis ingen resources)
const iterations = resourceIds.length || 1;
let columnCount = 0;
for (let r = 0; r < iterations; r++) {
const resourceId = resourceIds[r]; // undefined hvis ingen resources
for (const dateStr of dates) {
const date = this.dateService.parseISO(dateStr);
// Build columnKey for uniform identification
const segments: Record<string, string> = { date: dateStr };
if (resourceId) segments.resource = resourceId;
const columnKey = this.dateService.buildColumnKey(segments);
// Header
const header = document.createElement('swp-day-header');
header.dataset.date = dateStr;
header.dataset.columnKey = columnKey;
if (resourceId) {
header.dataset.resourceId = resourceId;
}
if (hideHeader) {
header.dataset.hidden = 'true';
}
header.innerHTML = `
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
`;
context.headerContainer.appendChild(header);
// Column
const column = document.createElement('swp-day-column');
column.dataset.date = dateStr;
column.dataset.columnKey = columnKey;
if (resourceId) {
column.dataset.resourceId = resourceId;
}
column.innerHTML = '<swp-events-layer></swp-events-layer>';
context.columnContainer.appendChild(column);
columnCount++;
}
}
// Set grid columns on container
const container = context.columnContainer.closest('swp-calendar-container');
if (container) {
(container as HTMLElement).style.setProperty('--grid-columns', String(columnCount));
}
}
}

View file

@ -0,0 +1 @@
export { DateRenderer } from './DateRenderer';

View file

@ -0,0 +1,279 @@
/**
* EventLayoutEngine - Simplified stacking/grouping algorithm
*
* Supports two layout modes:
* - GRID: Events starting at same time rendered side-by-side
* - STACKING: Overlapping events with margin-left offset (15px per level)
*
* No prev/next chains, single-pass greedy algorithm
*/
import { ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition } from '../../utils/PositionUtils';
import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes';
/**
* Check if two events overlap (strict - touching at boundary = NOT overlapping)
* This matches Scenario 8: end===start is NOT overlap
*/
export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean {
return a.start < b.end && a.end > b.start;
}
/**
* Check if two events are within threshold for grid grouping.
* This includes:
* 1. Start-to-start: Events start within threshold of each other
* 2. End-to-start: One event starts within threshold before another ends
*/
function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean {
const thresholdMs = thresholdMinutes * 60 * 1000;
// Start-to-start: both events start within threshold
const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
if (startToStartDiff <= thresholdMs) return true;
// End-to-start: one event starts within threshold before the other ends
// B starts within threshold before A ends
const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
// A starts within threshold before B ends
const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
return false;
}
/**
* Check if all events in a group start within threshold of each other
*/
function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean {
if (events.length <= 1) return true;
// Find earliest and latest start times
let earliest = events[0].start.getTime();
let latest = events[0].start.getTime();
for (const event of events) {
const time = event.start.getTime();
if (time < earliest) earliest = time;
if (time > latest) latest = time;
}
const diffMinutes = (latest - earliest) / (1000 * 60);
return diffMinutes <= thresholdMinutes;
}
/**
* Find groups of overlapping events (connected by overlap chain)
* Events are grouped if they overlap with any event in the group
*/
function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
// Start a new group with this event
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand group by finding all connected events (via overlap)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
// Check if candidate overlaps with any event in group
const connects = group.some(member => eventsOverlap(member, candidate));
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Find grid candidates within a group - events connected via threshold chain
* Uses V1 logic: events are connected if within threshold (no overlap requirement)
*/
function findGridCandidates(
events: ICalendarEvent[],
thresholdMinutes: number
): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand by threshold chain (V1 logic: no overlap requirement, just threshold)
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;
}
/**
* Calculate stack levels for overlapping events using greedy algorithm
* For each event: level = max(overlapping already-processed events) + 1
*/
function calculateStackLevels(events: ICalendarEvent[]): Map<string, number> {
const levels = new Map<string, number>();
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
for (const event of sorted) {
let maxOverlappingLevel = -1;
// Find max level among overlapping events already processed
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;
}
/**
* Allocate events to columns for GRID layout using greedy algorithm
* Non-overlapping events can share a column to minimize total columns
*/
function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const columns: ICalendarEvent[][] = [];
for (const event of sorted) {
// Find first column where event doesn't overlap with existing events
let placed = false;
for (const column of columns) {
const canFit = !column.some(e => eventsOverlap(event, e));
if (canFit) {
column.push(event);
placed = true;
break;
}
}
// No suitable column found, create new one
if (!placed) {
columns.push([event]);
}
}
return columns;
}
/**
* Main entry point: Calculate complete layout for a column's events
*
* Algorithm:
* 1. Find overlap groups (events connected by overlap chain)
* 2. For each overlap group, find grid candidates (events within threshold chain)
* 3. If all events in overlap group form a single grid candidate GRID mode
* 4. Otherwise STACKING mode with calculated levels
*/
export function calculateColumnLayout(
events: ICalendarEvent[],
config: IGridConfig
): IColumnLayout {
const thresholdMinutes = config.gridStartThresholdMinutes ?? 10;
const result: IColumnLayout = {
grids: [],
stacked: []
};
if (events.length === 0) return result;
// Find all overlapping event groups
const overlapGroups = findOverlapGroups(events);
for (const overlapGroup of overlapGroups) {
if (overlapGroup.length === 1) {
// Single event - no grouping needed
result.stacked.push({
event: overlapGroup[0],
stackLevel: 0
});
continue;
}
// Within this overlap group, find grid candidates (threshold-connected subgroups)
const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
// Check if the ENTIRE overlap group forms a single grid candidate
// This happens when all events are connected via threshold chain
const largestGridCandidate = gridSubgroups.reduce((max, g) =>
g.length > max.length ? g : max, gridSubgroups[0]);
if (largestGridCandidate.length === overlapGroup.length) {
// All events in overlap group are connected via threshold chain → GRID mode
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 {
// Not all events connected via threshold → STACKING mode
const levels = calculateStackLevels(overlapGroup);
for (const event of overlapGroup) {
result.stacked.push({
event,
stackLevel: levels.get(event.id) ?? 0
});
}
}
}
return result;
}

View file

@ -0,0 +1,35 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* Stack link metadata stored on event elements
* Simplified from V1: No prev/next chains - only stackLevel needed for rendering
*/
export interface IStackLink {
stackLevel: number;
}
/**
* Layout result for a stacked event (overlapping events with margin offset)
*/
export interface IStackedEventLayout {
event: ICalendarEvent;
stackLevel: number;
}
/**
* Layout result for a grid group (simultaneous events side-by-side)
*/
export interface IGridGroupLayout {
events: ICalendarEvent[];
columns: ICalendarEvent[][]; // Events grouped by column (non-overlapping within column)
stackLevel: number; // Stack level for entire group (if nested in another event)
position: { top: number }; // Top position of earliest event in pixels
}
/**
* Complete layout result for a column's events
*/
export interface IColumnLayout {
grids: IGridGroupLayout[];
stacked: IStackedEventLayout[];
}

View file

@ -0,0 +1,434 @@
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
import { CoreEvents } from '../../constants/CoreEvents';
import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload, IDragLeaveHeaderPayload } from '../../types/DragTypes';
import { calculateColumnLayout } from './EventLayoutEngine';
import { IGridGroupLayout } from './EventLayoutTypes';
import { FilterTemplate } from '../../core/FilterTemplate';
/**
* EventRenderer - Renders calendar events to the DOM
*
* CLEAN approach:
* - Only data-id attribute on event element
* - innerHTML contains only visible content
* - Event data retrieved via EventService when needed
*/
export class EventRenderer {
private container: HTMLElement | null = null;
constructor(
private eventService: EventService,
private dateService: DateService,
private gridConfig: IGridConfig,
private eventBus: IEventBus
) {
this.setupListeners();
}
/**
* Setup listeners for drag-drop and update events
*/
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => {
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
this.handleColumnChange(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE, (e) => {
const payload = (e as CustomEvent<IDragMovePayload>).detail;
this.updateDragTimestamp(payload);
});
this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => {
const payload = (e as CustomEvent<IEventUpdatedPayload>).detail;
this.handleEventUpdated(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
this.handleDragEnd(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
this.handleDragLeaveHeader(payload);
});
}
/**
* Handle EVENT_DRAG_END - remove element if dropped in header
*/
private handleDragEnd(payload: IDragEndPayload): void {
if (payload.target === 'header') {
// Event was dropped in header drawer - remove from grid
const element = this.container?.querySelector(`swp-content-viewport swp-event[data-event-id="${payload.swpEvent.eventId}"]`);
element?.remove();
}
}
/**
* Handle header item leaving header - create swp-event in grid
*/
private handleDragLeaveHeader(payload: IDragLeaveHeaderPayload): void {
// Only handle when source is header (header item dragged to grid)
if (payload.source !== 'header') return;
if (!payload.targetColumn || !payload.start || !payload.end) return;
// Turn header item into ghost (stays visible but faded)
if (payload.element) {
payload.element.classList.add('drag-ghost');
payload.element.style.opacity = '0.3';
payload.element.style.pointerEvents = 'none';
}
// Create event object from header item data
const event: ICalendarEvent = {
id: payload.eventId,
title: payload.title || '',
description: '',
start: payload.start,
end: payload.end,
type: 'customer',
allDay: false,
syncStatus: 'pending'
};
// Create swp-event element using existing method
const element = this.createEventElement(event);
// Add to target column
let eventsLayer = payload.targetColumn.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
payload.targetColumn.appendChild(eventsLayer);
}
eventsLayer.appendChild(element);
// Mark as dragging so DragDropManager can continue with it
element.classList.add('dragging');
}
/**
* Handle EVENT_UPDATED - re-render affected columns
*/
private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> {
// Re-render source column (if different from target)
if (payload.sourceColumnKey !== payload.targetColumnKey) {
await this.rerenderColumn(payload.sourceColumnKey);
}
// Re-render target column
await this.rerenderColumn(payload.targetColumnKey);
}
/**
* Re-render a single column with fresh data from IndexedDB
*/
private async rerenderColumn(columnKey: string): Promise<void> {
const column = this.findColumn(columnKey);
if (!column) return;
// Read date and resourceId directly from column attributes (columnKey is opaque)
const date = column.dataset.date;
const resourceId = column.dataset.resourceId;
if (!date) return;
// Get date range for this day
const startDate = new Date(date);
const endDate = new Date(date);
endDate.setHours(23, 59, 59, 999);
// Fetch events from IndexedDB
const events = resourceId
? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate)
: await this.eventService.getByDateRange(startDate, endDate);
// Filter to timed events and match date exactly
const timedEvents = events.filter(event =>
!event.allDay && this.dateService.getDateKey(event.start) === date
);
// Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
}
// Clear existing events
eventsLayer.innerHTML = '';
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
}
/**
* Find a column element by columnKey
*/
private findColumn(columnKey: string): HTMLElement | null {
if (!this.container) return null;
return this.container.querySelector(`swp-day-column[data-column-key="${columnKey}"]`) as HTMLElement;
}
/**
* Handle event moving to a new column during drag
*/
private handleColumnChange(payload: IDragColumnChangePayload): void {
const eventsLayer = payload.newColumn.querySelector('swp-events-layer');
if (!eventsLayer) return;
// Move element to new column
eventsLayer.appendChild(payload.element);
// Preserve Y position
payload.element.style.top = `${payload.currentY}px`;
}
/**
* Update timestamp display during drag (snapped to grid)
*/
private updateDragTimestamp(payload: IDragMovePayload): void {
const timeEl = payload.element.querySelector('swp-event-time');
if (!timeEl) return;
// Snap position to grid interval
const snappedY = snapToGrid(payload.currentY, this.gridConfig);
// Calculate new start time
const minutesFromGridStart = pixelsToMinutes(snappedY, this.gridConfig);
const startMinutes = (this.gridConfig.dayStartHour * 60) + minutesFromGridStart;
// Keep original duration (from element height)
const height = parseFloat(payload.element.style.height) || this.gridConfig.hourHeight;
const durationMinutes = pixelsToMinutes(height, this.gridConfig);
// Create Date objects for consistent formatting via DateService
const start = this.minutesToDate(startMinutes);
const end = this.minutesToDate(startMinutes + durationMinutes);
timeEl.textContent = this.dateService.formatTimeRange(start, end);
}
/**
* Convert minutes since midnight to a Date object (today)
*/
private minutesToDate(minutes: number): Date {
const date = new Date();
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
return date;
}
/**
* Render events for visible dates into day columns
* @param container - Calendar container element
* @param filter - Filter with 'date' and optionally 'resource' arrays
* @param filterTemplate - Template for matching events to columns
*/
async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store container reference for later re-renders
this.container = container;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
// Get date range for query
const startDate = new Date(visibleDates[0]);
const endDate = new Date(visibleDates[visibleDates.length - 1]);
endDate.setHours(23, 59, 59, 999);
// Fetch events from IndexedDB
const events = await this.eventService.getByDateRange(startDate, endDate);
// Find day columns
const dayColumns = container.querySelector('swp-day-columns');
if (!dayColumns) return;
const columns = dayColumns.querySelectorAll('swp-day-column');
// Render events into each column based on FilterTemplate matching
columns.forEach(column => {
const columnEl = column as HTMLElement;
// Use FilterTemplate for matching - only fields in template are checked
const columnEvents = events.filter(event => filterTemplate.matches(event, columnEl));
// Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
}
// Clear existing events
eventsLayer.innerHTML = '';
// Filter to timed events only
const timedEvents = columnEvents.filter(event => !event.allDay);
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups (simultaneous events side-by-side)
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events (overlapping with margin offset)
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
});
}
/**
* Create a single event element
*
* CLEAN approach:
* - Only data-id for lookup
* - Visible content in innerHTML only
*/
private createEventElement(event: ICalendarEvent): HTMLElement {
const element = document.createElement('swp-event');
// Data attributes for SwpEvent compatibility
element.dataset.eventId = event.id;
if (event.resourceId) {
element.dataset.resourceId = event.resourceId;
}
// Calculate position
const position = calculateEventPosition(event.start, event.end, this.gridConfig);
element.style.top = `${position.top}px`;
element.style.height = `${position.height}px`;
// Color class based on event type
const colorClass = this.getColorClass(event);
if (colorClass) {
element.classList.add(colorClass);
}
// Visible content only
element.innerHTML = `
<swp-event-time>${this.dateService.formatTimeRange(event.start, event.end)}</swp-event-time>
<swp-event-title>${this.escapeHtml(event.title)}</swp-event-title>
${event.description ? `<swp-event-description>${this.escapeHtml(event.description)}</swp-event-description>` : ''}
`;
return element;
}
/**
* Get color class based on metadata.color or event type
*/
private getColorClass(event: ICalendarEvent): string {
// Check metadata.color first
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
// Fallback to type-based color
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
'break': 'is-amber',
'meeting': 'is-purple',
'blocked': 'is-red'
};
return typeColors[event.type] || 'is-blue';
}
/**
* Escape HTML to prevent XSS
*/
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Render a GRID group with side-by-side columns
* Used when multiple events start at the same time
*/
private renderGridGroup(layout: IGridGroupLayout): HTMLElement {
const group = document.createElement('swp-event-group');
group.classList.add(`cols-${layout.columns.length}`);
group.style.top = `${layout.position.top}px`;
// Stack level styling for entire group (if nested in another event)
if (layout.stackLevel > 0) {
group.style.marginLeft = `${layout.stackLevel * 15}px`;
group.style.zIndex = `${100 + layout.stackLevel}`;
}
// Calculate the height needed for the group (tallest event)
let maxBottom = 0;
for (const event of layout.events) {
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
const eventBottom = pos.top + pos.height;
if (eventBottom > maxBottom) maxBottom = eventBottom;
}
const groupHeight = maxBottom - layout.position.top;
group.style.height = `${groupHeight}px`;
// Create wrapper div for each column
layout.columns.forEach(columnEvents => {
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
columnEvents.forEach(event => {
const eventEl = this.createEventElement(event);
// Position relative to group top
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
eventEl.style.top = `${pos.top - layout.position.top}px`;
eventEl.style.position = 'absolute';
eventEl.style.left = '0';
eventEl.style.right = '0';
wrapper.appendChild(eventEl);
});
group.appendChild(wrapper);
});
return group;
}
/**
* Render a STACKED event with margin-left offset
* Used for overlapping events that don't start at the same time
*/
private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement {
const element = this.createEventElement(event);
// Add stack metadata for drag-drop and other features
element.dataset.stackLink = JSON.stringify({ stackLevel });
// Visual styling based on stack level
if (stackLevel > 0) {
element.style.marginLeft = `${stackLevel * 15}px`;
element.style.zIndex = `${100 + stackLevel}`;
}
return element;
}
}

View file

@ -0,0 +1 @@
export { EventRenderer } from './EventRenderer';

View file

@ -0,0 +1,135 @@
/**
* HeaderDrawerLayoutEngine - Calculates row placement for header items
*
* Prevents visual overlap by assigning items to different rows when
* they occupy the same columns. Uses a track-based algorithm similar
* to V1's AllDayLayoutEngine.
*
* Each row can hold multiple items as long as they don't overlap in columns.
* When an item spans columns that are already occupied, it's placed in the
* next available row.
*/
export interface IHeaderItemLayout {
itemId: string;
gridArea: string; // "row / col-start / row+1 / col-end"
startColumn: number;
endColumn: number;
row: number;
}
export interface IHeaderItemInput {
id: string;
columnStart: number; // 0-based column index
columnEnd: number; // 0-based end column (inclusive)
}
export class HeaderDrawerLayoutEngine {
private tracks: boolean[][] = [];
private columnCount: number;
constructor(columnCount: number) {
this.columnCount = columnCount;
this.reset();
}
/**
* Reset tracks for new layout calculation
*/
reset(): void {
this.tracks = [new Array(this.columnCount).fill(false)];
}
/**
* Calculate layout for all items
* Items should be sorted by start column for optimal packing
*/
calculateLayout(items: IHeaderItemInput[]): IHeaderItemLayout[] {
this.reset();
const layouts: IHeaderItemLayout[] = [];
for (const item of items) {
const row = this.findAvailableRow(item.columnStart, item.columnEnd);
// Mark columns as occupied in this row
for (let col = item.columnStart; col <= item.columnEnd; col++) {
this.tracks[row][col] = true;
}
// gridArea format: "row / col-start / row+1 / col-end"
// CSS grid uses 1-based indices
layouts.push({
itemId: item.id,
gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`,
startColumn: item.columnStart,
endColumn: item.columnEnd,
row: row + 1 // 1-based for CSS
});
}
return layouts;
}
/**
* Calculate layout for a single new item
* Useful for real-time drag operations
*/
calculateSingleLayout(item: IHeaderItemInput): IHeaderItemLayout {
const row = this.findAvailableRow(item.columnStart, item.columnEnd);
// Mark columns as occupied
for (let col = item.columnStart; col <= item.columnEnd; col++) {
this.tracks[row][col] = true;
}
return {
itemId: item.id,
gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`,
startColumn: item.columnStart,
endColumn: item.columnEnd,
row: row + 1
};
}
/**
* Find the first row where all columns in range are available
*/
private findAvailableRow(startCol: number, endCol: number): number {
for (let row = 0; row < this.tracks.length; row++) {
if (this.isRowAvailable(row, startCol, endCol)) {
return row;
}
}
// Add new row if all existing rows are occupied
this.tracks.push(new Array(this.columnCount).fill(false));
return this.tracks.length - 1;
}
/**
* Check if columns in range are all available in given row
*/
private isRowAvailable(row: number, startCol: number, endCol: number): boolean {
for (let col = startCol; col <= endCol; col++) {
if (this.tracks[row][col]) {
return false;
}
}
return true;
}
/**
* Get the number of rows currently in use
*/
getRowCount(): number {
return this.tracks.length;
}
/**
* Update column count (e.g., when view changes)
*/
setColumnCount(count: number): void {
this.columnCount = count;
this.reset();
}
}

View file

@ -0,0 +1,419 @@
import { IEventBus, ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { CoreEvents } from '../../constants/CoreEvents';
import { HeaderDrawerManager } from '../../core/HeaderDrawerManager';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { FilterTemplate } from '../../core/FilterTemplate';
import {
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload,
IDragEndPayload
} from '../../types/DragTypes';
/**
* Layout information for a header item
*/
interface IHeaderItemLayout {
event: ICalendarEvent;
columnKey: string; // Opaque column identifier
row: number; // 1-indexed
colStart: number; // 1-indexed
colEnd: number; // exclusive
}
/**
* HeaderDrawerRenderer - Handles rendering of items in the header drawer
*
* Listens to drag events from DragDropManager and creates/manages
* swp-header-item elements in the header drawer.
*
* Uses subgrid for column alignment with parent swp-calendar-header.
* Position items via gridArea for explicit row/column placement.
*/
export class HeaderDrawerRenderer {
private currentItem: HTMLElement | null = null;
private container: HTMLElement | null = null;
private sourceElement: HTMLElement | null = null;
private wasExpandedBeforeDrag = false;
private filterTemplate: FilterTemplate | null = null;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private headerDrawerManager: HeaderDrawerManager,
private eventService: EventService,
private dateService: DateService
) {
this.setupListeners();
}
/**
* Render allDay events into the header drawer with row stacking
* @param filterTemplate - Template for matching events to columns
*/
async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store filterTemplate for buildColumnKeyFromEvent
this.filterTemplate = filterTemplate;
const drawer = container.querySelector('swp-header-drawer');
if (!drawer) return;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
// Get column keys from DOM for correct multi-resource positioning
const visibleColumnKeys = this.getVisibleColumnKeysFromDOM();
if (visibleColumnKeys.length === 0) return;
// Fetch events for date range
const startDate = new Date(visibleDates[0]);
const endDate = new Date(visibleDates[visibleDates.length - 1]);
endDate.setHours(23, 59, 59, 999);
const events = await this.eventService.getByDateRange(startDate, endDate);
// Filter to allDay events only (allDay !== false)
const allDayEvents = events.filter(event => event.allDay !== false);
// Clear existing items
drawer.innerHTML = '';
if (allDayEvents.length === 0) return;
// Calculate layout with row stacking using columnKeys
const layouts = this.calculateLayout(allDayEvents, visibleColumnKeys);
const rowCount = Math.max(1, ...layouts.map(l => l.row));
// Render each item with layout
layouts.forEach(layout => {
const item = this.createHeaderItem(layout);
drawer.appendChild(item);
});
// Expand drawer to fit all rows
this.headerDrawerManager.expandToRows(rowCount);
}
/**
* Create a header item element from layout
*/
private createHeaderItem(layout: IHeaderItemLayout): HTMLElement {
const { event, columnKey, row, colStart, colEnd } = layout;
const item = document.createElement('swp-header-item');
item.dataset.eventId = event.id;
item.dataset.itemType = 'event';
item.dataset.start = event.start.toISOString();
item.dataset.end = event.end.toISOString();
item.dataset.columnKey = columnKey;
item.textContent = event.title;
// Color class
const colorClass = this.getColorClass(event);
if (colorClass) item.classList.add(colorClass);
// Grid position from layout
item.style.gridArea = `${row} / ${colStart} / ${row + 1} / ${colEnd}`;
return item;
}
/**
* Calculate layout for all events with row stacking
* Uses track-based algorithm to find available rows for overlapping events
*/
private calculateLayout(events: ICalendarEvent[], visibleColumnKeys: string[]): IHeaderItemLayout[] {
// tracks[row][col] = occupied
const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)];
const layouts: IHeaderItemLayout[] = [];
for (const event of events) {
// Build columnKey from event fields (only place we need to construct it)
const columnKey = this.buildColumnKeyFromEvent(event);
const startCol = visibleColumnKeys.indexOf(columnKey);
const endColumnKey = this.buildColumnKeyFromEvent(event, event.end);
const endCol = visibleColumnKeys.indexOf(endColumnKey);
if (startCol === -1 && endCol === -1) continue;
// Clamp til synlige kolonner
const colStart = Math.max(0, startCol);
const colEnd = (endCol !== -1 ? endCol : visibleColumnKeys.length - 1) + 1;
// Find ledig række
const row = this.findAvailableRow(tracks, colStart, colEnd);
// Marker som optaget
for (let c = colStart; c < colEnd; c++) {
tracks[row][c] = true;
}
layouts.push({ event, columnKey, row: row + 1, colStart: colStart + 1, colEnd: colEnd + 1 });
}
return layouts;
}
/**
* Build columnKey from event using FilterTemplate
* Uses the same template that columns use for matching
*/
private buildColumnKeyFromEvent(event: ICalendarEvent, date?: Date): string {
if (!this.filterTemplate) {
// Fallback if no template - shouldn't happen in normal flow
const dateStr = this.dateService.getDateKey(date || event.start);
return dateStr;
}
// For multi-day events, we need to override the date in the event
if (date && date.getTime() !== event.start.getTime()) {
// Create temporary event with overridden start for key generation
const tempEvent = { ...event, start: date };
return this.filterTemplate.buildKeyFromEvent(tempEvent);
}
return this.filterTemplate.buildKeyFromEvent(event);
}
/**
* Find available row for event spanning columns [colStart, colEnd)
*/
private findAvailableRow(tracks: boolean[][], colStart: number, colEnd: number): number {
for (let row = 0; row < tracks.length; row++) {
let available = true;
for (let c = colStart; c < colEnd; c++) {
if (tracks[row][c]) { available = false; break; }
}
if (available) return row;
}
// Ny række
tracks.push(new Array(tracks[0].length).fill(false));
return tracks.length - 1;
}
/**
* Get color class based on event metadata or type
*/
private getColorClass(event: ICalendarEvent): string {
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
'break': 'is-amber',
'meeting': 'is-purple',
'blocked': 'is-red'
};
return typeColors[event.type] || 'is-blue';
}
/**
* Setup event listeners for drag events
*/
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => {
const payload = (e as CustomEvent<IDragEnterHeaderPayload>).detail;
this.handleDragEnter(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragMoveHeaderPayload>).detail;
this.handleDragMove(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
this.handleDragLeave(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
this.handleDragEnd(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => {
this.cleanup();
});
}
/**
* Handle drag entering header zone - create preview item
*/
private handleDragEnter(payload: IDragEnterHeaderPayload): void {
this.container = document.querySelector('swp-header-drawer');
if (!this.container) return;
// Remember if drawer was already expanded
this.wasExpandedBeforeDrag = this.headerDrawerManager.isExpanded();
// Expand to at least 1 row if collapsed, otherwise keep current height
if (!this.wasExpandedBeforeDrag) {
this.headerDrawerManager.expandToRows(1);
}
// Store reference to source element
this.sourceElement = payload.element;
// Create header item
const item = document.createElement('swp-header-item');
item.dataset.eventId = payload.eventId;
item.dataset.itemType = payload.itemType;
item.dataset.duration = String(payload.duration);
item.dataset.columnKey = payload.sourceColumnKey;
item.textContent = payload.title;
// Apply color class if present
if (payload.colorClass) {
item.classList.add(payload.colorClass);
}
// Add dragging state
item.classList.add('dragging');
// Initial placement (duration determines column span)
// gridArea format: "row / col-start / row+1 / col-end"
const col = payload.sourceColumnIndex + 1;
const endCol = col + payload.duration;
item.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
this.container.appendChild(item);
this.currentItem = item;
// Hide original element while in header
payload.element.style.visibility = 'hidden';
}
/**
* Handle drag moving within header - update column position
*/
private handleDragMove(payload: IDragMoveHeaderPayload): void {
if (!this.currentItem) return;
// Update column position
const col = payload.columnIndex + 1;
const duration = parseInt(this.currentItem.dataset.duration || '1', 10);
const endCol = col + duration;
this.currentItem.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
// Update columnKey to new position
this.currentItem.dataset.columnKey = payload.columnKey;
}
/**
* Handle drag leaving header - cleanup for gridheader drag only
*/
private handleDragLeave(payload: IDragLeaveHeaderPayload): void {
// Only cleanup for grid→header drag (when grid event leaves header back to grid)
// For header→grid drag, the header item stays as ghost until drop
if (payload.source === 'grid') {
this.cleanup();
}
// For header source, do nothing - ghost stays until EVENT_DRAG_END
}
/**
* Handle drag end - finalize based on drop target
*/
private handleDragEnd(payload: IDragEndPayload): void {
if (payload.target === 'header') {
// Grid→Header: Finalize the header item (it stays in header)
if (this.currentItem) {
this.currentItem.classList.remove('dragging');
this.recalculateDrawerLayout();
this.currentItem = null;
this.sourceElement = null;
}
} else {
// Header→Grid: Remove ghost header item and recalculate
const ghost = document.querySelector(`swp-header-item.drag-ghost[data-event-id="${payload.swpEvent.eventId}"]`);
ghost?.remove();
this.recalculateDrawerLayout();
}
}
/**
* Recalculate layout for all items currently in the drawer
* Called after drop to reposition items and adjust height
*/
private recalculateDrawerLayout(): void {
const drawer = document.querySelector('swp-header-drawer');
if (!drawer) return;
const items = Array.from(drawer.querySelectorAll('swp-header-item')) as HTMLElement[];
if (items.length === 0) return;
// Get visible column keys for correct multi-resource positioning
const visibleColumnKeys = this.getVisibleColumnKeysFromDOM();
if (visibleColumnKeys.length === 0) return;
// Build layout data from DOM items - use columnKey directly (opaque matching)
const itemData = items.map(item => ({
element: item,
columnKey: item.dataset.columnKey || '',
duration: parseInt(item.dataset.duration || '1', 10)
}));
// Calculate new layout using track algorithm
const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)];
for (const item of itemData) {
// Direct columnKey matching - no parsing or construction needed
const startCol = visibleColumnKeys.indexOf(item.columnKey);
if (startCol === -1) continue;
const colStart = startCol;
const colEnd = Math.min(startCol + item.duration, visibleColumnKeys.length);
const row = this.findAvailableRow(tracks, colStart, colEnd);
for (let c = colStart; c < colEnd; c++) {
tracks[row][c] = true;
}
// Update element position
item.element.style.gridArea = `${row + 1} / ${colStart + 1} / ${row + 2} / ${colEnd + 1}`;
}
// Update drawer height
const rowCount = tracks.length;
this.headerDrawerManager.expandToRows(rowCount);
}
/**
* Get visible column keys from DOM (preserves order for multi-resource views)
* Uses filterTemplate.buildKeyFromColumn() for consistent key format with events
*/
private getVisibleColumnKeysFromDOM(): string[] {
if (!this.filterTemplate) return [];
const columns = document.querySelectorAll('swp-day-column');
const columnKeys: string[] = [];
columns.forEach(col => {
const columnKey = this.filterTemplate!.buildKeyFromColumn(col as HTMLElement);
if (columnKey) columnKeys.push(columnKey);
});
return columnKeys;
}
/**
* Cleanup preview item and restore source visibility
*/
private cleanup(): void {
// Remove preview item
this.currentItem?.remove();
this.currentItem = null;
// Restore source element visibility
if (this.sourceElement) {
this.sourceElement.style.visibility = '';
this.sourceElement = null;
}
// Collapse drawer if it wasn't expanded before drag
if (!this.wasExpandedBeforeDrag) {
this.headerDrawerManager.collapse();
}
}
}

View file

@ -0,0 +1,2 @@
export { HeaderDrawerRenderer } from './HeaderDrawerRenderer';
export { HeaderDrawerLayoutEngine, type IHeaderItemLayout, type IHeaderItemInput } from './HeaderDrawerLayoutEngine';

View file

@ -0,0 +1,69 @@
import { IRenderContext } from '../../core/IGroupingRenderer';
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { ResourceService } from '../../storage/resources/ResourceService';
import { IResource } from '../../types/CalendarTypes';
export class ResourceRenderer extends BaseGroupingRenderer<IResource> {
readonly type = 'resource';
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-resource-header',
idAttribute: 'resourceId',
colspanVar: '--resource-cols'
};
constructor(private resourceService: ResourceService) {
super();
}
protected getEntities(ids: string[]): Promise<IResource[]> {
return this.resourceService.getByIds(ids);
}
protected getDisplayName(entity: IResource): string {
return entity.displayName;
}
/**
* Override render to handle:
* 1. Special ordering when parentChildMap exists (resources grouped by parent)
* 2. Different colspan calculation (just dateCount, not childCount * dateCount)
*/
async render(context: IRenderContext): Promise<void> {
const resourceIds = context.filter['resource'] || [];
const dateCount = context.filter['date']?.length || 1;
// Determine render order based on parentChildMap
// If parentChildMap exists, render resources grouped by parent (e.g., team)
// Otherwise, render in filter order
let orderedResourceIds: string[];
if (context.parentChildMap) {
// Render resources in parent-child order
orderedResourceIds = [];
for (const childIds of Object.values(context.parentChildMap)) {
for (const childId of childIds) {
if (resourceIds.includes(childId)) {
orderedResourceIds.push(childId);
}
}
}
} else {
orderedResourceIds = resourceIds;
}
const resources = await this.getEntities(orderedResourceIds);
// Create a map for quick lookup to preserve order
const resourceMap = new Map(resources.map(r => [r.id, r]));
for (const resourceId of orderedResourceIds) {
const resource = resourceMap.get(resourceId);
if (!resource) continue;
const header = this.createHeader(resource, context);
header.style.gridColumn = `span ${dateCount}`;
context.headerContainer.appendChild(header);
}
}
}

View file

@ -0,0 +1 @@
export { ResourceRenderer } from './ResourceRenderer';

View file

@ -0,0 +1,106 @@
import { ResourceScheduleService } from '../../extensions/schedules/ResourceScheduleService';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { ITimeSlot } from '../../types/ScheduleTypes';
/**
* ScheduleRenderer - Renders unavailable time zones in day columns
*
* Creates visual indicators for times outside the resource's working hours:
* - Before work start (e.g., 06:00 - 09:00)
* - After work end (e.g., 17:00 - 18:00)
* - Full day if resource is off (schedule = null)
*/
export class ScheduleRenderer {
constructor(
private scheduleService: ResourceScheduleService,
private dateService: DateService,
private gridConfig: IGridConfig
) {}
/**
* Render unavailable zones for visible columns
* @param container - Calendar container element
* @param filter - Filter with 'date' and 'resource' arrays
*/
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
const dates = filter['date'] || [];
const resourceIds = filter['resource'] || [];
if (dates.length === 0) return;
// Find day columns
const dayColumns = container.querySelector('swp-day-columns');
if (!dayColumns) return;
const columns = dayColumns.querySelectorAll('swp-day-column');
for (const column of columns) {
const date = (column as HTMLElement).dataset.date;
const resourceId = (column as HTMLElement).dataset.resourceId;
if (!date || !resourceId) continue;
// Get or create unavailable layer
let unavailableLayer = column.querySelector('swp-unavailable-layer');
if (!unavailableLayer) {
unavailableLayer = document.createElement('swp-unavailable-layer');
column.insertBefore(unavailableLayer, column.firstChild);
}
// Clear existing
unavailableLayer.innerHTML = '';
// Get schedule for this resource/date
const schedule = await this.scheduleService.getScheduleForDate(resourceId, date);
// Render unavailable zones
this.renderUnavailableZones(unavailableLayer as HTMLElement, schedule);
}
}
/**
* Render unavailable time zones based on schedule
*/
private renderUnavailableZones(layer: HTMLElement, schedule: ITimeSlot | null): void {
const dayStartMinutes = this.gridConfig.dayStartHour * 60;
const dayEndMinutes = this.gridConfig.dayEndHour * 60;
const minuteHeight = this.gridConfig.hourHeight / 60;
if (schedule === null) {
// Full day unavailable
const zone = this.createUnavailableZone(0, (dayEndMinutes - dayStartMinutes) * minuteHeight);
layer.appendChild(zone);
return;
}
const workStartMinutes = this.dateService.timeToMinutes(schedule.start);
const workEndMinutes = this.dateService.timeToMinutes(schedule.end);
// Before work start
if (workStartMinutes > dayStartMinutes) {
const top = 0;
const height = (workStartMinutes - dayStartMinutes) * minuteHeight;
const zone = this.createUnavailableZone(top, height);
layer.appendChild(zone);
}
// After work end
if (workEndMinutes < dayEndMinutes) {
const top = (workEndMinutes - dayStartMinutes) * minuteHeight;
const height = (dayEndMinutes - workEndMinutes) * minuteHeight;
const zone = this.createUnavailableZone(top, height);
layer.appendChild(zone);
}
}
/**
* Create an unavailable zone element
*/
private createUnavailableZone(top: number, height: number): HTMLElement {
const zone = document.createElement('swp-unavailable-zone');
zone.style.top = `${top}px`;
zone.style.height = `${height}px`;
return zone;
}
}

View file

@ -0,0 +1 @@
export { ScheduleRenderer } from './ScheduleRenderer';

View file

@ -0,0 +1,10 @@
export class TimeAxisRenderer {
render(container: HTMLElement, startHour = 6, endHour = 20): void {
container.innerHTML = '';
for (let hour = startHour; hour <= endHour; hour++) {
const marker = document.createElement('swp-hour-marker');
marker.textContent = `${hour.toString().padStart(2, '0')}:00`;
container.appendChild(marker);
}
}
}

View file

@ -0,0 +1,164 @@
// === CORE ===
// App
export { CalendarApp } from './core/CalendarApp';
export { CalendarOrchestrator } from './core/CalendarOrchestrator';
export { CalendarEvents } from './core/CalendarEvents';
export type {
RenderPayload,
WorkweekChangePayload,
ViewUpdatePayload
} from './core/CalendarEvents';
// Infrastructure
export { EventBus } from './core/EventBus';
export { DateService } from './core/DateService';
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
export { IRenderer, IRenderContext } from './core/IGroupingRenderer';
export { IGroupingStore } from './core/IGroupingStore';
export { BaseGroupingRenderer, IGroupingRendererConfig } from './core/BaseGroupingRenderer';
export { buildPipeline, Pipeline } from './core/RenderBuilder';
export { NavigationAnimator } from './core/NavigationAnimator';
export { ScrollManager } from './core/ScrollManager';
export { HeaderDrawerManager } from './core/HeaderDrawerManager';
export { FilterTemplate } from './core/FilterTemplate';
export { EntityResolver } from './core/EntityResolver';
export type { IEntityResolver } from './core/IEntityResolver';
// Configuration interfaces
export type { ITimeFormatConfig } from './core/ITimeFormatConfig';
export type { IGridConfig } from './core/IGridConfig';
// Core Features
export { DateRenderer } from './features/date';
export { ResourceRenderer } from './features/resource';
export { EventRenderer } from './features/event';
export { eventsOverlap, calculateColumnLayout } from './features/event/EventLayoutEngine';
export type {
IStackLink,
IStackedEventLayout,
IGridGroupLayout,
IColumnLayout
} from './features/event/EventLayoutTypes';
export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
export { HeaderDrawerRenderer, HeaderDrawerLayoutEngine } from './features/headerdrawer';
export { ScheduleRenderer } from './features/schedule';
// Core Storage
export { IndexedDBContext, defaultDBConfig } from './storage/IndexedDBContext';
export type { IDBConfig } from './storage/IndexedDBContext';
export { BaseEntityService } from './storage/BaseEntityService';
export type { IEntityService } from './storage/IEntityService';
export type { IStore } from './storage/IStore';
export { SyncPlugin } from './storage/SyncPlugin';
// Event storage
export { EventService } from './storage/events/EventService';
export { EventStore } from './storage/events/EventStore';
export { EventSerialization } from './storage/events/EventSerialization';
// Resource storage
export { ResourceService } from './storage/resources/ResourceService';
export { ResourceStore } from './storage/resources/ResourceStore';
// Settings storage
export { SettingsService } from './storage/settings/SettingsService';
export { SettingsStore } from './storage/settings/SettingsStore';
// ViewConfig storage
export { ViewConfigService } from './storage/viewconfigs/ViewConfigService';
export { ViewConfigStore } from './storage/viewconfigs/ViewConfigStore';
// Core Managers
export { DragDropManager } from './managers/DragDropManager';
export { EdgeScrollManager } from './managers/EdgeScrollManager';
export { ResizeManager } from './managers/ResizeManager';
export { EventPersistenceManager } from './managers/EventPersistenceManager';
// Position utilities
export {
calculateEventPosition,
minutesToPixels,
pixelsToMinutes,
snapToGrid
} from './utils/PositionUtils';
export type { EventPosition } from './utils/PositionUtils';
// Types
export type {
ICalendarEvent,
IResource,
IEventBus,
ISync,
SyncStatus,
EntityType,
CalendarEventType,
ResourceType,
ITeam,
IDepartment,
IBooking,
BookingStatus,
IBookingService,
ICustomer,
IDataEntity,
IEventLogEntry,
IListenerEntry,
IEntitySavedPayload,
IEntityDeletedPayload,
IEventUpdatedPayload
} from './types/CalendarTypes';
export type {
TenantSetting,
IGridSettings,
IWorkweekPreset
} from './types/SettingsTypes';
export type {
IScheduleOverride,
ITimeSlot,
IWeekSchedule,
WeekDay
} from './types/ScheduleTypes';
// Drag types
export type {
IMousePosition,
IDragStartPayload,
IDragMovePayload,
IDragEndPayload,
IDragCancelPayload,
IDragColumnChangePayload,
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload
} from './types/DragTypes';
// Resize types
export type {
IResizeStartPayload,
IResizeEndPayload
} from './types/ResizeTypes';
// Audit types
export type {
IAuditEntry,
IAuditLoggedPayload
} from './types/AuditTypes';
export { SwpEvent } from './types/SwpEvent';
// Core Events constants
export { CoreEvents } from './constants/CoreEvents';
// Repository interface (for custom implementations)
export type { IApiRepository } from './repositories/IApiRepository';
// DI helpers
export {
createCalendarContainer,
registerCoreServices,
defaultTimeFormatConfig,
defaultGridConfig
} from './CompositionRoot';
export type { ICalendarOptions } from './CompositionRoot';

View file

@ -0,0 +1,581 @@
import { IEventBus } from '../types/CalendarTypes';
import { IGridConfig } from '../core/IGridConfig';
import { CoreEvents } from '../constants/CoreEvents';
import { snapToGrid } from '../utils/PositionUtils';
import {
IMousePosition,
IDragStartPayload,
IDragMovePayload,
IDragEndPayload,
IDragCancelPayload,
IDragColumnChangePayload,
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload
} from '../types/DragTypes';
import { SwpEvent } from '../types/SwpEvent';
interface DragState {
eventId: string;
element: HTMLElement;
ghostElement: HTMLElement | null; // Null for header items
startY: number;
mouseOffset: IMousePosition;
columnElement: HTMLElement | null; // Null when starting from header
currentColumn: HTMLElement | null; // Null when in header
targetY: number;
currentY: number;
animationId: number;
sourceColumnKey: string; // Source column key (where drag started)
dragSource: 'grid' | 'header'; // Where drag originated
}
/**
* DragDropManager - Handles drag-drop for calendar events
*
* Strategy: Drag original element, leave ghost-clone in place
* - mousedown: Store initial state, wait for movement
* - mousemove (>5px): Create ghost, start dragging original
* - mouseup: Snap to grid, remove ghost, emit drag:end
* - cancel: Animate back to startY, remove ghost
*/
export class DragDropManager {
private dragState: DragState | null = null;
private mouseDownPosition: IMousePosition | null = null;
private pendingElement: HTMLElement | null = null;
private pendingMouseOffset: IMousePosition | null = null;
private container: HTMLElement | null = null;
private inHeader = false;
private readonly DRAG_THRESHOLD = 5;
private readonly INTERPOLATION_FACTOR = 0.3;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig
) {
this.setupScrollListener();
}
private setupScrollListener(): void {
this.eventBus.on(CoreEvents.EDGE_SCROLL_TICK, (e) => {
if (!this.dragState) return;
const { scrollDelta } = (e as CustomEvent<{ scrollDelta: number }>).detail;
// Element skal flytte med scroll for at forblive under musen
// (elementets top er relativ til kolonnen, som scroller med viewport)
this.dragState.targetY += scrollDelta;
this.dragState.currentY += scrollDelta;
this.dragState.element.style.top = `${this.dragState.currentY}px`;
});
}
/**
* Initialize drag-drop on a container element
*/
init(container: HTMLElement): void {
this.container = container;
container.addEventListener('pointerdown', this.handlePointerDown);
document.addEventListener('pointermove', this.handlePointerMove);
document.addEventListener('pointerup', this.handlePointerUp);
}
private handlePointerDown = (e: PointerEvent): void => {
const target = e.target as HTMLElement;
// Ignore if clicking on resize handle
if (target.closest('swp-resize-handle')) return;
// Match both swp-event and swp-header-item
const eventElement = target.closest('swp-event') as HTMLElement;
const headerItem = target.closest('swp-header-item') as HTMLElement;
const draggable = eventElement || headerItem;
if (!draggable) return;
// Store for potential drag
this.mouseDownPosition = { x: e.clientX, y: e.clientY };
this.pendingElement = draggable;
// Calculate mouse offset within element
const rect = draggable.getBoundingClientRect();
this.pendingMouseOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
// Capture pointer for reliable tracking
draggable.setPointerCapture(e.pointerId);
};
private handlePointerMove = (e: PointerEvent): void => {
// Not in potential drag state
if (!this.mouseDownPosition || !this.pendingElement) {
// Already dragging - update target
if (this.dragState) {
this.updateDragTarget(e);
}
return;
}
// Check threshold
const deltaX = Math.abs(e.clientX - this.mouseDownPosition.x);
const deltaY = Math.abs(e.clientY - this.mouseDownPosition.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance < this.DRAG_THRESHOLD) return;
// Start drag
this.initializeDrag(this.pendingElement, this.pendingMouseOffset!, e);
this.mouseDownPosition = null;
this.pendingElement = null;
this.pendingMouseOffset = null;
};
private handlePointerUp = (_e: PointerEvent): void => {
// Clear pending state
this.mouseDownPosition = null;
this.pendingElement = null;
this.pendingMouseOffset = null;
if (!this.dragState) return;
// Stop animation
cancelAnimationFrame(this.dragState.animationId);
// Handle based on drag source and target
if (this.dragState.dragSource === 'header') {
// Header item drag end
this.handleHeaderItemDragEnd();
} else {
// Grid event drag end
this.handleGridEventDragEnd();
}
// Cleanup
this.dragState.element.classList.remove('dragging');
this.dragState = null;
this.inHeader = false;
};
/**
* Handle drag end for header items
*/
private handleHeaderItemDragEnd(): void {
if (!this.dragState) return;
// If dropped in grid (not in header), the swp-event was already created
// by EventRenderer listening to EVENT_DRAG_LEAVE_HEADER
// Just emit drag:end for persistence
if (!this.inHeader && this.dragState.currentColumn) {
// Dropped in grid - emit drag:end with the new swp-event element
const gridEvent = this.dragState.currentColumn.querySelector(
`swp-event[data-event-id="${this.dragState.eventId}"]`
) as HTMLElement;
if (gridEvent) {
const columnKey = this.dragState.currentColumn.dataset.columnKey || '';
const date = this.dragState.currentColumn.dataset.date || '';
const swpEvent = SwpEvent.fromElement(gridEvent, columnKey, date, this.gridConfig);
const payload: IDragEndPayload = {
swpEvent,
sourceColumnKey: this.dragState.sourceColumnKey,
target: 'grid'
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload);
}
}
// If still in header, no persistence needed (stayed in header)
}
/**
* Handle drag end for grid events
*/
private handleGridEventDragEnd(): void {
if (!this.dragState || !this.dragState.columnElement) return;
// Snap to grid
const snappedY = snapToGrid(this.dragState.currentY, this.gridConfig);
this.dragState.element.style.top = `${snappedY}px`;
// Remove ghost
this.dragState.ghostElement?.remove();
// Get columnKey and date from target column
const columnKey = this.dragState.columnElement.dataset.columnKey || '';
const date = this.dragState.columnElement.dataset.date || '';
// Create SwpEvent from element (reads top/height/eventId from element)
const swpEvent = SwpEvent.fromElement(
this.dragState.element,
columnKey,
date,
this.gridConfig
);
// Emit drag:end
const payload: IDragEndPayload = {
swpEvent,
sourceColumnKey: this.dragState.sourceColumnKey,
target: this.inHeader ? 'header' : 'grid'
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload);
}
private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void {
const eventId = element.dataset.eventId || '';
const isHeaderItem = element.tagName.toLowerCase() === 'swp-header-item';
const columnElement = element.closest('swp-day-column') as HTMLElement;
// For grid events, we need a column
if (!isHeaderItem && !columnElement) return;
if (isHeaderItem) {
// Header item drag initialization
this.initializeHeaderItemDrag(element, mouseOffset, eventId);
} else {
// Grid event drag initialization
this.initializeGridEventDrag(element, mouseOffset, e, columnElement, eventId);
}
}
/**
* Initialize drag for a header item (allDay event)
*/
private initializeHeaderItemDrag(element: HTMLElement, mouseOffset: IMousePosition, eventId: string): void {
// Mark as dragging
element.classList.add('dragging');
// Initialize drag state for header item
this.dragState = {
eventId,
element,
ghostElement: null, // No ghost for header items
startY: 0,
mouseOffset,
columnElement: null,
currentColumn: null,
targetY: 0,
currentY: 0,
animationId: 0,
sourceColumnKey: '', // Will be set from header item data
dragSource: 'header'
};
// Start in header mode
this.inHeader = true;
}
/**
* Initialize drag for a grid event
*/
private initializeGridEventDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent, columnElement: HTMLElement, eventId: string): void {
// Calculate absolute Y position using getBoundingClientRect
const elementRect = element.getBoundingClientRect();
const columnRect = columnElement.getBoundingClientRect();
const startY = elementRect.top - columnRect.top;
// If event is inside a group, move it to events-layer for correct positioning during drag
const group = element.closest('swp-event-group');
if (group) {
const eventsLayer = columnElement.querySelector('swp-events-layer');
if (eventsLayer) {
eventsLayer.appendChild(element);
}
}
// Set consistent positioning for drag (works for both grouped and stacked events)
element.style.position = 'absolute';
element.style.top = `${startY}px`;
element.style.left = '2px';
element.style.right = '2px';
element.style.marginLeft = '0'; // Reset stacking margin
// Create ghost clone
const ghostElement = element.cloneNode(true) as HTMLElement;
ghostElement.classList.add('drag-ghost');
ghostElement.style.opacity = '0.3';
ghostElement.style.pointerEvents = 'none';
// Insert ghost before original
element.parentNode?.insertBefore(ghostElement, element);
// Setup element for dragging
element.classList.add('dragging');
// Calculate initial target from mouse position
const targetY = e.clientY - columnRect.top - mouseOffset.y;
// Initialize drag state
this.dragState = {
eventId,
element,
ghostElement,
startY,
mouseOffset,
columnElement,
currentColumn: columnElement,
targetY: Math.max(0, targetY),
currentY: startY,
animationId: 0,
sourceColumnKey: columnElement.dataset.columnKey || '',
dragSource: 'grid'
};
// Emit drag:start
const payload: IDragStartPayload = {
eventId,
element,
ghostElement,
startY,
mouseOffset,
columnElement
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_START, payload);
// Start animation loop
this.animateDrag();
}
private updateDragTarget(e: PointerEvent): void {
if (!this.dragState) return;
// Check header zone first
this.checkHeaderZone(e);
// Skip normal grid handling if in header
if (this.inHeader) return;
// Check for column change
const columnAtPoint = this.getColumnAtPoint(e.clientX);
// For header items entering grid, set initial column
if (this.dragState.dragSource === 'header' && columnAtPoint && !this.dragState.currentColumn) {
this.dragState.currentColumn = columnAtPoint;
this.dragState.columnElement = columnAtPoint;
}
if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn && this.dragState.currentColumn) {
const payload: IDragColumnChangePayload = {
eventId: this.dragState.eventId,
element: this.dragState.element,
previousColumn: this.dragState.currentColumn,
newColumn: columnAtPoint,
currentY: this.dragState.currentY
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, payload);
this.dragState.currentColumn = columnAtPoint;
this.dragState.columnElement = columnAtPoint;
}
// Skip grid position updates if no column yet
if (!this.dragState.columnElement) return;
const columnRect = this.dragState.columnElement.getBoundingClientRect();
const targetY = e.clientY - columnRect.top - this.dragState.mouseOffset.y;
this.dragState.targetY = Math.max(0, targetY);
// Start animation if not running
if (!this.dragState.animationId) {
this.animateDrag();
}
}
/**
* Check if pointer is in header zone and emit appropriate events
*/
private checkHeaderZone(e: PointerEvent): void {
if (!this.dragState) return;
const headerViewport = document.querySelector('swp-header-viewport');
if (!headerViewport) return;
const rect = headerViewport.getBoundingClientRect();
const isInHeader = e.clientY < rect.bottom;
if (isInHeader && !this.inHeader) {
// Entered header (from grid)
this.inHeader = true;
if (this.dragState.dragSource === 'grid' && this.dragState.columnElement) {
const payload: IDragEnterHeaderPayload = {
eventId: this.dragState.eventId,
element: this.dragState.element,
sourceColumnIndex: this.getColumnIndex(this.dragState.columnElement),
sourceColumnKey: this.dragState.columnElement.dataset.columnKey || '',
title: this.dragState.element.querySelector('swp-event-title')?.textContent || '',
colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')),
itemType: 'event',
duration: 1
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload);
}
// For header source re-entering header, just update inHeader flag
} else if (!isInHeader && this.inHeader) {
// Left header (entering grid)
this.inHeader = false;
const targetColumn = this.getColumnAtPoint(e.clientX);
if (this.dragState.dragSource === 'header') {
// Header item leaving header → create swp-event in grid
const payload: IDragLeaveHeaderPayload = {
eventId: this.dragState.eventId,
source: 'header',
element: this.dragState.element,
targetColumn: targetColumn || undefined,
start: this.dragState.element.dataset.start ? new Date(this.dragState.element.dataset.start) : undefined,
end: this.dragState.element.dataset.end ? new Date(this.dragState.element.dataset.end) : undefined,
title: this.dragState.element.textContent || '',
colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-'))
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload);
// Re-attach to the new swp-event created by EventRenderer
if (targetColumn) {
const newElement = targetColumn.querySelector(
`swp-event[data-event-id="${this.dragState.eventId}"]`
) as HTMLElement;
if (newElement) {
this.dragState.element = newElement;
this.dragState.columnElement = targetColumn;
this.dragState.currentColumn = targetColumn;
// Start animation for the new element
this.animateDrag();
}
}
} else {
// Grid event leaving header → restore to grid
const payload: IDragLeaveHeaderPayload = {
eventId: this.dragState.eventId,
source: 'grid'
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload);
}
} else if (isInHeader) {
// Moving within header
const column = this.getColumnAtX(e.clientX);
if (column) {
const payload: IDragMoveHeaderPayload = {
eventId: this.dragState.eventId,
columnIndex: this.getColumnIndex(column),
columnKey: column.dataset.columnKey || ''
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE_HEADER, payload);
}
}
}
/**
* Get column index (0-based) for a column element
*/
private getColumnIndex(column: HTMLElement | null): number {
if (!this.container || !column) return 0;
const columns = Array.from(this.container.querySelectorAll('swp-day-column'));
return columns.indexOf(column);
}
/**
* Get column at X coordinate (alias for getColumnAtPoint)
*/
private getColumnAtX(clientX: number): HTMLElement | null {
return this.getColumnAtPoint(clientX);
}
/**
* Find column element at given X coordinate
*/
private getColumnAtPoint(clientX: number): HTMLElement | null {
if (!this.container) return null;
const columns = this.container.querySelectorAll('swp-day-column');
for (const col of columns) {
const rect = col.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right) {
return col as HTMLElement;
}
}
return null;
}
private animateDrag = (): void => {
if (!this.dragState) return;
const diff = this.dragState.targetY - this.dragState.currentY;
// Stop animation when close enough to target
if (Math.abs(diff) <= 0.5) {
this.dragState.animationId = 0;
return;
}
// Interpolate towards target
this.dragState.currentY += diff * this.INTERPOLATION_FACTOR;
// Update element position
this.dragState.element.style.top = `${this.dragState.currentY}px`;
// Emit drag:move (only if we have a column)
if (this.dragState.columnElement) {
const payload: IDragMovePayload = {
eventId: this.dragState.eventId,
element: this.dragState.element,
currentY: this.dragState.currentY,
columnElement: this.dragState.columnElement
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE, payload);
}
// Continue animation
this.dragState.animationId = requestAnimationFrame(this.animateDrag);
};
/**
* Cancel drag and animate back to start position
*/
cancelDrag(): void {
if (!this.dragState) return;
// Stop animation
cancelAnimationFrame(this.dragState.animationId);
const { element, ghostElement, startY, eventId } = this.dragState;
// Animate back to start
element.style.transition = 'top 200ms ease-out';
element.style.top = `${startY}px`;
// Remove ghost after animation (if exists)
setTimeout(() => {
ghostElement?.remove();
element.style.transition = '';
element.classList.remove('dragging');
}, 200);
// Emit drag:cancel
const payload: IDragCancelPayload = {
eventId,
element,
startY
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload);
this.dragState = null;
this.inHeader = false;
}
}

View file

@ -0,0 +1,140 @@
/**
* EdgeScrollManager - Auto-scroll when dragging near viewport edges
*
* 2-zone system:
* - Inner zone (0-50px): Fast scroll (640 px/sec)
* - Outer zone (50-100px): Slow scroll (140 px/sec)
*/
import { IEventBus } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
export class EdgeScrollManager {
private scrollableContent: HTMLElement | null = null;
private timeGrid: HTMLElement | null = null;
private draggedElement: HTMLElement | null = null;
private scrollRAF: number | null = null;
private mouseY = 0;
private isDragging = false;
private isScrolling = false;
private lastTs = 0;
private rect: DOMRect | null = null;
private initialScrollTop = 0;
private readonly OUTER_ZONE = 100;
private readonly INNER_ZONE = 50;
private readonly SLOW_SPEED = 140;
private readonly FAST_SPEED = 640;
constructor(private eventBus: IEventBus) {
this.subscribeToEvents();
document.addEventListener('pointermove', this.trackMouse);
}
init(scrollableContent: HTMLElement): void {
this.scrollableContent = scrollableContent;
this.timeGrid = scrollableContent.querySelector('swp-time-grid');
this.scrollableContent.style.scrollBehavior = 'auto';
}
private trackMouse = (e: PointerEvent): void => {
if (this.isDragging) {
this.mouseY = e.clientY;
}
};
private subscribeToEvents(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event: Event) => {
const payload = (event as CustomEvent).detail;
this.draggedElement = payload.element;
this.startDrag();
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => this.stopDrag());
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => this.stopDrag());
}
private startDrag(): void {
this.isDragging = true;
this.isScrolling = false;
this.lastTs = 0;
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
if (this.scrollRAF === null) {
this.scrollRAF = requestAnimationFrame(this.scrollTick);
}
}
private stopDrag(): void {
this.isDragging = false;
this.setScrollingState(false);
if (this.scrollRAF !== null) {
cancelAnimationFrame(this.scrollRAF);
this.scrollRAF = null;
}
this.rect = null;
this.lastTs = 0;
this.initialScrollTop = 0;
}
private calculateVelocity(): number {
if (!this.rect) return 0;
const distTop = this.mouseY - this.rect.top;
const distBot = this.rect.bottom - this.mouseY;
if (distTop < this.INNER_ZONE) return -this.FAST_SPEED;
if (distTop < this.OUTER_ZONE) return -this.SLOW_SPEED;
if (distBot < this.INNER_ZONE) return this.FAST_SPEED;
if (distBot < this.OUTER_ZONE) return this.SLOW_SPEED;
return 0;
}
private isAtBoundary(velocity: number): boolean {
if (!this.scrollableContent || !this.timeGrid || !this.draggedElement) return false;
const atTop = this.scrollableContent.scrollTop <= 0 && velocity < 0;
const atBottom = velocity > 0 &&
this.draggedElement.getBoundingClientRect().bottom >=
this.timeGrid.getBoundingClientRect().bottom;
return atTop || atBottom;
}
private setScrollingState(scrolling: boolean): void {
if (this.isScrolling === scrolling) return;
this.isScrolling = scrolling;
if (scrolling) {
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STARTED, {});
} else {
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
}
}
private scrollTick = (ts: number): void => {
if (!this.isDragging || !this.scrollableContent) return;
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
this.lastTs = ts;
this.rect ??= this.scrollableContent.getBoundingClientRect();
const velocity = this.calculateVelocity();
if (velocity !== 0 && !this.isAtBoundary(velocity)) {
const scrollDelta = velocity * dt;
this.scrollableContent.scrollTop += scrollDelta;
this.rect = null;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_TICK, { scrollDelta });
this.setScrollingState(true);
} else {
this.setScrollingState(false);
}
this.scrollRAF = requestAnimationFrame(this.scrollTick);
};
}

View file

@ -0,0 +1,102 @@
/**
* EventPersistenceManager - Persists event changes to IndexedDB
*
* Listens to drag/resize events and updates IndexedDB via EventService.
* This bridges the gap between UI interactions and data persistence.
*/
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../types/CalendarTypes';
import { EventService } from '../storage/events/EventService';
import { DateService } from '../core/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { IDragEndPayload } from '../types/DragTypes';
import { IResizeEndPayload } from '../types/ResizeTypes';
export class EventPersistenceManager {
constructor(
private eventService: EventService,
private eventBus: IEventBus,
private dateService: DateService
) {
this.setupListeners();
}
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_END, this.handleDragEnd);
this.eventBus.on(CoreEvents.EVENT_RESIZE_END, this.handleResizeEnd);
}
/**
* Handle drag end - update event position in IndexedDB
*/
private handleDragEnd = async (e: Event): Promise<void> => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
const { swpEvent } = payload;
// Get existing event to merge with
const event = await this.eventService.get(swpEvent.eventId);
if (!event) {
console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`);
return;
}
// Parse resourceId from columnKey if present
const { resource } = this.dateService.parseColumnKey(swpEvent.columnKey);
// Update and save - start/end already calculated in SwpEvent
// Set allDay based on drop target:
// - header: allDay = true
// - grid: allDay = false (converts allDay event to timed)
const updatedEvent: ICalendarEvent = {
...event,
start: swpEvent.start,
end: swpEvent.end,
resourceId: resource ?? event.resourceId,
allDay: payload.target === 'header',
syncStatus: 'pending'
};
await this.eventService.save(updatedEvent);
// Emit EVENT_UPDATED for EventRenderer to re-render affected columns
const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id,
sourceColumnKey: payload.sourceColumnKey,
targetColumnKey: swpEvent.columnKey
};
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
};
/**
* Handle resize end - update event duration in IndexedDB
*/
private handleResizeEnd = async (e: Event): Promise<void> => {
const payload = (e as CustomEvent<IResizeEndPayload>).detail;
const { swpEvent } = payload;
// Get existing event to merge with
const event = await this.eventService.get(swpEvent.eventId);
if (!event) {
console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`);
return;
}
// Update and save - end already calculated in SwpEvent
const updatedEvent: ICalendarEvent = {
...event,
end: swpEvent.end,
syncStatus: 'pending'
};
await this.eventService.save(updatedEvent);
// Emit EVENT_UPDATED for EventRenderer to re-render the column
// Resize stays in same column, so source and target are the same
const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id,
sourceColumnKey: swpEvent.columnKey,
targetColumnKey: swpEvent.columnKey
};
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
};
}

View file

@ -0,0 +1,290 @@
import { IEventBus } from '../types/CalendarTypes';
import { IGridConfig } from '../core/IGridConfig';
import { pixelsToMinutes, minutesToPixels, snapToGrid } from '../utils/PositionUtils';
import { DateService } from '../core/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { IResizeStartPayload, IResizeEndPayload } from '../types/ResizeTypes';
import { SwpEvent } from '../types/SwpEvent';
/**
* ResizeManager - Handles resize of calendar events
*
* Step 1: Handle creation on mouseover (CSS handles visibility)
* Step 2: Pointer events + resize start
* Step 3: RAF animation for smooth height update
* Step 4: Grid snapping + timestamp update
*/
interface ResizeState {
eventId: string;
element: HTMLElement;
handleElement: HTMLElement;
startY: number;
startHeight: number;
startDurationMinutes: number;
pointerId: number;
prevZIndex: string;
// Animation state
currentHeight: number;
targetHeight: number;
animationId: number | null;
}
export class ResizeManager {
private container: HTMLElement | null = null;
private resizeState: ResizeState | null = null;
private readonly Z_INDEX_RESIZING = '1000';
private readonly ANIMATION_SPEED = 0.35;
private readonly MIN_HEIGHT_MINUTES = 15;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private dateService: DateService
) {}
/**
* Initialize resize functionality on container
*/
init(container: HTMLElement): void {
this.container = container;
// Mouseover listener for handle creation (capture phase like V1)
container.addEventListener('mouseover', this.handleMouseOver, true);
// Pointer listeners for resize (capture phase like V1)
document.addEventListener('pointerdown', this.handlePointerDown, true);
document.addEventListener('pointermove', this.handlePointerMove, true);
document.addEventListener('pointerup', this.handlePointerUp, true);
}
/**
* Create resize handle element
*/
private createResizeHandle(): HTMLElement {
const handle = document.createElement('swp-resize-handle');
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
return handle;
}
/**
* Handle mouseover - create resize handle if not exists
*/
private handleMouseOver = (e: Event): void => {
const target = e.target as HTMLElement;
const eventElement = target.closest('swp-event') as HTMLElement;
if (!eventElement || this.resizeState) return;
// Check if handle already exists
if (!eventElement.querySelector(':scope > swp-resize-handle')) {
const handle = this.createResizeHandle();
eventElement.appendChild(handle);
}
};
/**
* Handle pointerdown - start resize if on handle
*/
private handlePointerDown = (e: PointerEvent): void => {
const handle = (e.target as HTMLElement).closest('swp-resize-handle') as HTMLElement;
if (!handle) return;
const element = handle.parentElement as HTMLElement;
if (!element) return;
const eventId = element.dataset.eventId || '';
const startHeight = element.offsetHeight;
const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig);
// Store previous z-index
const container = element.closest('swp-event-group') as HTMLElement ?? element;
const prevZIndex = container.style.zIndex;
// Set resize state
this.resizeState = {
eventId,
element,
handleElement: handle,
startY: e.clientY,
startHeight,
startDurationMinutes,
pointerId: e.pointerId,
prevZIndex,
// Animation state
currentHeight: startHeight,
targetHeight: startHeight,
animationId: null
};
// Elevate z-index
container.style.zIndex = this.Z_INDEX_RESIZING;
// Capture pointer for smooth tracking
try {
handle.setPointerCapture(e.pointerId);
} catch (err) {
console.warn('Pointer capture failed:', err);
}
// Add global resizing class
document.documentElement.classList.add('swp--resizing');
// Emit resize start event
this.eventBus.emit(CoreEvents.EVENT_RESIZE_START, {
eventId,
element,
startHeight
} as IResizeStartPayload);
e.preventDefault();
};
/**
* Handle pointermove - update target height during resize
*/
private handlePointerMove = (e: PointerEvent): void => {
if (!this.resizeState) return;
const deltaY = e.clientY - this.resizeState.startY;
const minHeight = (this.MIN_HEIGHT_MINUTES / 60) * this.gridConfig.hourHeight;
const newHeight = Math.max(minHeight, this.resizeState.startHeight + deltaY);
// Set target height for animation
this.resizeState.targetHeight = newHeight;
// Start animation if not running
if (this.resizeState.animationId === null) {
this.animateHeight();
}
};
/**
* RAF animation loop for smooth height interpolation
*/
private animateHeight = (): void => {
if (!this.resizeState) return;
const diff = this.resizeState.targetHeight - this.resizeState.currentHeight;
// Stop animation when close enough
if (Math.abs(diff) < 0.5) {
this.resizeState.animationId = null;
return;
}
// Interpolate towards target (35% per frame like V1)
this.resizeState.currentHeight += diff * this.ANIMATION_SPEED;
this.resizeState.element.style.height = `${this.resizeState.currentHeight}px`;
// Update timestamp display (snapped)
this.updateTimestampDisplay();
// Continue animation
this.resizeState.animationId = requestAnimationFrame(this.animateHeight);
};
/**
* Update timestamp display with snapped end time
*/
private updateTimestampDisplay(): void {
if (!this.resizeState) return;
const timeEl = this.resizeState.element.querySelector('swp-event-time');
if (!timeEl) return;
// Get start time from element position
const top = parseFloat(this.resizeState.element.style.top) || 0;
const startMinutesFromGrid = pixelsToMinutes(top, this.gridConfig);
const startMinutes = (this.gridConfig.dayStartHour * 60) + startMinutesFromGrid;
// Calculate snapped end time from current height
const snappedHeight = snapToGrid(this.resizeState.currentHeight, this.gridConfig);
const durationMinutes = pixelsToMinutes(snappedHeight, this.gridConfig);
const endMinutes = startMinutes + durationMinutes;
// Format and update
const start = this.minutesToDate(startMinutes);
const end = this.minutesToDate(endMinutes);
timeEl.textContent = this.dateService.formatTimeRange(start, end);
}
/**
* Convert minutes since midnight to Date
*/
private minutesToDate(minutes: number): Date {
const date = new Date();
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
return date;
};
/**
* Handle pointerup - finish resize
*/
private handlePointerUp = (e: PointerEvent): void => {
if (!this.resizeState) return;
// Cancel any pending animation
if (this.resizeState.animationId !== null) {
cancelAnimationFrame(this.resizeState.animationId);
}
// Release pointer capture
try {
this.resizeState.handleElement.releasePointerCapture(e.pointerId);
} catch (err) {
console.warn('Pointer release failed:', err);
}
// Snap final height to grid
this.snapToGridFinal();
// Update timestamp one final time
this.updateTimestampDisplay();
// Restore z-index
const container = this.resizeState.element.closest('swp-event-group') as HTMLElement ?? this.resizeState.element;
container.style.zIndex = this.resizeState.prevZIndex;
// Remove global resizing class
document.documentElement.classList.remove('swp--resizing');
// Get columnKey and date from parent column
const column = this.resizeState.element.closest('swp-day-column') as HTMLElement;
const columnKey = column?.dataset.columnKey || '';
const date = column?.dataset.date || '';
// Create SwpEvent from element (reads top/height/eventId from element)
const swpEvent = SwpEvent.fromElement(
this.resizeState.element,
columnKey,
date,
this.gridConfig
);
// Emit resize end event
this.eventBus.emit(CoreEvents.EVENT_RESIZE_END, {
swpEvent
} as IResizeEndPayload);
// Reset state
this.resizeState = null;
};
/**
* Snap final height to grid interval
*/
private snapToGridFinal(): void {
if (!this.resizeState) return;
const currentHeight = this.resizeState.element.offsetHeight;
const snappedHeight = snapToGrid(currentHeight, this.gridConfig);
const minHeight = minutesToPixels(this.MIN_HEIGHT_MINUTES, this.gridConfig);
const finalHeight = Math.max(minHeight, snappedHeight);
this.resizeState.element.style.height = `${finalHeight}px`;
this.resizeState.currentHeight = finalHeight;
}
}

View file

@ -0,0 +1,33 @@
import { EntityType } from '../types/CalendarTypes';
/**
* IApiRepository<T> - Generic interface for backend API communication
*
* Used by DataSeeder to fetch initial data and by SyncManager for sync operations.
*/
export interface IApiRepository<T> {
/**
* Entity type discriminator - used for runtime routing
*/
readonly entityType: EntityType;
/**
* Send create operation to backend API
*/
sendCreate(data: T): Promise<T>;
/**
* Send update operation to backend API
*/
sendUpdate(id: string, updates: Partial<T>): Promise<T>;
/**
* Send delete operation to backend API
*/
sendDelete(id: string): Promise<void>;
/**
* Fetch all entities from backend API
*/
fetchAll(): Promise<T[]>;
}

View file

@ -0,0 +1,181 @@
import { ISync, EntityType, SyncStatus, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
import { IndexedDBContext } from './IndexedDBContext';
import { CoreEvents } from '../constants/CoreEvents';
import { diff } from 'json-diff-ts';
/**
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
*
* PROVIDES:
* - Generic CRUD operations (get, getAll, save, delete)
* - Sync status management (delegates to SyncPlugin)
* - Serialization hooks (override in subclass if needed)
*/
export abstract class BaseEntityService<T extends ISync> implements IEntityService<T> {
abstract readonly storeName: string;
abstract readonly entityType: EntityType;
private syncPlugin: SyncPlugin<T>;
private context: IndexedDBContext;
protected eventBus: IEventBus;
constructor(context: IndexedDBContext, eventBus: IEventBus) {
this.context = context;
this.eventBus = eventBus;
this.syncPlugin = new SyncPlugin<T>(this);
}
protected get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* Serialize entity before storing in IndexedDB
*/
protected serialize(entity: T): unknown {
return entity;
}
/**
* Deserialize data from IndexedDB back to entity
*/
protected deserialize(data: unknown): T {
return data as T;
}
/**
* Get a single entity by ID
*/
async get(id: string): Promise<T | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
resolve(data ? this.deserialize(data) : null);
};
request.onerror = () => {
reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`));
};
});
}
/**
* Get all entities
*/
async getAll(): Promise<T[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
const data = request.result as unknown[];
const entities = data.map(item => this.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get all ${this.entityType}s: ${request.error}`));
};
});
}
/**
* Save an entity (create or update)
* Emits ENTITY_SAVED event with operation type and changes (diff for updates)
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
async save(entity: T, silent = false): Promise<void> {
const entityId = (entity as unknown as { id: string }).id;
const existingEntity = await this.get(entityId);
const isCreate = existingEntity === null;
// Calculate changes: full entity for create, diff for update
let changes: unknown;
if (isCreate) {
changes = entity;
} else {
const existingSerialized = this.serialize(existingEntity);
const newSerialized = this.serialize(entity);
changes = diff(existingSerialized, newSerialized);
}
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 = () => {
// Only emit event if not silent (silent used for seeding)
if (!silent) {
const payload: IEntitySavedPayload = {
entityType: this.entityType,
entityId,
operation: isCreate ? 'create' : 'update',
changes,
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
}
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
};
});
}
/**
* Delete an entity
* Emits ENTITY_DELETED event
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => {
const payload: IEntityDeletedPayload = {
entityType: this.entityType,
entityId: id,
operation: 'delete',
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete ${this.entityType} ${id}: ${request.error}`));
};
});
}
// Sync methods - delegate to SyncPlugin
async markAsSynced(id: string): Promise<void> {
return this.syncPlugin.markAsSynced(id);
}
async markAsError(id: string): Promise<void> {
return this.syncPlugin.markAsError(id);
}
async getSyncStatus(id: string): Promise<SyncStatus | null> {
return this.syncPlugin.getSyncStatus(id);
}
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return this.syncPlugin.getBySyncStatus(syncStatus);
}
}

View file

@ -0,0 +1,40 @@
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
/**
* IEntityService<T> - Generic interface for entity services with sync capabilities
*
* All entity services implement this interface to enable polymorphic operations.
*/
export interface IEntityService<T extends ISync> {
/**
* Entity type discriminator for runtime routing
*/
readonly entityType: EntityType;
/**
* Get all entities from IndexedDB
*/
getAll(): Promise<T[]>;
/**
* Save an entity (create or update) to IndexedDB
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
save(entity: T, silent?: boolean): Promise<void>;
/**
* Mark entity as successfully synced
*/
markAsSynced(id: string): Promise<void>;
/**
* Mark entity as sync error
*/
markAsError(id: string): Promise<void>;
/**
* Get current sync status for an entity
*/
getSyncStatus(id: string): Promise<SyncStatus | null>;
}

View file

@ -0,0 +1,18 @@
/**
* IStore - Interface for IndexedDB ObjectStore definitions
*
* Each entity store implements this interface to define its schema.
* Enables Open/Closed Principle: IndexedDBContext works with any IStore.
*/
export interface IStore {
/**
* The name of the ObjectStore in IndexedDB
*/
readonly storeName: string;
/**
* Create the ObjectStore with its schema (indexes, keyPath, etc.)
* Called during database upgrade (onupgradeneeded event)
*/
create(db: IDBDatabase): void;
}

View file

@ -0,0 +1,108 @@
import { IStore } from './IStore';
/**
* Database configuration
*/
export interface IDBConfig {
dbName: string;
dbVersion?: number;
}
export const defaultDBConfig: IDBConfig = {
dbName: 'CalendarDB',
dbVersion: 4
};
/**
* IndexedDBContext - Database connection manager
*
* RESPONSIBILITY:
* - Opens and manages IDBDatabase connection lifecycle
* - Creates object stores via injected IStore implementations
* - Provides shared IDBDatabase instance to all services
*/
export class IndexedDBContext {
private db: IDBDatabase | null = null;
private initialized: boolean = false;
private stores: IStore[];
private config: IDBConfig;
constructor(stores: IStore[], config: IDBConfig) {
this.stores = stores;
this.config = config;
}
get dbName(): string {
return this.config.dbName;
}
/**
* Initialize and open the database
*/
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.config.dbName, this.config.dbVersion);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
this.initialized = true;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create all entity stores via injected IStore implementations
this.stores.forEach(store => {
if (!db.objectStoreNames.contains(store.storeName)) {
store.create(db);
}
});
};
});
}
/**
* Check if database is initialized
*/
public isInitialized(): boolean {
return this.initialized;
}
/**
* Get IDBDatabase instance
*/
public getDatabase(): IDBDatabase {
if (!this.db) {
throw new Error('IndexedDB not initialized. Call initialize() first.');
}
return this.db;
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
this.initialized = false;
}
}
/**
* Delete entire database (for testing/reset)
*/
static async deleteDatabase(dbName: string = defaultDBConfig.dbName): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error(`Failed to delete database: ${request.error}`));
});
}
}

View file

@ -0,0 +1,64 @@
import { ISync, SyncStatus } from '../types/CalendarTypes';
/**
* SyncPlugin<T extends ISync> - Pluggable sync functionality for entity services
*
* COMPOSITION PATTERN:
* - Encapsulates all sync-related logic in separate class
* - Composed into BaseEntityService (not inheritance)
*/
export class SyncPlugin<T extends ISync> {
constructor(private service: any) {}
/**
* Mark entity as successfully synced
*/
async markAsSynced(id: string): Promise<void> {
const entity = await this.service.get(id);
if (entity) {
entity.syncStatus = 'synced';
await this.service.save(entity);
}
}
/**
* Mark entity as sync error
*/
async markAsError(id: string): Promise<void> {
const entity = await this.service.get(id);
if (entity) {
entity.syncStatus = 'error';
await this.service.save(entity);
}
}
/**
* Get current sync status for an entity
*/
async getSyncStatus(id: string): Promise<SyncStatus | null> {
const entity = await this.service.get(id);
return entity ? entity.syncStatus : null;
}
/**
* Get entities by sync status using IndexedDB index
*/
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return new Promise((resolve, reject) => {
const transaction = this.service.db.transaction([this.service.storeName], 'readonly');
const store = transaction.objectStore(this.service.storeName);
const index = store.index('syncStatus');
const request = index.getAll(syncStatus);
request.onsuccess = () => {
const data = request.result as unknown[];
const entities = data.map(item => this.service.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get by sync status ${syncStatus}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,32 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* EventSerialization - Handles Date field serialization for IndexedDB
*
* IndexedDB doesn't store Date objects directly, so we convert:
* - Date ISO string (serialize) when writing
* - ISO string Date (deserialize) when reading
*/
export class EventSerialization {
/**
* Serialize event for IndexedDB storage
*/
static serialize(event: ICalendarEvent): unknown {
return {
...event,
start: event.start instanceof Date ? event.start.toISOString() : event.start,
end: event.end instanceof Date ? event.end.toISOString() : event.end
};
}
/**
* Deserialize event from IndexedDB storage
*/
static deserialize(data: Record<string, unknown>): ICalendarEvent {
return {
...data,
start: typeof data.start === 'string' ? new Date(data.start) : data.start,
end: typeof data.end === 'string' ? new Date(data.end) : data.end
} as ICalendarEvent;
}
}

View file

@ -0,0 +1,84 @@
import { ICalendarEvent, EntityType, IEventBus } from '../../types/CalendarTypes';
import { EventStore } from './EventStore';
import { EventSerialization } from './EventSerialization';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* EventService - CRUD operations for calendar events in IndexedDB
*
* Extends BaseEntityService for shared CRUD and sync logic.
* Provides event-specific query methods.
*/
export class EventService extends BaseEntityService<ICalendarEvent> {
readonly storeName = EventStore.STORE_NAME;
readonly entityType: EntityType = 'Event';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
protected serialize(event: ICalendarEvent): unknown {
return EventSerialization.serialize(event);
}
protected deserialize(data: unknown): ICalendarEvent {
return EventSerialization.deserialize(data as Record<string, unknown>);
}
/**
* Get events within a date range
*/
async getByDateRange(start: Date, end: Date): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('start');
const range = IDBKeyRange.lowerBound(start.toISOString());
const request = index.getAll(range);
request.onsuccess = () => {
const data = request.result as unknown[];
const events = data
.map(item => this.deserialize(item))
.filter(event => event.start <= end);
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events by date range: ${request.error}`));
};
});
}
/**
* Get events for a specific resource
*/
async getByResource(resourceId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('resourceId');
const request = index.getAll(resourceId);
request.onsuccess = () => {
const data = request.result as unknown[];
const events = data.map(item => this.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for resource ${resourceId}: ${request.error}`));
};
});
}
/**
* Get events for a resource within a date range
*/
async getByResourceAndDateRange(resourceId: string, start: Date, end: Date): Promise<ICalendarEvent[]> {
const resourceEvents = await this.getByResource(resourceId);
return resourceEvents.filter(event => event.start >= start && event.start <= end);
}
}

View file

@ -0,0 +1,37 @@
import { IStore } from '../IStore';
/**
* EventStore - IndexedDB ObjectStore definition for calendar events
*/
export class EventStore implements IStore {
static readonly STORE_NAME = 'events';
readonly storeName = EventStore.STORE_NAME;
/**
* Create the events ObjectStore with indexes
*/
create(db: IDBDatabase): void {
const store = db.createObjectStore(EventStore.STORE_NAME, { keyPath: 'id' });
// Index: start (for date range queries)
store.createIndex('start', 'start', { unique: false });
// Index: end (for date range queries)
store.createIndex('end', 'end', { unique: false });
// Index: syncStatus (for filtering by sync state)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
// Index: resourceId (for resource-mode filtering)
store.createIndex('resourceId', 'resourceId', { unique: false });
// Index: customerId (for customer-centric queries)
store.createIndex('customerId', 'customerId', { unique: false });
// Index: bookingId (for event-to-booking lookups)
store.createIndex('bookingId', 'bookingId', { unique: false });
// Compound index: startEnd (for optimized range queries)
store.createIndex('startEnd', ['start', 'end'], { unique: false });
}
}

View file

@ -0,0 +1,55 @@
import { IResource, EntityType, IEventBus } from '../../types/CalendarTypes';
import { ResourceStore } from './ResourceStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* ResourceService - CRUD operations for resources in IndexedDB
*/
export class ResourceService extends BaseEntityService<IResource> {
readonly storeName = ResourceStore.STORE_NAME;
readonly entityType: EntityType = 'Resource';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get all active resources
*/
async getActive(): Promise<IResource[]> {
const all = await this.getAll();
return all.filter(r => r.isActive !== false);
}
/**
* Get resources by IDs
*/
async getByIds(ids: string[]): Promise<IResource[]> {
if (ids.length === 0) return [];
const results = await Promise.all(ids.map(id => this.get(id)));
return results.filter((r): r is IResource => r !== null);
}
/**
* Get resources by type
*/
async getByType(type: string): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('type');
const request = index.getAll(type);
request.onsuccess = () => {
const data = request.result as IResource[];
resolve(data);
};
request.onerror = () => {
reject(new Error(`Failed to get resources by type ${type}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,17 @@
import { IStore } from '../IStore';
/**
* ResourceStore - IndexedDB ObjectStore definition for resources
*/
export class ResourceStore implements IStore {
static readonly STORE_NAME = 'resources';
readonly storeName = ResourceStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('type', 'type', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('isActive', 'isActive', { unique: false });
}
}

View file

@ -0,0 +1,83 @@
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import {
TenantSetting,
IWorkweekSettings,
IGridSettings,
ITimeFormatSettings,
IViewSettings,
IWorkweekPreset,
SettingsIds
} from '../../types/SettingsTypes';
import { SettingsStore } from './SettingsStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* SettingsService - CRUD operations for tenant settings
*
* Settings are stored as separate records per section.
* This service provides typed methods for accessing specific settings.
*/
export class SettingsService extends BaseEntityService<TenantSetting> {
readonly storeName = SettingsStore.STORE_NAME;
readonly entityType: EntityType = 'Settings';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get workweek settings
*/
async getWorkweekSettings(): Promise<IWorkweekSettings | null> {
return this.get(SettingsIds.WORKWEEK) as Promise<IWorkweekSettings | null>;
}
/**
* Get grid settings
*/
async getGridSettings(): Promise<IGridSettings | null> {
return this.get(SettingsIds.GRID) as Promise<IGridSettings | null>;
}
/**
* Get time format settings
*/
async getTimeFormatSettings(): Promise<ITimeFormatSettings | null> {
return this.get(SettingsIds.TIME_FORMAT) as Promise<ITimeFormatSettings | null>;
}
/**
* Get view settings
*/
async getViewSettings(): Promise<IViewSettings | null> {
return this.get(SettingsIds.VIEWS) as Promise<IViewSettings | null>;
}
/**
* Get workweek preset by ID
*/
async getWorkweekPreset(presetId: string): Promise<IWorkweekPreset | null> {
const settings = await this.getWorkweekSettings();
if (!settings) return null;
return settings.presets[presetId] || null;
}
/**
* Get the default workweek preset
*/
async getDefaultWorkweekPreset(): Promise<IWorkweekPreset | null> {
const settings = await this.getWorkweekSettings();
if (!settings) return null;
return settings.presets[settings.defaultPreset] || null;
}
/**
* Get all available workweek presets
*/
async getWorkweekPresets(): Promise<IWorkweekPreset[]> {
const settings = await this.getWorkweekSettings();
if (!settings) return [];
return Object.values(settings.presets);
}
}

View file

@ -0,0 +1,16 @@
import { IStore } from '../IStore';
/**
* SettingsStore - IndexedDB ObjectStore definition for tenant settings
*
* Single store for all settings sections. Settings are stored as one document
* per tenant with id='tenant-settings'.
*/
export class SettingsStore implements IStore {
static readonly STORE_NAME = 'settings';
readonly storeName = SettingsStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(SettingsStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -0,0 +1,18 @@
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { ViewConfig } from '../../core/ViewConfig';
import { ViewConfigStore } from './ViewConfigStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
export class ViewConfigService extends BaseEntityService<ViewConfig> {
readonly storeName = ViewConfigStore.STORE_NAME;
readonly entityType: EntityType = 'ViewConfig';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
async getById(id: string): Promise<ViewConfig | null> {
return this.get(id);
}
}

View file

@ -0,0 +1,10 @@
import { IStore } from '../IStore';
export class ViewConfigStore implements IStore {
static readonly STORE_NAME = 'viewconfigs';
readonly storeName = ViewConfigStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(ViewConfigStore.STORE_NAME, { keyPath: 'id' });
}
}

Some files were not shown because too many files have changed in this diff Show more