Moving away from Azure Devops #1
200 changed files with 2331 additions and 16193 deletions
|
|
@ -13,7 +13,10 @@
|
|||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(npm run css:analyze:*)",
|
||||
"Bash(npm run test:run:*)",
|
||||
"Bash(cd:*)"
|
||||
"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:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -1,266 +0,0 @@
|
|||
Selvfølgelig—her er en **opdateret, selvstændig `.md`-spec**, som **understøtter variable antal resources per team**, dynamisk kolonnebredde, ingen inline layout-styles, pipeline‐rendering i grupper, og CSS-controlling via custom properties.
|
||||
|
||||
Kopier → gem som fx:
|
||||
`grid-render-pipeline-dynamic-columns.md`
|
||||
|
||||
---
|
||||
|
||||
````md
|
||||
# Grid Render Pipeline — Dynamic Columns Spec
|
||||
|
||||
Denne specifikation beskriver en generisk render-pipeline til at bygge et
|
||||
dynamisk CSS Grid layout, hvor hver "gruppe" (teams, resources, dates) har sin
|
||||
egen renderer og pipeline-styring. Layoutet understøtter **variable antal
|
||||
resources pr. team** og beregner automatisk antal kolonner. Ingen inline-styles
|
||||
til positionering anvendes.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Formål
|
||||
|
||||
- Ét globalt CSS Grid.
|
||||
- Variabelt antal resources pr. team → dynamisk antal kolonner.
|
||||
- CSS-grid auto-placerer rækker.
|
||||
- Ingen inline styling af layout (ingen `element.style.gridRow = ...`).
|
||||
- CSS custom properties bruges til at definere dynamiske spænder.
|
||||
- Renderere har ens interface og bindes i pipeline.
|
||||
- `pipeline.run(ctx)` executer alle renderers i rækkefølge.
|
||||
- Hver renderer kan hente sin egen data (API, async osv.).
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Data Model
|
||||
|
||||
```ts
|
||||
type DateString = string;
|
||||
|
||||
interface Resource {
|
||||
id: string;
|
||||
name: string;
|
||||
dates: DateString[];
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
resources: Resource[];
|
||||
}
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Context
|
||||
|
||||
```ts
|
||||
interface RenderContext {
|
||||
grid: HTMLElement; // root grid container
|
||||
teams: Team[]; // data
|
||||
}
|
||||
```
|
||||
|
||||
`grid` er HTML-elementet med `display:grid`, og `teams` er data.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 CSS Layout
|
||||
|
||||
Grid kolonner bestemmes dynamisk via CSS variablen `--total-cols`.
|
||||
|
||||
```css
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--total-cols), minmax(0, 1fr));
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
```
|
||||
|
||||
### Teams (øverste række)
|
||||
|
||||
Hver team-header spænder **antal resources for team'et**:
|
||||
|
||||
```css
|
||||
.team-header {
|
||||
grid-column: span var(--team-cols, 1);
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### Resources (2. række)
|
||||
|
||||
```css
|
||||
.resource-cell {
|
||||
padding: 4px 2px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
```
|
||||
|
||||
### Dates (3. række)
|
||||
|
||||
```css
|
||||
.dates-cell { padding: 2px 0; }
|
||||
|
||||
.dates-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date-pill {
|
||||
padding: 3px 6px;
|
||||
background: #e3e3e3;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Beregning af kolonner
|
||||
|
||||
**Total cols = sum(resources.length for all teams)**
|
||||
|
||||
```ts
|
||||
const totalCols = ctx.teams.reduce((sum, t) => sum + t.resources.length, 0);
|
||||
ctx.grid.style.setProperty('--total-cols', totalCols.toString());
|
||||
```
|
||||
|
||||
For hvert team defineres hvor mange kolonner det spænder:
|
||||
|
||||
```ts
|
||||
cell.style.setProperty('--team-cols', team.resources.length.toString());
|
||||
```
|
||||
|
||||
> Bemærk: vi bruger **kun CSS vars** til layoutparametre – ikke inline
|
||||
> grid-row/grid-column.
|
||||
|
||||
---
|
||||
|
||||
## ⚙ Renderer Interface
|
||||
|
||||
```ts
|
||||
interface Renderer {
|
||||
id: string;
|
||||
next: Renderer | null;
|
||||
render(ctx: RenderContext): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Factory
|
||||
|
||||
```ts
|
||||
function createRenderer(id: string, fn: (ctx: RenderContext) => void): Renderer {
|
||||
return {
|
||||
id,
|
||||
next: null,
|
||||
render(ctx) {
|
||||
fn(ctx);
|
||||
if (this.next) this.next.render(ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧱 De tre render-lag (grupper)
|
||||
|
||||
### Teams
|
||||
|
||||
* Appender én `.team-header` per team.
|
||||
* Sætter `--team-cols`.
|
||||
|
||||
### Resources
|
||||
|
||||
* Appender én `.resource-cell` per resource.
|
||||
* Foregår i teams-orden → CSS auto-row sørger for næste række.
|
||||
|
||||
### Dates
|
||||
|
||||
* Appender én `.dates-cell` per resource.
|
||||
* Hver celle indeholder flere `.date-pill`.
|
||||
|
||||
Append-rækkefølge giver 3 rækker automatisk:
|
||||
|
||||
1. teams, 2) resources, 3) dates.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Pipeline
|
||||
|
||||
```ts
|
||||
function buildPipeline(renderers: Renderer[]) {
|
||||
for (let i = 0; i < renderers.length - 1; i++) {
|
||||
renderers[i].next = renderers[i + 1];
|
||||
}
|
||||
const first = renderers[0] ?? null;
|
||||
return {
|
||||
run(ctx: RenderContext) {
|
||||
if (first) first.render(ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Brug
|
||||
|
||||
```ts
|
||||
const pipeline = buildPipeline([
|
||||
teamsRenderer,
|
||||
resourcesRenderer,
|
||||
datesRenderer
|
||||
]);
|
||||
|
||||
pipeline.run(ctx);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Kørsel
|
||||
|
||||
```ts
|
||||
// 1) beregn total kolonner
|
||||
const totalCols = ctx.teams.reduce((sum, t) => sum + t.resources.length, 0);
|
||||
ctx.grid.style.setProperty('--total-cols', totalCols);
|
||||
|
||||
// 2) pipeline
|
||||
pipeline.run(ctx);
|
||||
```
|
||||
|
||||
CSS klarer resten.
|
||||
|
||||
---
|
||||
|
||||
## 🧽 Principper
|
||||
|
||||
* **Ingen inline style-positionering**.
|
||||
* **CSS Grid** owner layout.
|
||||
* **JS** owner data & rækkefølge.
|
||||
* **Renderers** er udskiftelige og genbrugelige.
|
||||
* **Append i grupper** = rækker automatisk.
|
||||
* **CSS vars** styrer spans dynamisk.
|
||||
|
||||
---
|
||||
|
||||
## ✔ TL;DR
|
||||
|
||||
* Grid-cols bestemmes ud fra data.
|
||||
* Team-header `span = resources.length`.
|
||||
* Append rækkefølge = rækker.
|
||||
* Renderere i pipeline.
|
||||
* Ingen koordinater, ingen inline layout-styles.
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
50
build.js
50
build.js
|
|
@ -32,9 +32,9 @@ async function renameFiles(dir) {
|
|||
// Build with esbuild
|
||||
async function build() {
|
||||
try {
|
||||
// Main calendar bundle (with DI)
|
||||
// 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,40 +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 })]
|
||||
});
|
||||
|
||||
// V2 standalone bundle (no DI, no dependencies on main calendar)
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/v2/entry.ts'],
|
||||
bundle: true,
|
||||
outfile: 'wwwroot/js/calendar-v2.js',
|
||||
format: 'esm',
|
||||
sourcemap: 'inline',
|
||||
target: 'es2020',
|
||||
minify: false,
|
||||
keepNames: true,
|
||||
platform: 'browser'
|
||||
});
|
||||
|
||||
console.log('V2 bundle created: wwwroot/js/calendar-v2.js');
|
||||
|
||||
// V2 demo bundle (with DI transformer for autowiring)
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/v2/demo/index.ts'],
|
||||
bundle: true,
|
||||
outfile: 'wwwroot/js/v2-demo.js',
|
||||
format: 'esm',
|
||||
sourcemap: 'inline',
|
||||
target: 'es2020',
|
||||
minify: false,
|
||||
keepNames: true,
|
||||
platform: 'browser',
|
||||
plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true })]
|
||||
});
|
||||
|
||||
console.log('V2 demo bundle created: wwwroot/js/v2-demo.js');
|
||||
console.log('Demo bundle created: wwwroot/js/demo.js');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@
|
|||
<div class="summary">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total CSS Size</div>
|
||||
<div class="stat-value">17.00 KB</div>
|
||||
<div class="stat-value">19.26 KB</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">CSS Files</div>
|
||||
|
|
@ -149,11 +149,11 @@
|
|||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="stat-label">Unused CSS Rules</div>
|
||||
<div class="stat-value">23</div>
|
||||
<div class="stat-value">43</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="stat-label">Potential Removal</div>
|
||||
<div class="stat-value">0.15%</div>
|
||||
<div class="stat-value">0.27%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -195,12 +195,12 @@
|
|||
|
||||
<tr>
|
||||
<td><strong>calendar-v2-layout.css</strong></td>
|
||||
<td>6.39 KB</td>
|
||||
<td>308</td>
|
||||
<td>38</td>
|
||||
<td>48</td>
|
||||
<td>153</td>
|
||||
<td>1</td>
|
||||
<td>8.65 KB</td>
|
||||
<td>428</td>
|
||||
<td>56</td>
|
||||
<td>71</td>
|
||||
<td>219</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
|
@ -237,17 +237,17 @@
|
|||
<h3>calendar-v2-layout.css</h3>
|
||||
<p>
|
||||
<span class="badge badge-success">
|
||||
3 unused rules
|
||||
16 unused rules
|
||||
</span>
|
||||
<span style="margin-left: 10px; color: #666;">
|
||||
Original: 6275 | After purge: 6203
|
||||
Original: 7087 | After purge: 6800
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
|
||||
<div class="rejected-list">
|
||||
&:hover<br>&[data-levels="resource date"]<br>&[data-levels="team resource date"]
|
||||
.view-chip<br>&:hover<br>&.active<br>.workweek-dropdown<br>&:focus<br>fieldset<br>legend<br>.resource-checkboxes<br>label<br>input[type="checkbox"]<br>&.btn-small<br>&[data-levels="date"] > swp-day-header<br>&[data-levels="resource date"]<br>&[data-levels="team resource date"]<br>&[data-levels="department resource date"]<br>&[data-hidden="true"]
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -257,19 +257,19 @@
|
|||
<div class="file-detail">
|
||||
<h3>calendar-v2-events.css</h3>
|
||||
<p>
|
||||
<span class="badge badge-success">
|
||||
20 unused rules
|
||||
<span class="badge badge-warning">
|
||||
26 unused rules
|
||||
</span>
|
||||
<span style="margin-left: 10px; color: #666;">
|
||||
Original: 7298 | After purge: 6810
|
||||
Original: 7047 | After purge: 6504
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
|
||||
<div class="rejected-list">
|
||||
&:hover<br>&[data-continues-before="true"]<br>&[data-continues-after="true"]<br>swp-events-layer[data-filter-active="true"] swp-event<br>swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"]<br>swp-event[data-stack-link]:not([data-stack-link*='"stackLevel":0'])<br>
|
||||
swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-event<br>.is-pink<br>.is-magenta<br>.is-violet<br>.is-deep-purple<br>.is-indigo<br>.is-light-blue<br>.is-cyan<br>.is-teal<br>.is-light-green<br>.is-lime<br>.is-yellow<br>.is-orange<br>.is-deep-orange
|
||||
&.drag-ghost<br>&:hover<br>&[data-continues-before="true"]<br>&[data-continues-after="true"]<br>swp-events-layer[data-filter-active="true"] swp-event<br>swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"]<br>swp-event[data-stack-link]:not([data-stack-link*='"stackLevel":0'])<br>
|
||||
swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-event<br>.is-red<br>.is-pink<br>.is-magenta<br>.is-purple<br>.is-violet<br>.is-deep-purple<br>.is-indigo<br>.is-blue<br>.is-light-blue<br>.is-cyan<br>.is-teal<br>.is-green<br>.is-light-green<br>.is-lime<br>.is-yellow<br>.is-amber<br>.is-orange<br>.is-deep-orange
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -280,13 +280,21 @@ swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-ev
|
|||
<h3>calendar-v2-base.css</h3>
|
||||
<p>
|
||||
<span class="badge badge-success">
|
||||
0 unused rules
|
||||
1 unused rules
|
||||
</span>
|
||||
<span style="margin-left: 10px; color: #666;">
|
||||
Original: 1701 | After purge: 1701
|
||||
Original: 1574 | After purge: 1570
|
||||
</span>
|
||||
</p>
|
||||
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
|
||||
|
||||
<details>
|
||||
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
|
||||
<div class="rejected-list">
|
||||
body
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
|
@ -297,12 +305,12 @@ swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-ev
|
|||
<li>✅ CSS usage is relatively clean.</li>
|
||||
|
||||
<li>📦 Consider consolidating similar styles to reduce duplication.</li>
|
||||
<li>🎨 Review color palette - found 38 unique colors across all files.</li>
|
||||
<li>🎨 Review color palette - found 39 unique colors across all files.</li>
|
||||
<li>🔄 Implement a build process to automatically remove unused CSS in production.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p class="timestamp">Report generated: 11.12.2025, 00.08.52</p>
|
||||
<p class="timestamp">Report generated: 17.12.2025, 21.36.53</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -33,14 +33,15 @@
|
|||
"mediaQueries": 0
|
||||
},
|
||||
"calendar-v2-layout.css": {
|
||||
"lines": 308,
|
||||
"size": "6.39 KB",
|
||||
"sizeBytes": 6548,
|
||||
"rules": 38,
|
||||
"selectors": 48,
|
||||
"properties": 153,
|
||||
"uniqueColors": 1,
|
||||
"lines": 428,
|
||||
"size": "8.65 KB",
|
||||
"sizeBytes": 8857,
|
||||
"rules": 56,
|
||||
"selectors": 71,
|
||||
"properties": 219,
|
||||
"uniqueColors": 2,
|
||||
"colors": [
|
||||
"rgba(0,0,0,0.1)",
|
||||
"rgba(0, 0, 0, 0.05)"
|
||||
],
|
||||
"mediaQueries": 0
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"summary": {
|
||||
"totalFiles": 4,
|
||||
"totalOriginalSize": 15460,
|
||||
"totalPurgedSize": 14900,
|
||||
"totalRejected": 23,
|
||||
"percentageRemoved": "0.15%",
|
||||
"potentialSavings": 560
|
||||
"totalOriginalSize": 15894,
|
||||
"totalPurgedSize": 15060,
|
||||
"totalRejected": 43,
|
||||
"percentageRemoved": "0.27%",
|
||||
"potentialSavings": 834
|
||||
},
|
||||
"fileDetails": {
|
||||
"calendar-v2.css": {
|
||||
|
|
@ -15,20 +15,34 @@
|
|||
"rejected": []
|
||||
},
|
||||
"calendar-v2-layout.css": {
|
||||
"originalSize": 6275,
|
||||
"purgedSize": 6203,
|
||||
"rejectedCount": 3,
|
||||
"originalSize": 7087,
|
||||
"purgedSize": 6800,
|
||||
"rejectedCount": 16,
|
||||
"rejected": [
|
||||
".view-chip",
|
||||
"&:hover",
|
||||
"&.active",
|
||||
".workweek-dropdown",
|
||||
"&:focus",
|
||||
"fieldset",
|
||||
"legend",
|
||||
".resource-checkboxes",
|
||||
"label",
|
||||
"input[type=\"checkbox\"]",
|
||||
"&.btn-small",
|
||||
"&[data-levels=\"date\"] > swp-day-header",
|
||||
"&[data-levels=\"resource date\"]",
|
||||
"&[data-levels=\"team resource date\"]"
|
||||
"&[data-levels=\"team resource date\"]",
|
||||
"&[data-levels=\"department resource date\"]",
|
||||
"&[data-hidden=\"true\"]"
|
||||
]
|
||||
},
|
||||
"calendar-v2-events.css": {
|
||||
"originalSize": 7298,
|
||||
"purgedSize": 6810,
|
||||
"rejectedCount": 20,
|
||||
"originalSize": 7047,
|
||||
"purgedSize": 6504,
|
||||
"rejectedCount": 26,
|
||||
"rejected": [
|
||||
"&.drag-ghost",
|
||||
"&:hover",
|
||||
"&[data-continues-before=\"true\"]",
|
||||
"&[data-continues-after=\"true\"]",
|
||||
|
|
@ -36,26 +50,33 @@
|
|||
"swp-events-layer[data-filter-active=\"true\"] swp-event[data-matches=\"true\"]",
|
||||
"swp-event[data-stack-link]:not([data-stack-link*='\"stackLevel\":0'])",
|
||||
"\nswp-event-group[data-stack-link]:not([data-stack-link*='\"stackLevel\":0']) swp-event",
|
||||
".is-red",
|
||||
".is-pink",
|
||||
".is-magenta",
|
||||
".is-purple",
|
||||
".is-violet",
|
||||
".is-deep-purple",
|
||||
".is-indigo",
|
||||
".is-blue",
|
||||
".is-light-blue",
|
||||
".is-cyan",
|
||||
".is-teal",
|
||||
".is-green",
|
||||
".is-light-green",
|
||||
".is-lime",
|
||||
".is-yellow",
|
||||
".is-amber",
|
||||
".is-orange",
|
||||
".is-deep-orange"
|
||||
]
|
||||
},
|
||||
"calendar-v2-base.css": {
|
||||
"originalSize": 1701,
|
||||
"purgedSize": 1701,
|
||||
"rejectedCount": 0,
|
||||
"rejected": []
|
||||
"originalSize": 1574,
|
||||
"purgedSize": 1570,
|
||||
"rejectedCount": 1,
|
||||
"rejected": [
|
||||
"body"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -95,7 +95,7 @@ const defaultGridConfig: IGridConfig = {
|
|||
gridStartThresholdMinutes: 30
|
||||
};
|
||||
|
||||
export function createV2Container(): Container {
|
||||
export function createContainer(): Container {
|
||||
const container = new Container();
|
||||
const builder = container.builder();
|
||||
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import { IEventBus, CalendarView } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { INavButtonClickedEventPayload } from '../types/EventTypes';
|
||||
|
||||
/**
|
||||
* NavigationButtons - Manages navigation button UI and navigation logic
|
||||
*
|
||||
* RESPONSIBILITY:
|
||||
* ===============
|
||||
* This manager owns all logic related to the <swp-nav-group> UI element
|
||||
* and performs the actual navigation calculations.
|
||||
*
|
||||
* RESPONSIBILITIES:
|
||||
* - Handles button clicks on swp-nav-button elements
|
||||
* - Validates navigation actions (prev, next, today)
|
||||
* - Calculates next/previous dates based on current view
|
||||
* - Emits NAVIGATION_COMPLETED events with new date
|
||||
* - Manages button UI listeners
|
||||
*
|
||||
* EVENT FLOW:
|
||||
* ===========
|
||||
* User clicks button → calculateNewDate() → emit NAVIGATION_COMPLETED → GridManager re-renders
|
||||
*/
|
||||
export class NavigationButtons {
|
||||
private eventBus: IEventBus;
|
||||
private buttonListeners: Map<Element, EventListener> = new Map();
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private currentDate: Date = new Date();
|
||||
private currentView: CalendarView = 'week';
|
||||
|
||||
constructor(
|
||||
eventBus: IEventBus,
|
||||
dateService: DateService,
|
||||
config: Configuration
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.setupButtonListeners();
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
private subscribeToEvents(): void {
|
||||
// Listen for view changes
|
||||
this.eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.currentView = detail.currentView;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup click listeners on all navigation buttons
|
||||
*/
|
||||
private setupButtonListeners(): void {
|
||||
const buttons = document.querySelectorAll('swp-nav-button[data-action]');
|
||||
|
||||
buttons.forEach(button => {
|
||||
const clickHandler = (event: Event) => {
|
||||
event.preventDefault();
|
||||
const action = button.getAttribute('data-action');
|
||||
if (action && this.isValidAction(action)) {
|
||||
this.handleNavigation(action);
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener('click', clickHandler);
|
||||
this.buttonListeners.set(button, clickHandler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation action
|
||||
*/
|
||||
private handleNavigation(action: string): void {
|
||||
switch (action) {
|
||||
case 'prev':
|
||||
this.navigatePrevious();
|
||||
break;
|
||||
case 'next':
|
||||
this.navigateNext();
|
||||
break;
|
||||
case 'today':
|
||||
this.navigateToday();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate in specified direction
|
||||
*/
|
||||
private navigate(direction: 'next' | 'previous'): void {
|
||||
const offset = direction === 'next' ? 1 : -1;
|
||||
let newDate: Date;
|
||||
|
||||
switch (this.currentView) {
|
||||
case 'week':
|
||||
newDate = this.dateService.addWeeks(this.currentDate, offset);
|
||||
break;
|
||||
case 'month':
|
||||
newDate = this.dateService.addMonths(this.currentDate, offset);
|
||||
break;
|
||||
case 'day':
|
||||
newDate = this.dateService.addDays(this.currentDate, offset);
|
||||
break;
|
||||
default:
|
||||
newDate = this.dateService.addWeeks(this.currentDate, offset);
|
||||
}
|
||||
|
||||
this.currentDate = newDate;
|
||||
|
||||
const payload: INavButtonClickedEventPayload = {
|
||||
direction: direction,
|
||||
newDate: newDate
|
||||
};
|
||||
|
||||
this.eventBus.emit(CoreEvents.NAV_BUTTON_CLICKED, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to next period
|
||||
*/
|
||||
private navigateNext(): void {
|
||||
this.navigate('next');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to previous period
|
||||
*/
|
||||
private navigatePrevious(): void {
|
||||
this.navigate('previous');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to today
|
||||
*/
|
||||
private navigateToday(): void {
|
||||
this.currentDate = new Date();
|
||||
|
||||
const payload: INavButtonClickedEventPayload = {
|
||||
direction: 'today',
|
||||
newDate: this.currentDate
|
||||
};
|
||||
|
||||
this.eventBus.emit(CoreEvents.NAV_BUTTON_CLICKED, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if string is a valid navigation action
|
||||
*/
|
||||
private isValidAction(action: string): boolean {
|
||||
return ['prev', 'next', 'today'].includes(action);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { CalendarView, IEventBus } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
/**
|
||||
* ViewSelectorManager - Manages view selector UI and state
|
||||
*
|
||||
* RESPONSIBILITY:
|
||||
* ===============
|
||||
* This manager owns all logic related to the <swp-view-selector> UI element.
|
||||
* It follows the principle that each functional UI element has its own manager.
|
||||
*
|
||||
* RESPONSIBILITIES:
|
||||
* - Handles button clicks on swp-view-button elements
|
||||
* - Manages current view state (day/week/month)
|
||||
* - Validates view values
|
||||
* - Emits VIEW_CHANGED and VIEW_RENDERED events
|
||||
* - Updates button UI states (data-active attributes)
|
||||
*
|
||||
* EVENT FLOW:
|
||||
* ===========
|
||||
* User clicks button → changeView() → validate → update state → emit event → update UI
|
||||
*
|
||||
* IMPLEMENTATION STATUS:
|
||||
* ======================
|
||||
* - Week view: FULLY IMPLEMENTED
|
||||
* - Day view: NOT IMPLEMENTED (button exists but no rendering)
|
||||
* - Month view: NOT IMPLEMENTED (button exists but no rendering)
|
||||
*
|
||||
* SUBSCRIBERS:
|
||||
* ============
|
||||
* - GridRenderer: Uses view parameter (currently only supports 'week')
|
||||
* - Future: DayRenderer, MonthRenderer when implemented
|
||||
*/
|
||||
export class ViewSelector {
|
||||
private eventBus: IEventBus;
|
||||
private config: Configuration;
|
||||
private buttonListeners: Map<Element, EventListener> = new Map();
|
||||
|
||||
constructor(eventBus: IEventBus, config: Configuration) {
|
||||
this.eventBus = eventBus;
|
||||
this.config = config;
|
||||
|
||||
this.setupButtonListeners();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup click listeners on all view selector buttons
|
||||
*/
|
||||
private setupButtonListeners(): void {
|
||||
const buttons = document.querySelectorAll('swp-view-button[data-view]');
|
||||
|
||||
buttons.forEach(button => {
|
||||
const clickHandler = (event: Event) => {
|
||||
event.preventDefault();
|
||||
const view = button.getAttribute('data-view');
|
||||
if (view && this.isValidView(view)) {
|
||||
this.changeView(view as CalendarView);
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener('click', clickHandler);
|
||||
this.buttonListeners.set(button, clickHandler);
|
||||
});
|
||||
|
||||
// Initialize button states
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event bus listeners
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
this.eventBus.on(CoreEvents.INITIALIZED, () => {
|
||||
this.initializeView();
|
||||
});
|
||||
|
||||
this.eventBus.on(CoreEvents.DATE_CHANGED, () => {
|
||||
this.refreshCurrentView();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the active view
|
||||
*/
|
||||
private changeView(newView: CalendarView): void {
|
||||
if (newView === this.config.currentView) {
|
||||
return; // No change
|
||||
}
|
||||
|
||||
const previousView = this.config.currentView;
|
||||
this.config.currentView = newView;
|
||||
|
||||
// Update button UI states
|
||||
this.updateButtonStates();
|
||||
|
||||
// Emit event for subscribers
|
||||
this.eventBus.emit(CoreEvents.VIEW_CHANGED, {
|
||||
previousView,
|
||||
currentView: newView
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update button states (data-active attributes)
|
||||
*/
|
||||
private updateButtonStates(): void {
|
||||
const buttons = document.querySelectorAll('swp-view-button[data-view]');
|
||||
|
||||
buttons.forEach(button => {
|
||||
const buttonView = button.getAttribute('data-view');
|
||||
|
||||
if (buttonView === this.config.currentView) {
|
||||
button.setAttribute('data-active', 'true');
|
||||
} else {
|
||||
button.removeAttribute('data-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize view on INITIALIZED event
|
||||
*/
|
||||
private initializeView(): void {
|
||||
this.updateButtonStates();
|
||||
this.emitViewRendered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit VIEW_RENDERED event
|
||||
*/
|
||||
private emitViewRendered(): void {
|
||||
this.eventBus.emit(CoreEvents.VIEW_RENDERED, {
|
||||
view: this.config.currentView
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh current view on DATE_CHANGED event
|
||||
*/
|
||||
private refreshCurrentView(): void {
|
||||
this.emitViewRendered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if string is a valid CalendarView type
|
||||
*/
|
||||
private isValidView(view: string): view is CalendarView {
|
||||
return ['day', 'week', 'month'].includes(view);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { IWorkWeekSettings } from '../configurations/WorkWeekSettings';
|
||||
import { WORK_WEEK_PRESETS, Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
/**
|
||||
* WorkweekPresetsManager - Manages workweek preset UI and state
|
||||
*
|
||||
* RESPONSIBILITY:
|
||||
* ===============
|
||||
* This manager owns all logic related to the <swp-workweek-presets> UI element.
|
||||
* It follows the principle that each functional UI element has its own manager.
|
||||
*
|
||||
* RESPONSIBILITIES:
|
||||
* - Owns WORK_WEEK_PRESETS data
|
||||
* - Handles button clicks on swp-preset-button elements
|
||||
* - Manages current workweek preset state
|
||||
* - Validates preset IDs
|
||||
* - Emits WORKWEEK_CHANGED events
|
||||
* - Updates button UI states (data-active attributes)
|
||||
*
|
||||
* EVENT FLOW:
|
||||
* ===========
|
||||
* User clicks button → changePreset() → validate → update state → emit event → update UI
|
||||
*
|
||||
* SUBSCRIBERS:
|
||||
* ============
|
||||
* - ConfigManager: Updates CSS variables (--grid-columns)
|
||||
* - GridManager: Re-renders grid with new column count
|
||||
* - CalendarManager: Relays to header update (via workweek:header-update)
|
||||
* - HeaderManager: Updates date headers
|
||||
*/
|
||||
export class WorkweekPresets {
|
||||
private eventBus: IEventBus;
|
||||
private config: Configuration;
|
||||
private buttonListeners: Map<Element, EventListener> = new Map();
|
||||
|
||||
constructor(eventBus: IEventBus, config: Configuration) {
|
||||
this.eventBus = eventBus;
|
||||
this.config = config;
|
||||
|
||||
this.setupButtonListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup click listeners on all workweek preset buttons
|
||||
*/
|
||||
private setupButtonListeners(): void {
|
||||
const buttons = document.querySelectorAll('swp-preset-button[data-workweek]');
|
||||
|
||||
buttons.forEach(button => {
|
||||
const clickHandler = (event: Event) => {
|
||||
event.preventDefault();
|
||||
const presetId = button.getAttribute('data-workweek');
|
||||
if (presetId) {
|
||||
this.changePreset(presetId);
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener('click', clickHandler);
|
||||
this.buttonListeners.set(button, clickHandler);
|
||||
});
|
||||
|
||||
// Initialize button states
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the active workweek preset
|
||||
*/
|
||||
private changePreset(presetId: string): void {
|
||||
if (!WORK_WEEK_PRESETS[presetId]) {
|
||||
console.warn(`Invalid preset ID "${presetId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (presetId === this.config.currentWorkWeek) {
|
||||
return; // No change
|
||||
}
|
||||
|
||||
const previousPresetId = this.config.currentWorkWeek;
|
||||
this.config.currentWorkWeek = presetId;
|
||||
|
||||
const settings = WORK_WEEK_PRESETS[presetId];
|
||||
|
||||
// Update button UI states
|
||||
this.updateButtonStates();
|
||||
|
||||
// Emit event for subscribers
|
||||
this.eventBus.emit(CoreEvents.WORKWEEK_CHANGED, {
|
||||
workWeekId: presetId,
|
||||
previousWorkWeekId: previousPresetId,
|
||||
settings: settings
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update button states (data-active attributes)
|
||||
*/
|
||||
private updateButtonStates(): void {
|
||||
const buttons = document.querySelectorAll('swp-preset-button[data-workweek]');
|
||||
|
||||
buttons.forEach(button => {
|
||||
const buttonPresetId = button.getAttribute('data-workweek');
|
||||
|
||||
if (buttonPresetId === this.config.currentWorkWeek) {
|
||||
button.setAttribute('data-active', 'true');
|
||||
} else {
|
||||
button.removeAttribute('data-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import { ICalendarConfig } from './ICalendarConfig';
|
||||
import { IGridSettings } from './GridSettings';
|
||||
import { IDateViewSettings } from './DateViewSettings';
|
||||
import { ITimeFormatConfig } from './TimeFormatConfig';
|
||||
import { IWorkWeekSettings } from './WorkWeekSettings';
|
||||
import { CalendarView } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* All-day event layout constants
|
||||
*/
|
||||
export const ALL_DAY_CONSTANTS = {
|
||||
EVENT_HEIGHT: 22,
|
||||
EVENT_GAP: 2,
|
||||
CONTAINER_PADDING: 4,
|
||||
MAX_COLLAPSED_ROWS: 4,
|
||||
get SINGLE_ROW_HEIGHT() {
|
||||
return this.EVENT_HEIGHT + this.EVENT_GAP; // 28px
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Work week presets - Configuration data
|
||||
*/
|
||||
export const WORK_WEEK_PRESETS: { [key: string]: IWorkWeekSettings } = {
|
||||
'standard': {
|
||||
id: 'standard',
|
||||
workDays: [1, 2, 3, 4, 5],
|
||||
totalDays: 5,
|
||||
firstWorkDay: 1
|
||||
},
|
||||
'compressed': {
|
||||
id: 'compressed',
|
||||
workDays: [1, 2, 3, 4],
|
||||
totalDays: 4,
|
||||
firstWorkDay: 1
|
||||
},
|
||||
'midweek': {
|
||||
id: 'midweek',
|
||||
workDays: [3, 4, 5],
|
||||
totalDays: 3,
|
||||
firstWorkDay: 3
|
||||
},
|
||||
'weekend': {
|
||||
id: 'weekend',
|
||||
workDays: [6, 7],
|
||||
totalDays: 2,
|
||||
firstWorkDay: 6
|
||||
},
|
||||
'fullweek': {
|
||||
id: 'fullweek',
|
||||
workDays: [1, 2, 3, 4, 5, 6, 7],
|
||||
totalDays: 7,
|
||||
firstWorkDay: 1
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration - DTO container for all configuration
|
||||
* Pure data object loaded from JSON via ConfigManager
|
||||
*/
|
||||
export class Configuration {
|
||||
private static _instance: Configuration | null = null;
|
||||
|
||||
public config: ICalendarConfig;
|
||||
public gridSettings: IGridSettings;
|
||||
public dateViewSettings: IDateViewSettings;
|
||||
public timeFormatConfig: ITimeFormatConfig;
|
||||
public currentWorkWeek: string;
|
||||
public currentView: CalendarView;
|
||||
public selectedDate: Date;
|
||||
public apiEndpoint: string = '/api';
|
||||
|
||||
constructor(
|
||||
config: ICalendarConfig,
|
||||
gridSettings: IGridSettings,
|
||||
dateViewSettings: IDateViewSettings,
|
||||
timeFormatConfig: ITimeFormatConfig,
|
||||
currentWorkWeek: string,
|
||||
currentView: CalendarView,
|
||||
selectedDate: Date = new Date()
|
||||
) {
|
||||
this.config = config;
|
||||
this.gridSettings = gridSettings;
|
||||
this.dateViewSettings = dateViewSettings;
|
||||
this.timeFormatConfig = timeFormatConfig;
|
||||
this.currentWorkWeek = currentWorkWeek;
|
||||
this.currentView = currentView;
|
||||
this.selectedDate = selectedDate;
|
||||
|
||||
// Store as singleton instance for web components
|
||||
Configuration._instance = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Configuration instance
|
||||
* Used by web components that can't use dependency injection
|
||||
*/
|
||||
public static getInstance(): Configuration {
|
||||
if (!Configuration._instance) {
|
||||
throw new Error('Configuration has not been initialized. Call ConfigManager.load() first.');
|
||||
}
|
||||
return Configuration._instance;
|
||||
}
|
||||
|
||||
setSelectedDate(date: Date): void {
|
||||
this.selectedDate = date;
|
||||
}
|
||||
|
||||
getWorkWeekSettings(): IWorkWeekSettings {
|
||||
return WORK_WEEK_PRESETS[this.currentWorkWeek] || WORK_WEEK_PRESETS['standard'];
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility alias
|
||||
export { Configuration as CalendarConfig };
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Configuration } from './CalendarConfig';
|
||||
import { ICalendarConfig } from './ICalendarConfig';
|
||||
import { TimeFormatter } from '../utils/TimeFormatter';
|
||||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { IWorkWeekSettings } from './WorkWeekSettings';
|
||||
|
||||
/**
|
||||
* ConfigManager - Configuration loader and CSS property manager
|
||||
* Loads JSON and creates Configuration instance
|
||||
* Listens to events and manages CSS custom properties for dynamic styling
|
||||
*/
|
||||
export class ConfigManager {
|
||||
private eventBus: IEventBus;
|
||||
private config: Configuration;
|
||||
|
||||
constructor(eventBus: IEventBus, config: Configuration) {
|
||||
this.eventBus = eventBus;
|
||||
this.config = config;
|
||||
|
||||
this.setupEventListeners();
|
||||
this.syncGridCSSVariables();
|
||||
this.syncWorkweekCSSVariables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for dynamic CSS updates
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
// Listen to workweek changes and update CSS accordingly
|
||||
this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event: Event) => {
|
||||
const { settings } = (event as CustomEvent<{ settings: IWorkWeekSettings }>).detail;
|
||||
this.syncWorkweekCSSVariables(settings);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync grid-related CSS variables from configuration
|
||||
*/
|
||||
private syncGridCSSVariables(): void {
|
||||
const gridSettings = this.config.gridSettings;
|
||||
|
||||
document.documentElement.style.setProperty('--hour-height', `${gridSettings.hourHeight}px`);
|
||||
document.documentElement.style.setProperty('--day-start-hour', gridSettings.dayStartHour.toString());
|
||||
document.documentElement.style.setProperty('--day-end-hour', gridSettings.dayEndHour.toString());
|
||||
document.documentElement.style.setProperty('--work-start-hour', gridSettings.workStartHour.toString());
|
||||
document.documentElement.style.setProperty('--work-end-hour', gridSettings.workEndHour.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync workweek-related CSS variables
|
||||
*/
|
||||
private syncWorkweekCSSVariables(workWeekSettings?: IWorkWeekSettings): void {
|
||||
const settings = workWeekSettings || this.config.getWorkWeekSettings();
|
||||
document.documentElement.style.setProperty('--grid-columns', settings.totalDays.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from JSON and create Configuration instance
|
||||
*/
|
||||
static async load(): Promise<Configuration> {
|
||||
const response = await fetch('/wwwroot/data/calendar-config.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load config: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Build main config
|
||||
const mainConfig: ICalendarConfig = {
|
||||
scrollbarWidth: data.scrollbar.width,
|
||||
scrollbarColor: data.scrollbar.color,
|
||||
scrollbarTrackColor: data.scrollbar.trackColor,
|
||||
scrollbarHoverColor: data.scrollbar.hoverColor,
|
||||
scrollbarBorderRadius: data.scrollbar.borderRadius,
|
||||
allowDrag: data.interaction.allowDrag,
|
||||
allowResize: data.interaction.allowResize,
|
||||
allowCreate: data.interaction.allowCreate,
|
||||
apiEndpoint: data.api.endpoint,
|
||||
dateFormat: data.api.dateFormat,
|
||||
timeFormat: data.api.timeFormat,
|
||||
enableSearch: data.features.enableSearch,
|
||||
enableTouch: data.features.enableTouch,
|
||||
defaultEventDuration: data.eventDefaults.defaultEventDuration,
|
||||
minEventDuration: data.gridSettings.snapInterval,
|
||||
maxEventDuration: data.eventDefaults.maxEventDuration
|
||||
};
|
||||
|
||||
// Create Configuration instance
|
||||
const config = new Configuration(
|
||||
mainConfig,
|
||||
data.gridSettings,
|
||||
data.dateViewSettings,
|
||||
data.timeFormatConfig,
|
||||
data.currentWorkWeek,
|
||||
data.currentView || 'week'
|
||||
);
|
||||
|
||||
// Configure TimeFormatter
|
||||
TimeFormatter.configure(config.timeFormatConfig);
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { ViewPeriod } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* View settings for date-based calendar mode
|
||||
*/
|
||||
export interface IDateViewSettings {
|
||||
period: ViewPeriod;
|
||||
weekDays: number;
|
||||
firstDayOfWeek: number;
|
||||
showAllDay: boolean;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/**
|
||||
* Grid display settings interface
|
||||
*/
|
||||
export interface IGridSettings {
|
||||
dayStartHour: number;
|
||||
dayEndHour: number;
|
||||
workStartHour: number;
|
||||
workEndHour: number;
|
||||
hourHeight: number;
|
||||
snapInterval: number;
|
||||
fitToWidth: boolean;
|
||||
scrollToHour: number | null;
|
||||
gridStartThresholdMinutes: number;
|
||||
showCurrentTime: boolean;
|
||||
showWorkHours: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid settings utility functions
|
||||
*/
|
||||
export namespace GridSettingsUtils {
|
||||
export function isValidSnapInterval(interval: number): boolean {
|
||||
return [5, 10, 15, 30, 60].includes(interval);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/**
|
||||
* Main calendar configuration interface
|
||||
*/
|
||||
export interface ICalendarConfig {
|
||||
// Scrollbar styling
|
||||
scrollbarWidth: number;
|
||||
scrollbarColor: string;
|
||||
scrollbarTrackColor: string;
|
||||
scrollbarHoverColor: string;
|
||||
scrollbarBorderRadius: number;
|
||||
|
||||
// Interaction settings
|
||||
allowDrag: boolean;
|
||||
allowResize: boolean;
|
||||
allowCreate: boolean;
|
||||
|
||||
// API settings
|
||||
apiEndpoint: string;
|
||||
dateFormat: string;
|
||||
timeFormat: string;
|
||||
|
||||
// Feature flags
|
||||
enableSearch: boolean;
|
||||
enableTouch: boolean;
|
||||
|
||||
// Event defaults
|
||||
defaultEventDuration: number;
|
||||
minEventDuration: number;
|
||||
maxEventDuration: number;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
/**
|
||||
* Time format configuration settings
|
||||
*/
|
||||
export interface ITimeFormatConfig {
|
||||
timezone: string;
|
||||
use24HourFormat: boolean;
|
||||
locale: string;
|
||||
dateFormat: 'locale' | 'technical';
|
||||
showSeconds: boolean;
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* Work week configuration settings
|
||||
*/
|
||||
export interface IWorkWeekSettings {
|
||||
id: string;
|
||||
workDays: number[];
|
||||
totalDays: number;
|
||||
firstWorkDay: number;
|
||||
}
|
||||
|
|
@ -1,61 +1,71 @@
|
|||
/**
|
||||
* CoreEvents - Consolidated essential events for the calendar
|
||||
* Reduces complexity from 102+ events to ~20 core events
|
||||
*/
|
||||
export const CoreEvents = {
|
||||
// Lifecycle events (3)
|
||||
// Lifecycle events
|
||||
INITIALIZED: 'core:initialized',
|
||||
READY: 'core:ready',
|
||||
DESTROYED: 'core:destroyed',
|
||||
|
||||
// View events (3)
|
||||
// View events
|
||||
VIEW_CHANGED: 'view:changed',
|
||||
VIEW_RENDERED: 'view:rendered',
|
||||
WORKWEEK_CHANGED: 'workweek:changed',
|
||||
|
||||
// Navigation events (4)
|
||||
NAV_BUTTON_CLICKED: 'nav:button-clicked',
|
||||
// Navigation events
|
||||
DATE_CHANGED: 'nav:date-changed',
|
||||
NAVIGATION_COMPLETED: 'nav:navigation-completed',
|
||||
NAVIGATE_TO_EVENT: 'nav:navigate-to-event',
|
||||
|
||||
// Data events (5)
|
||||
// Data events
|
||||
DATA_LOADING: 'data:loading',
|
||||
DATA_LOADED: 'data:loaded',
|
||||
DATA_ERROR: 'data:error',
|
||||
EVENTS_FILTERED: 'data:events-filtered',
|
||||
REMOTE_UPDATE_RECEIVED: 'data:remote-update',
|
||||
|
||||
// Grid events (3)
|
||||
// Grid events
|
||||
GRID_RENDERED: 'grid:rendered',
|
||||
GRID_CLICKED: 'grid:clicked',
|
||||
CELL_SELECTED: 'grid:cell-selected',
|
||||
|
||||
// Event management (4)
|
||||
// Event management
|
||||
EVENT_CREATED: 'event:created',
|
||||
EVENT_UPDATED: 'event:updated',
|
||||
EVENT_DELETED: 'event:deleted',
|
||||
EVENT_SELECTED: 'event:selected',
|
||||
|
||||
// System events (3)
|
||||
ERROR: 'system:error',
|
||||
REFRESH_REQUESTED: 'system:refresh',
|
||||
OFFLINE_MODE_CHANGED: 'system:offline-mode-changed',
|
||||
// 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',
|
||||
|
||||
// Sync events (4)
|
||||
// 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',
|
||||
SYNC_RETRY: 'sync:retry',
|
||||
|
||||
// Entity events (3) - for audit and sync
|
||||
// Entity events - for audit and sync
|
||||
ENTITY_SAVED: 'entity:saved',
|
||||
ENTITY_DELETED: 'entity:deleted',
|
||||
|
||||
// Audit events
|
||||
AUDIT_LOGGED: 'audit:logged',
|
||||
|
||||
// Filter events (1)
|
||||
FILTER_CHANGED: 'filter:changed',
|
||||
|
||||
// Rendering events (1)
|
||||
// Rendering events
|
||||
EVENTS_RENDERED: 'events:rendered'
|
||||
} as const;
|
||||
|
|
@ -11,11 +11,15 @@ import { ResizeManager } from '../managers/ResizeManager';
|
|||
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
|
||||
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||
import { SettingsService } from '../storage/settings/SettingsService';
|
||||
import { ResourceService } from '../storage/resources/ResourceService';
|
||||
import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService';
|
||||
import { IWorkweekPreset } from '../types/SettingsTypes';
|
||||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { CalendarEvents } from './CalendarEvents';
|
||||
import {
|
||||
CalendarEvents,
|
||||
RenderPayload,
|
||||
WorkweekChangePayload,
|
||||
ViewUpdatePayload
|
||||
} from './CalendarEvents';
|
||||
|
||||
export class CalendarApp {
|
||||
private animator!: NavigationAnimator;
|
||||
|
|
@ -37,7 +41,6 @@ export class CalendarApp {
|
|||
private headerDrawerRenderer: HeaderDrawerRenderer,
|
||||
private eventPersistenceManager: EventPersistenceManager,
|
||||
private settingsService: SettingsService,
|
||||
private resourceService: ResourceService,
|
||||
private viewConfigService: ViewConfigService,
|
||||
private eventBus: IEventBus
|
||||
) {}
|
||||
|
|
@ -45,7 +48,12 @@ export class CalendarApp {
|
|||
async init(container: HTMLElement): Promise<void> {
|
||||
this.container = container;
|
||||
|
||||
// Load default workweek preset from settings
|
||||
// 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
|
||||
|
|
@ -54,11 +62,11 @@ export class CalendarApp {
|
|||
container.querySelector('swp-content-track') as HTMLElement
|
||||
);
|
||||
|
||||
// Render time axis (from settings later, hardcoded for now)
|
||||
// Render time axis from settings
|
||||
this.timeAxisRenderer.render(
|
||||
container.querySelector('#time-axis') as HTMLElement,
|
||||
6,
|
||||
18
|
||||
gridSettings.dayStartHour,
|
||||
gridSettings.dayEndHour
|
||||
);
|
||||
|
||||
// Init managers
|
||||
|
|
@ -93,22 +101,22 @@ export class CalendarApp {
|
|||
});
|
||||
|
||||
// Render command via EventBus
|
||||
this.eventBus.on(CalendarEvents.CMD_RENDER, ((e: CustomEvent) => {
|
||||
const { viewId } = e.detail;
|
||||
this.eventBus.on(CalendarEvents.CMD_RENDER, (e: Event) => {
|
||||
const { viewId } = (e as CustomEvent<RenderPayload>).detail;
|
||||
this.handleRenderCommand(viewId);
|
||||
}) as EventListener);
|
||||
});
|
||||
|
||||
// Workweek change via EventBus
|
||||
this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, ((e: CustomEvent) => {
|
||||
const { presetId } = e.detail;
|
||||
this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, (e: Event) => {
|
||||
const { presetId } = (e as CustomEvent<WorkweekChangePayload>).detail;
|
||||
this.handleWorkweekChange(presetId);
|
||||
}) as EventListener);
|
||||
});
|
||||
|
||||
// View update via EventBus
|
||||
this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, ((e: CustomEvent) => {
|
||||
const { type, values } = e.detail;
|
||||
this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, (e: Event) => {
|
||||
const { type, values } = (e as CustomEvent<ViewUpdatePayload>).detail;
|
||||
this.handleViewUpdate(type, values);
|
||||
}) as EventListener);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRenderCommand(viewId: string): Promise<void> {
|
||||
|
|
@ -10,3 +10,19 @@ export const CalendarEvents = {
|
|||
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[];
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// Core EventBus using pure DOM CustomEvents
|
||||
import { IEventLogEntry, IListenerEntry, IEventBus } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
|
|
@ -89,7 +88,7 @@ export class EventBus implements IEventBus {
|
|||
/**
|
||||
* Log event with console grouping
|
||||
*/
|
||||
private logEventWithGrouping(eventType: string, detail: unknown): void {
|
||||
private logEventWithGrouping(eventType: string, _detail: unknown): void {
|
||||
// Extract category from event type (e.g., 'calendar:datechanged' → 'calendar')
|
||||
const category = this.extractCategory(eventType);
|
||||
|
||||
|
|
@ -98,10 +97,8 @@ export class EventBus implements IEventBus {
|
|||
return;
|
||||
}
|
||||
|
||||
// Get category emoji and color
|
||||
const { emoji, color } = this.getCategoryStyle(category);
|
||||
|
||||
// Use collapsed group to reduce visual noise
|
||||
// Get category emoji and color (used for future console styling)
|
||||
this.getCategoryStyle(category);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -132,12 +129,12 @@ export class EventBus implements IEventBus {
|
|||
*/
|
||||
private getCategoryStyle(category: string): { emoji: string; color: string } {
|
||||
const styles: { [key: string]: { emoji: string; color: string } } = {
|
||||
calendar: { emoji: '🗓️', color: '#2196F3' },
|
||||
calendar: { emoji: '📅', color: '#2196F3' },
|
||||
grid: { emoji: '📊', color: '#4CAF50' },
|
||||
event: { emoji: '📅', color: '#FF9800' },
|
||||
event: { emoji: '📌', color: '#FF9800' },
|
||||
scroll: { emoji: '📜', color: '#9C27B0' },
|
||||
navigation: { emoji: '🧭', color: '#F44336' },
|
||||
view: { emoji: '👁️', color: '#00BCD4' },
|
||||
view: { emoji: '👁', color: '#00BCD4' },
|
||||
default: { emoji: '📢', color: '#607D8B' }
|
||||
};
|
||||
|
||||
|
|
@ -175,6 +172,3 @@ export class EventBus implements IEventBus {
|
|||
this.debug = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const eventBus = new EventBus();
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { CalendarView } from '../types/CalendarTypes';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
|
||||
/**
|
||||
* DateColumnDataSource - Provides date-based columns
|
||||
*
|
||||
* Calculates which dates to display based on:
|
||||
* - Current date
|
||||
* - Current view (day/week/month)
|
||||
* - Workweek settings
|
||||
*
|
||||
* Also fetches and filters events per column using EventService.
|
||||
*/
|
||||
export class DateColumnDataSource implements IColumnDataSource {
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private eventService: EventService;
|
||||
private currentDate: Date;
|
||||
private currentView: CalendarView;
|
||||
|
||||
constructor(
|
||||
dateService: DateService,
|
||||
config: Configuration,
|
||||
eventService: EventService
|
||||
) {
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.eventService = eventService;
|
||||
this.currentDate = new Date();
|
||||
this.currentView = this.config.currentView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get columns (dates) to display with their events
|
||||
* Each column fetches its own events directly from EventService
|
||||
*/
|
||||
public async getColumns(): Promise<IColumnInfo[]> {
|
||||
let dates: Date[];
|
||||
|
||||
switch (this.currentView) {
|
||||
case 'week':
|
||||
dates = this.getWeekDates();
|
||||
break;
|
||||
case 'month':
|
||||
dates = this.getMonthDates();
|
||||
break;
|
||||
case 'day':
|
||||
dates = [this.currentDate];
|
||||
break;
|
||||
default:
|
||||
dates = this.getWeekDates();
|
||||
}
|
||||
|
||||
// Fetch events for each column directly from EventService
|
||||
const columnsWithEvents = await Promise.all(
|
||||
dates.map(async date => ({
|
||||
identifier: this.dateService.formatISODate(date),
|
||||
data: date,
|
||||
events: await this.eventService.getByDateRange(
|
||||
this.dateService.startOfDay(date),
|
||||
this.dateService.endOfDay(date)
|
||||
),
|
||||
groupId: 'week' // All columns in date mode share same group for spanning
|
||||
}))
|
||||
);
|
||||
|
||||
return columnsWithEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type of datasource
|
||||
*/
|
||||
public getType(): 'date' | 'resource' {
|
||||
return 'date';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this datasource is in resource mode
|
||||
*/
|
||||
public isResource(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current date
|
||||
*/
|
||||
public setCurrentDate(date: Date): void {
|
||||
this.currentDate = date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current date
|
||||
*/
|
||||
public getCurrentDate(): Date {
|
||||
return this.currentDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current view
|
||||
*/
|
||||
public setCurrentView(view: CalendarView): void {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dates for week view based on workweek settings
|
||||
*/
|
||||
private getWeekDates(): Date[] {
|
||||
const weekStart = this.getISOWeekStart(this.currentDate);
|
||||
const workWeekSettings = this.config.getWorkWeekSettings();
|
||||
return this.dateService.getWorkWeekDates(weekStart, workWeekSettings.workDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dates in current month
|
||||
*/
|
||||
private getMonthDates(): Date[] {
|
||||
const dates: Date[] = [];
|
||||
const monthStart = this.getMonthStart(this.currentDate);
|
||||
const monthEnd = this.getMonthEnd(this.currentDate);
|
||||
|
||||
const totalDays = Math.ceil((monthEnd.getTime() - monthStart.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
dates.push(this.dateService.addDays(monthStart, i));
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ISO week start (Monday)
|
||||
*/
|
||||
private getISOWeekStart(date: Date): Date {
|
||||
const weekBounds = this.dateService.getWeekBounds(date);
|
||||
return this.dateService.startOfDay(weekBounds.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get month start
|
||||
*/
|
||||
private getMonthStart(date: Date): Date {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
return this.dateService.startOfDay(new Date(year, month, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get month end
|
||||
*/
|
||||
private getMonthEnd(date: Date): Date {
|
||||
const nextMonth = this.dateService.addMonths(date, 1);
|
||||
const firstOfNextMonth = this.getMonthStart(nextMonth);
|
||||
return this.dateService.endOfDay(this.dateService.addDays(firstOfNextMonth, -1));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource';
|
||||
import { CalendarView } from '../types/CalendarTypes';
|
||||
import { ResourceService } from '../storage/resources/ResourceService';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
import { DateService } from '../utils/DateService';
|
||||
|
||||
/**
|
||||
* ResourceColumnDataSource - Provides resource-based columns
|
||||
*
|
||||
* In resource mode, columns represent resources (people, rooms, etc.)
|
||||
* instead of dates. Events are filtered by current date AND resourceId.
|
||||
*/
|
||||
export class ResourceColumnDataSource implements IColumnDataSource {
|
||||
private resourceService: ResourceService;
|
||||
private eventService: EventService;
|
||||
private dateService: DateService;
|
||||
private currentDate: Date;
|
||||
private currentView: CalendarView;
|
||||
|
||||
constructor(
|
||||
resourceService: ResourceService,
|
||||
eventService: EventService,
|
||||
dateService: DateService
|
||||
) {
|
||||
this.resourceService = resourceService;
|
||||
this.eventService = eventService;
|
||||
this.dateService = dateService;
|
||||
this.currentDate = new Date();
|
||||
this.currentView = 'day';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get columns (resources) to display with their events
|
||||
*/
|
||||
public async getColumns(): Promise<IColumnInfo[]> {
|
||||
const resources = await this.resourceService.getActive();
|
||||
const startDate = this.dateService.startOfDay(this.currentDate);
|
||||
const endDate = this.dateService.endOfDay(this.currentDate);
|
||||
|
||||
// Fetch events for each resource in parallel
|
||||
const columnsWithEvents = await Promise.all(
|
||||
resources.map(async resource => ({
|
||||
identifier: resource.id,
|
||||
data: resource,
|
||||
events: await this.eventService.getByResourceAndDateRange(resource.id, startDate, endDate),
|
||||
groupId: resource.id // Each resource is its own group - no spanning across resources
|
||||
}))
|
||||
);
|
||||
|
||||
return columnsWithEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type of datasource
|
||||
*/
|
||||
public getType(): 'date' | 'resource' {
|
||||
return 'resource';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this datasource is in resource mode
|
||||
*/
|
||||
public isResource(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current date (for event filtering)
|
||||
*/
|
||||
public setCurrentDate(date: Date): void {
|
||||
this.currentDate = date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current view
|
||||
*/
|
||||
public setCurrentView(view: CalendarView): void {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current date (for event filtering)
|
||||
*/
|
||||
public getCurrentDate(): Date {
|
||||
return this.currentDate;
|
||||
}
|
||||
}
|
||||
5
src/demo/index.ts
Normal file
5
src/demo/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createContainer } from '../CompositionRoot';
|
||||
import { DemoApp } from './DemoApp';
|
||||
|
||||
const container = createContainer();
|
||||
container.resolveType<DemoApp>().init().catch(console.error);
|
||||
|
|
@ -1,393 +0,0 @@
|
|||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { CalendarEventType } from '../types/BookingTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { TimeFormatter } from '../utils/TimeFormatter';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { EventId } from '../types/EventId';
|
||||
|
||||
/**
|
||||
* Base class for event elements
|
||||
*/
|
||||
export abstract class BaseSwpEventElement extends HTMLElement {
|
||||
protected dateService: DateService;
|
||||
protected config: Configuration;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Get singleton instance for web components (can't use DI)
|
||||
this.config = Configuration.getInstance();
|
||||
this.dateService = new DateService(this.config);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Abstract Methods
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a clone for drag operations
|
||||
* Must be implemented by subclasses
|
||||
*/
|
||||
public abstract createClone(): HTMLElement;
|
||||
|
||||
// ============================================
|
||||
// Common Getters/Setters
|
||||
// ============================================
|
||||
|
||||
get eventId(): string {
|
||||
return this.dataset.eventId || '';
|
||||
}
|
||||
set eventId(value: string) {
|
||||
this.dataset.eventId = value;
|
||||
}
|
||||
|
||||
get start(): Date {
|
||||
return new Date(this.dataset.start || '');
|
||||
}
|
||||
set start(value: Date) {
|
||||
this.dataset.start = this.dateService.toUTC(value);
|
||||
}
|
||||
|
||||
get end(): Date {
|
||||
return new Date(this.dataset.end || '');
|
||||
}
|
||||
set end(value: Date) {
|
||||
this.dataset.end = this.dateService.toUTC(value);
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.dataset.title || '';
|
||||
}
|
||||
set title(value: string) {
|
||||
this.dataset.title = value;
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return this.dataset.description || '';
|
||||
}
|
||||
set description(value: string) {
|
||||
this.dataset.description = value;
|
||||
}
|
||||
|
||||
get type(): string {
|
||||
return this.dataset.type || 'work';
|
||||
}
|
||||
set type(value: string) {
|
||||
this.dataset.type = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Component for timed calendar events (Light DOM)
|
||||
*/
|
||||
export class SwpEventElement extends BaseSwpEventElement {
|
||||
|
||||
/**
|
||||
* Observed attributes - changes trigger attributeChangedCallback
|
||||
*/
|
||||
static get observedAttributes() {
|
||||
return ['data-start', 'data-end', 'data-title', 'data-description', 'data-type'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when element is added to DOM
|
||||
*/
|
||||
connectedCallback() {
|
||||
if (!this.hasChildNodes()) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when observed attribute changes
|
||||
*/
|
||||
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
|
||||
if (oldValue !== newValue && this.isConnected) {
|
||||
this.updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public Methods
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Update event position during drag
|
||||
* Uses the event's existing date, only updates the time based on Y position
|
||||
* @param snappedY - The Y position in pixels
|
||||
*/
|
||||
public updatePosition(snappedY: number): void {
|
||||
// 1. Update visual position
|
||||
this.style.top = `${snappedY + 1}px`;
|
||||
|
||||
// 2. Calculate new timestamps (keep existing date, only change time)
|
||||
const existingDate = this.start;
|
||||
const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY);
|
||||
|
||||
// 3. Update data attributes (triggers attributeChangedCallback)
|
||||
const startDate = this.dateService.createDateAtTime(existingDate, startMinutes);
|
||||
let endDate = this.dateService.createDateAtTime(existingDate, endMinutes);
|
||||
|
||||
// Handle cross-midnight events
|
||||
if (endMinutes >= 1440) {
|
||||
const extraDays = Math.floor(endMinutes / 1440);
|
||||
endDate = this.dateService.addDays(endDate, extraDays);
|
||||
}
|
||||
|
||||
this.start = startDate;
|
||||
this.end = endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update event height during resize
|
||||
* @param newHeight - The new height in pixels
|
||||
*/
|
||||
public updateHeight(newHeight: number): void {
|
||||
// 1. Update visual height
|
||||
this.style.height = `${newHeight}px`;
|
||||
|
||||
// 2. Calculate new end time based on height
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const { hourHeight, snapInterval } = gridSettings;
|
||||
|
||||
// Get current start time
|
||||
const start = this.start;
|
||||
|
||||
// Calculate duration from height
|
||||
const rawDurationMinutes = (newHeight / hourHeight) * 60;
|
||||
|
||||
// Snap duration to grid interval (like drag & drop)
|
||||
const snappedDurationMinutes = Math.round(rawDurationMinutes / snapInterval) * snapInterval;
|
||||
|
||||
// Calculate new end time by adding snapped duration to start (using DateService for timezone safety)
|
||||
const endDate = this.dateService.addMinutes(start, snappedDurationMinutes);
|
||||
|
||||
// 3. Update end attribute (triggers attributeChangedCallback → updateDisplay)
|
||||
this.end = endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a clone for drag operations
|
||||
*/
|
||||
public createClone(): SwpEventElement {
|
||||
const clone = this.cloneNode(true) as SwpEventElement;
|
||||
|
||||
// Apply "clone-" prefix to ID
|
||||
clone.dataset.eventId = EventId.toCloneId(this.eventId as EventId);
|
||||
|
||||
// Disable pointer events on clone so it doesn't interfere with hover detection
|
||||
clone.style.pointerEvents = 'none';
|
||||
|
||||
// Cache original duration
|
||||
const timeEl = this.querySelector('swp-event-time');
|
||||
if (timeEl) {
|
||||
const duration = timeEl.getAttribute('data-duration');
|
||||
if (duration) {
|
||||
clone.dataset.originalDuration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
// Set height from original
|
||||
clone.style.height = this.style.height || `${this.getBoundingClientRect().height}px`;
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Private Methods
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Render inner HTML structure
|
||||
*/
|
||||
private render(): void {
|
||||
const start = this.start;
|
||||
const end = this.end;
|
||||
const timeRange = TimeFormatter.formatTimeRange(start, end);
|
||||
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
|
||||
|
||||
this.innerHTML = `
|
||||
<swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time>
|
||||
<swp-event-title>${this.title}</swp-event-title>
|
||||
${this.description ? `<swp-event-description>${this.description}</swp-event-description>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update time display when attributes change
|
||||
*/
|
||||
private updateDisplay(): void {
|
||||
const timeEl = this.querySelector('swp-event-time');
|
||||
const titleEl = this.querySelector('swp-event-title');
|
||||
const descEl = this.querySelector('swp-event-description');
|
||||
|
||||
if (timeEl && this.dataset.start && this.dataset.end) {
|
||||
const start = new Date(this.dataset.start);
|
||||
const end = new Date(this.dataset.end);
|
||||
const timeRange = TimeFormatter.formatTimeRange(start, end);
|
||||
timeEl.textContent = timeRange;
|
||||
|
||||
// Update duration attribute
|
||||
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
|
||||
timeEl.setAttribute('data-duration', durationMinutes.toString());
|
||||
}
|
||||
|
||||
if (titleEl && this.dataset.title) {
|
||||
titleEl.textContent = this.dataset.title;
|
||||
}
|
||||
|
||||
if (this.dataset.description) {
|
||||
if (descEl) {
|
||||
descEl.textContent = this.dataset.description;
|
||||
} else if (this.description) {
|
||||
// Add description element if it doesn't exist
|
||||
const newDescEl = document.createElement('swp-event-description');
|
||||
newDescEl.textContent = this.description;
|
||||
this.appendChild(newDescEl);
|
||||
}
|
||||
} else if (descEl) {
|
||||
// Remove description element if description is empty
|
||||
descEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate start/end minutes from Y position
|
||||
*/
|
||||
private calculateTimesFromPosition(snappedY: number): { startMinutes: number; endMinutes: number } {
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const { hourHeight, dayStartHour, snapInterval } = gridSettings;
|
||||
|
||||
// Get original duration
|
||||
const originalDuration = parseInt(
|
||||
this.dataset.originalDuration ||
|
||||
this.dataset.duration ||
|
||||
'60'
|
||||
);
|
||||
|
||||
// Calculate snapped start minutes
|
||||
const minutesFromGridStart = (snappedY / hourHeight) * 60;
|
||||
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
|
||||
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
|
||||
|
||||
// Calculate end minutes
|
||||
const endMinutes = snappedStartMinutes + originalDuration;
|
||||
|
||||
return { startMinutes: snappedStartMinutes, endMinutes };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Static Factory Methods
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create SwpEventElement from ICalendarEvent
|
||||
*/
|
||||
public static fromCalendarEvent(event: ICalendarEvent): SwpEventElement {
|
||||
const element = document.createElement('swp-event') as SwpEventElement;
|
||||
const config = Configuration.getInstance();
|
||||
const dateService = new DateService(config);
|
||||
|
||||
element.dataset.eventId = event.id;
|
||||
element.dataset.title = event.title;
|
||||
element.dataset.description = event.description || '';
|
||||
element.dataset.start = dateService.toUTC(event.start);
|
||||
element.dataset.end = dateService.toUTC(event.end);
|
||||
element.dataset.type = event.type;
|
||||
element.dataset.duration = event.metadata?.duration?.toString() || '60';
|
||||
|
||||
// Apply color class from metadata
|
||||
if (event.metadata?.color) {
|
||||
element.classList.add(`is-${event.metadata.color}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ICalendarEvent from DOM element
|
||||
*/
|
||||
public static extractCalendarEventFromElement(element: HTMLElement): ICalendarEvent {
|
||||
return {
|
||||
id: element.dataset.eventId || '',
|
||||
title: element.dataset.title || '',
|
||||
description: element.dataset.description || undefined,
|
||||
start: new Date(element.dataset.start || ''),
|
||||
end: new Date(element.dataset.end || ''),
|
||||
type: element.dataset.type as CalendarEventType,
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: {
|
||||
duration: element.dataset.duration
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Component for all-day calendar events
|
||||
*/
|
||||
export class SwpAllDayEventElement extends BaseSwpEventElement {
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.textContent) {
|
||||
this.textContent = this.dataset.title || 'Untitled';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a clone for drag operations
|
||||
*/
|
||||
public createClone(): SwpAllDayEventElement {
|
||||
const clone = this.cloneNode(true) as SwpAllDayEventElement;
|
||||
|
||||
// Apply "clone-" prefix to ID
|
||||
clone.dataset.eventId = EventId.toCloneId(this.eventId as EventId);
|
||||
|
||||
// Disable pointer events on clone so it doesn't interfere with hover detection
|
||||
clone.style.pointerEvents = 'none';
|
||||
|
||||
// Preserve full opacity during drag
|
||||
clone.style.opacity = '1';
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CSS grid positioning
|
||||
*/
|
||||
public applyGridPositioning(row: number, startColumn: number, endColumn: number): void {
|
||||
const gridArea = `${row} / ${startColumn} / ${row + 1} / ${endColumn + 1}`;
|
||||
this.style.gridArea = gridArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from ICalendarEvent
|
||||
*/
|
||||
public static fromCalendarEvent(event: ICalendarEvent): SwpAllDayEventElement {
|
||||
const element = document.createElement('swp-allday-event') as SwpAllDayEventElement;
|
||||
const config = Configuration.getInstance();
|
||||
const dateService = new DateService(config);
|
||||
|
||||
element.dataset.eventId = event.id;
|
||||
element.dataset.title = event.title;
|
||||
element.dataset.start = dateService.toUTC(event.start);
|
||||
element.dataset.end = dateService.toUTC(event.end);
|
||||
element.dataset.type = event.type;
|
||||
element.dataset.allday = 'true';
|
||||
element.textContent = event.title;
|
||||
|
||||
// Apply color class from metadata
|
||||
if (event.metadata?.color) {
|
||||
element.classList.add(`is-${event.metadata.color}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom elements
|
||||
customElements.define('swp-event', SwpEventElement);
|
||||
customElements.define('swp-allday-event', SwpAllDayEventElement);
|
||||
6
src/entry.ts
Normal file
6
src/entry.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Calendar - Standalone Entry Point
|
||||
*/
|
||||
|
||||
// Re-export everything from index
|
||||
export * from './index';
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* EventLayoutEngine - Simplified stacking/grouping algorithm for V2
|
||||
* 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)
|
||||
*
|
||||
* Simplified from V1: No prev/next chains, single-pass greedy algorithm
|
||||
* No prev/next chains, single-pass greedy algorithm
|
||||
*/
|
||||
|
||||
import { ICalendarEvent } from '../../types/CalendarTypes';
|
||||
297
src/index.ts
297
src/index.ts
|
|
@ -1,284 +1,17 @@
|
|||
// Main entry point for Calendar Plantempus
|
||||
import { Container } from '@novadi/core';
|
||||
import { eventBus } from './core/EventBus';
|
||||
import { ConfigManager } from './configurations/ConfigManager';
|
||||
import { Configuration } from './configurations/CalendarConfig';
|
||||
import { URLManager } from './utils/URLManager';
|
||||
import { ICalendarEvent, IEventBus } from './types/CalendarTypes';
|
||||
// Core exports
|
||||
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
|
||||
export { IRenderer as Renderer, IRenderContext as RenderContext } from './core/IGroupingRenderer';
|
||||
export { IGroupingStore } from './core/IGroupingStore';
|
||||
export { CalendarOrchestrator } from './core/CalendarOrchestrator';
|
||||
export { NavigationAnimator } from './core/NavigationAnimator';
|
||||
export { buildPipeline, Pipeline } from './core/RenderBuilder';
|
||||
|
||||
// Import all managers
|
||||
import { EventManager } from './managers/EventManager';
|
||||
import { EventRenderingService } from './renderers/EventRendererManager';
|
||||
import { GridManager } from './managers/GridManager';
|
||||
import { ScrollManager } from './managers/ScrollManager';
|
||||
import { NavigationManager } from './managers/NavigationManager';
|
||||
import { NavigationButtons } from './components/NavigationButtons';
|
||||
import { ViewSelector } from './components/ViewSelector';
|
||||
import { CalendarManager } from './managers/CalendarManager';
|
||||
import { DragDropManager } from './managers/DragDropManager';
|
||||
import { AllDayManager } from './managers/AllDayManager';
|
||||
import { ResizeHandleManager } from './managers/ResizeHandleManager';
|
||||
import { EdgeScrollManager } from './managers/EdgeScrollManager';
|
||||
import { HeaderManager } from './managers/HeaderManager';
|
||||
import { WorkweekPresets } from './components/WorkweekPresets';
|
||||
|
||||
// Import repositories and storage
|
||||
import { MockEventRepository } from './repositories/MockEventRepository';
|
||||
import { MockBookingRepository } from './repositories/MockBookingRepository';
|
||||
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
|
||||
import { MockResourceRepository } from './repositories/MockResourceRepository';
|
||||
import { MockAuditRepository } from './repositories/MockAuditRepository';
|
||||
import { IApiRepository } from './repositories/IApiRepository';
|
||||
import { IAuditEntry } from './types/AuditTypes';
|
||||
import { ApiEventRepository } from './repositories/ApiEventRepository';
|
||||
import { ApiBookingRepository } from './repositories/ApiBookingRepository';
|
||||
import { ApiCustomerRepository } from './repositories/ApiCustomerRepository';
|
||||
import { ApiResourceRepository } from './repositories/ApiResourceRepository';
|
||||
import { IndexedDBContext } from './storage/IndexedDBContext';
|
||||
import { IStore } from './storage/IStore';
|
||||
import { AuditStore } from './storage/audit/AuditStore';
|
||||
import { AuditService } from './storage/audit/AuditService';
|
||||
import { BookingStore } from './storage/bookings/BookingStore';
|
||||
import { CustomerStore } from './storage/customers/CustomerStore';
|
||||
import { ResourceStore } from './storage/resources/ResourceStore';
|
||||
import { EventStore } from './storage/events/EventStore';
|
||||
import { IEntityService } from './storage/IEntityService';
|
||||
import { EventService } from './storage/events/EventService';
|
||||
import { BookingService } from './storage/bookings/BookingService';
|
||||
import { CustomerService } from './storage/customers/CustomerService';
|
||||
import { ResourceService } from './storage/resources/ResourceService';
|
||||
|
||||
// Import workers
|
||||
import { SyncManager } from './workers/SyncManager';
|
||||
import { DataSeeder } from './workers/DataSeeder';
|
||||
|
||||
// Import renderers
|
||||
import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer';
|
||||
import { DateColumnRenderer, type IColumnRenderer } from './renderers/ColumnRenderer';
|
||||
import { DateEventRenderer, type IEventRenderer } from './renderers/EventRenderer';
|
||||
import { AllDayEventRenderer } from './renderers/AllDayEventRenderer';
|
||||
import { GridRenderer } from './renderers/GridRenderer';
|
||||
import { WeekInfoRenderer } from './renderers/WeekInfoRenderer';
|
||||
|
||||
// Import utilities and services
|
||||
import { DateService } from './utils/DateService';
|
||||
import { TimeFormatter } from './utils/TimeFormatter';
|
||||
import { PositionUtils } from './utils/PositionUtils';
|
||||
import { AllDayLayoutEngine } from './utils/AllDayLayoutEngine';
|
||||
import { WorkHoursManager } from './managers/WorkHoursManager';
|
||||
import { EventStackManager } from './managers/EventStackManager';
|
||||
import { EventLayoutCoordinator } from './managers/EventLayoutCoordinator';
|
||||
import { IColumnDataSource } from './types/ColumnDataSource';
|
||||
import { DateColumnDataSource } from './datasources/DateColumnDataSource';
|
||||
import { ResourceColumnDataSource } from './datasources/ResourceColumnDataSource';
|
||||
import { ResourceHeaderRenderer } from './renderers/ResourceHeaderRenderer';
|
||||
import { ResourceColumnRenderer } from './renderers/ResourceColumnRenderer';
|
||||
import { IBooking } from './types/BookingTypes';
|
||||
import { ICustomer } from './types/CustomerTypes';
|
||||
import { IResource } from './types/ResourceTypes';
|
||||
|
||||
/**
|
||||
* Handle deep linking functionality after managers are initialized
|
||||
*/
|
||||
async function handleDeepLinking(eventManager: EventManager, urlManager: URLManager): Promise<void> {
|
||||
try {
|
||||
const eventId = urlManager.parseEventIdFromURL();
|
||||
|
||||
if (eventId) {
|
||||
console.log(`Deep linking to event ID: ${eventId}`);
|
||||
|
||||
// Wait a bit for managers to be fully ready
|
||||
setTimeout(async () => {
|
||||
const success = await eventManager.navigateToEvent(eventId);
|
||||
if (!success) {
|
||||
console.warn(`Deep linking failed: Event with ID ${eventId} not found`);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Deep linking failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the calendar application using NovaDI
|
||||
*/
|
||||
async function initializeCalendar(): Promise<void> {
|
||||
try {
|
||||
// Load configuration from JSON
|
||||
const config = await ConfigManager.load();
|
||||
|
||||
// Create NovaDI container
|
||||
const container = new Container();
|
||||
const builder = container.builder();
|
||||
|
||||
// Enable debug mode for development
|
||||
eventBus.setDebug(true);
|
||||
|
||||
// Bind core services as instances
|
||||
builder.registerInstance(eventBus).as<IEventBus>();
|
||||
|
||||
// Register configuration instance
|
||||
builder.registerInstance(config).as<Configuration>();
|
||||
|
||||
// Register storage stores (IStore implementations)
|
||||
// Open/Closed Principle: Adding new entity only requires adding one line here
|
||||
builder.registerType(BookingStore).as<IStore>();
|
||||
builder.registerType(CustomerStore).as<IStore>();
|
||||
builder.registerType(ResourceStore).as<IStore>();
|
||||
builder.registerType(EventStore).as<IStore>();
|
||||
builder.registerType(AuditStore).as<IStore>();
|
||||
|
||||
// Register storage and repository services
|
||||
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
|
||||
|
||||
// Register Mock repositories (development/testing - load from JSON files)
|
||||
// Each entity type has its own Mock repository implementing IApiRepository<T>
|
||||
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
|
||||
builder.registerType(MockBookingRepository).as<IApiRepository<IBooking>>();
|
||||
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
|
||||
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
|
||||
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
|
||||
|
||||
|
||||
let calendarMode = 'resource' ;
|
||||
// Register DataSource and HeaderRenderer based on mode
|
||||
if (calendarMode === 'resource') {
|
||||
builder.registerType(ResourceColumnDataSource).as<IColumnDataSource>();
|
||||
builder.registerType(ResourceHeaderRenderer).as<IHeaderRenderer>();
|
||||
} else {
|
||||
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
|
||||
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
|
||||
}
|
||||
|
||||
// Register entity services (sync status management)
|
||||
// Open/Closed Principle: Adding new entity only requires adding one line here
|
||||
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
|
||||
builder.registerType(EventService).as<EventService>();
|
||||
builder.registerType(BookingService).as<IEntityService<IBooking>>();
|
||||
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
|
||||
builder.registerType(ResourceService).as<IEntityService<IResource>>();
|
||||
builder.registerType(ResourceService).as<ResourceService>();
|
||||
builder.registerType(AuditService).as<AuditService>();
|
||||
|
||||
// Register workers
|
||||
builder.registerType(SyncManager).as<SyncManager>();
|
||||
builder.registerType(DataSeeder).as<DataSeeder>();
|
||||
|
||||
// Register renderers
|
||||
// Note: IHeaderRenderer and IColumnRenderer are registered above based on calendarMode
|
||||
if (calendarMode === 'resource') {
|
||||
builder.registerType(ResourceColumnRenderer).as<IColumnRenderer>();
|
||||
} else {
|
||||
builder.registerType(DateColumnRenderer).as<IColumnRenderer>();
|
||||
}
|
||||
builder.registerType(DateEventRenderer).as<IEventRenderer>();
|
||||
|
||||
// Register core services and utilities
|
||||
builder.registerType(DateService).as<DateService>();
|
||||
builder.registerType(EventStackManager).as<EventStackManager>();
|
||||
builder.registerType(EventLayoutCoordinator).as<EventLayoutCoordinator>();
|
||||
builder.registerType(WorkHoursManager).as<WorkHoursManager>();
|
||||
builder.registerType(URLManager).as<URLManager>();
|
||||
builder.registerType(TimeFormatter).as<TimeFormatter>();
|
||||
builder.registerType(PositionUtils).as<PositionUtils>();
|
||||
// Note: AllDayLayoutEngine is instantiated per-operation with specific dates, not a singleton
|
||||
builder.registerType(WeekInfoRenderer).as<WeekInfoRenderer>();
|
||||
builder.registerType(AllDayEventRenderer).as<AllDayEventRenderer>();
|
||||
|
||||
builder.registerType(EventRenderingService).as<EventRenderingService>();
|
||||
builder.registerType(GridRenderer).as<GridRenderer>();
|
||||
builder.registerType(GridManager).as<GridManager>();
|
||||
builder.registerType(ScrollManager).as<ScrollManager>();
|
||||
builder.registerType(NavigationManager).as<NavigationManager>();
|
||||
builder.registerType(NavigationButtons).as<NavigationButtons>();
|
||||
builder.registerType(ViewSelector).as<ViewSelector>();
|
||||
builder.registerType(DragDropManager).as<DragDropManager>();
|
||||
builder.registerType(AllDayManager).as<AllDayManager>();
|
||||
builder.registerType(ResizeHandleManager).as<ResizeHandleManager>();
|
||||
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
|
||||
builder.registerType(HeaderManager).as<HeaderManager>();
|
||||
builder.registerType(CalendarManager).as<CalendarManager>();
|
||||
builder.registerType(WorkweekPresets).as<WorkweekPresets>();
|
||||
|
||||
builder.registerType(ConfigManager).as<ConfigManager>();
|
||||
builder.registerType(EventManager).as<EventManager>();
|
||||
|
||||
// Build the container
|
||||
const app = builder.build();
|
||||
|
||||
// Initialize database and seed data BEFORE initializing managers
|
||||
const indexedDBContext = app.resolveType<IndexedDBContext>();
|
||||
await indexedDBContext.initialize();
|
||||
|
||||
const dataSeeder = app.resolveType<DataSeeder>();
|
||||
await dataSeeder.seedIfEmpty();
|
||||
|
||||
// Get managers from container
|
||||
const eb = app.resolveType<IEventBus>();
|
||||
const calendarManager = app.resolveType<CalendarManager>();
|
||||
const eventManager = app.resolveType<EventManager>();
|
||||
const resizeHandleManager = app.resolveType<ResizeHandleManager>();
|
||||
const headerManager = app.resolveType<HeaderManager>();
|
||||
const dragDropManager = app.resolveType<DragDropManager>();
|
||||
const viewSelectorManager = app.resolveType<ViewSelector>();
|
||||
const navigationManager = app.resolveType<NavigationManager>();
|
||||
const navigationButtonsManager = app.resolveType<NavigationButtons>();
|
||||
const edgeScrollManager = app.resolveType<EdgeScrollManager>();
|
||||
const allDayManager = app.resolveType<AllDayManager>();
|
||||
const urlManager = app.resolveType<URLManager>();
|
||||
const workweekPresetsManager = app.resolveType<WorkweekPresets>();
|
||||
const configManager = app.resolveType<ConfigManager>();
|
||||
|
||||
// Initialize managers
|
||||
await calendarManager.initialize?.();
|
||||
await resizeHandleManager.initialize?.();
|
||||
|
||||
// Resolve AuditService (starts listening for entity events)
|
||||
const auditService = app.resolveType<AuditService>();
|
||||
|
||||
// Resolve SyncManager (starts background sync automatically)
|
||||
const syncManager = app.resolveType<SyncManager>();
|
||||
|
||||
// Handle deep linking after managers are initialized
|
||||
await handleDeepLinking(eventManager, urlManager);
|
||||
|
||||
// Expose to window for debugging (with proper typing)
|
||||
(window as Window & {
|
||||
calendarDebug?: {
|
||||
eventBus: typeof eventBus;
|
||||
app: typeof app;
|
||||
calendarManager: typeof calendarManager;
|
||||
eventManager: typeof eventManager;
|
||||
workweekPresetsManager: typeof workweekPresetsManager;
|
||||
auditService: typeof auditService;
|
||||
syncManager: typeof syncManager;
|
||||
};
|
||||
}).calendarDebug = {
|
||||
eventBus,
|
||||
app,
|
||||
calendarManager,
|
||||
eventManager,
|
||||
workweekPresetsManager,
|
||||
auditService,
|
||||
syncManager,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready - now handles async properly
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeCalendar().catch(error => {
|
||||
console.error('Calendar initialization failed:', error);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
initializeCalendar().catch(error => {
|
||||
console.error('Calendar initialization failed:', error);
|
||||
});
|
||||
}
|
||||
// Feature exports
|
||||
export { DateRenderer } from './features/date';
|
||||
export { DateService } from './core/DateService';
|
||||
export { ITimeFormatConfig } from './core/ITimeFormatConfig';
|
||||
export { EventRenderer } from './features/event';
|
||||
export { ResourceRenderer } from './features/resource';
|
||||
export { TeamRenderer } from './features/team';
|
||||
export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,744 +0,0 @@
|
|||
// All-day row height management and animations
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig';
|
||||
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
|
||||
import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine';
|
||||
import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
|
||||
import { IColumnDataSource } from '../types/ColumnDataSource';
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { CalendarEventType } from '../types/BookingTypes';
|
||||
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
|
||||
import {
|
||||
IDragMouseEnterHeaderEventPayload,
|
||||
IDragMouseEnterColumnEventPayload,
|
||||
IDragStartEventPayload,
|
||||
IDragMoveEventPayload,
|
||||
IDragEndEventPayload,
|
||||
IDragColumnChangeEventPayload,
|
||||
IHeaderReadyEventPayload
|
||||
} from '../types/EventTypes';
|
||||
import { IDragOffset, IMousePosition } from '../types/DragDropTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { EventManager } from './EventManager';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { EventId } from '../types/EventId';
|
||||
|
||||
/**
|
||||
* AllDayManager - Handles all-day row height animations and management
|
||||
* Uses AllDayLayoutEngine for all overlap detection and layout calculation
|
||||
*/
|
||||
export class AllDayManager {
|
||||
private allDayEventRenderer: AllDayEventRenderer;
|
||||
private eventManager: EventManager;
|
||||
private dateService: DateService;
|
||||
private dataSource: IColumnDataSource;
|
||||
|
||||
private layoutEngine: AllDayLayoutEngine | null = null;
|
||||
|
||||
// State tracking for layout calculation
|
||||
private currentAllDayEvents: ICalendarEvent[] = [];
|
||||
private currentColumns: IColumnBounds[] = [];
|
||||
|
||||
// Expand/collapse state
|
||||
private isExpanded: boolean = false;
|
||||
private actualRowCount: number = 0;
|
||||
|
||||
|
||||
constructor(
|
||||
eventManager: EventManager,
|
||||
allDayEventRenderer: AllDayEventRenderer,
|
||||
dateService: DateService,
|
||||
dataSource: IColumnDataSource
|
||||
) {
|
||||
this.eventManager = eventManager;
|
||||
this.allDayEventRenderer = allDayEventRenderer;
|
||||
this.dateService = dateService;
|
||||
this.dataSource = dataSource;
|
||||
|
||||
// Sync CSS variable with TypeScript constant to ensure consistency
|
||||
document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`);
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for drag conversions
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
eventBus.on('drag:mouseenter-header', (event) => {
|
||||
const payload = (event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail;
|
||||
|
||||
if (payload.draggedClone.hasAttribute('data-allday'))
|
||||
return;
|
||||
|
||||
console.log('🔄 AllDayManager: Received drag:mouseenter-header', {
|
||||
targetDate: payload.targetColumn,
|
||||
originalElementId: payload.originalElement?.dataset?.eventId,
|
||||
originalElementTag: payload.originalElement?.tagName
|
||||
});
|
||||
|
||||
this.handleConvertToAllDay(payload);
|
||||
});
|
||||
|
||||
eventBus.on('drag:mouseleave-header', (event) => {
|
||||
const { originalElement, cloneElement } = (event as CustomEvent).detail;
|
||||
|
||||
console.log('🚪 AllDayManager: Received drag:mouseleave-header', {
|
||||
originalElementId: originalElement?.dataset?.eventId
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Listen for drag operations on all-day events
|
||||
eventBus.on('drag:start', (event) => {
|
||||
let payload: IDragStartEventPayload = (event as CustomEvent<IDragStartEventPayload>).detail;
|
||||
|
||||
if (!payload.draggedClone?.hasAttribute('data-allday')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.allDayEventRenderer.handleDragStart(payload);
|
||||
});
|
||||
|
||||
eventBus.on('drag:column-change', (event) => {
|
||||
let payload: IDragColumnChangeEventPayload = (event as CustomEvent<IDragColumnChangeEventPayload>).detail;
|
||||
|
||||
if (!payload.draggedClone?.hasAttribute('data-allday')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleColumnChange(payload);
|
||||
});
|
||||
|
||||
eventBus.on('drag:end', (event) => {
|
||||
let dragEndPayload: IDragEndEventPayload = (event as CustomEvent<IDragEndEventPayload>).detail;
|
||||
|
||||
console.log('🎯 AllDayManager: drag:end received', {
|
||||
target: dragEndPayload.target,
|
||||
originalElementTag: dragEndPayload.originalElement?.tagName,
|
||||
hasAllDayAttribute: dragEndPayload.originalElement?.hasAttribute('data-allday'),
|
||||
eventId: dragEndPayload.originalElement?.dataset.eventId
|
||||
});
|
||||
|
||||
// Handle all-day → all-day drops (within header)
|
||||
if (dragEndPayload.target === 'swp-day-header' && dragEndPayload.originalElement?.hasAttribute('data-allday')) {
|
||||
console.log('✅ AllDayManager: Handling all-day → all-day drop');
|
||||
this.handleDragEnd(dragEndPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle timed → all-day conversion (dropped in header)
|
||||
if (dragEndPayload.target === 'swp-day-header' && !dragEndPayload.originalElement?.hasAttribute('data-allday')) {
|
||||
console.log('🔄 AllDayManager: Timed → all-day conversion on drop');
|
||||
this.handleTimedToAllDayDrop(dragEndPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle all-day → timed conversion (dropped in column)
|
||||
if (dragEndPayload.target === 'swp-day-column' && dragEndPayload.originalElement?.hasAttribute('data-allday')) {
|
||||
const eventId = dragEndPayload.originalElement.dataset.eventId;
|
||||
|
||||
console.log('🔄 AllDayManager: All-day → timed conversion', { eventId });
|
||||
|
||||
// Mark for removal (sets data-removing attribute)
|
||||
this.fadeOutAndRemove(dragEndPayload.originalElement);
|
||||
|
||||
// Recalculate layout WITHOUT the removed event to compress gaps
|
||||
const remainingEvents = this.currentAllDayEvents.filter(e => e.id !== eventId);
|
||||
const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentColumns);
|
||||
|
||||
// Re-render all-day events with compressed layout
|
||||
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
|
||||
|
||||
// NOW animate height with compressed layout
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for drag cancellation to recalculate height
|
||||
eventBus.on('drag:cancelled', (event) => {
|
||||
const { draggedElement, reason } = (event as CustomEvent).detail;
|
||||
|
||||
console.log('🚫 AllDayManager: Drag cancelled', {
|
||||
eventId: draggedElement?.dataset?.eventId,
|
||||
reason
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Listen for header ready - when dates are populated with period data
|
||||
eventBus.on('header:ready', async (event: Event) => {
|
||||
let headerReadyEventPayload = (event as CustomEvent<IHeaderReadyEventPayload>).detail;
|
||||
|
||||
let startDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(0)!.identifier);
|
||||
let endDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(-1)!.identifier);
|
||||
|
||||
let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate);
|
||||
// Filter for all-day events
|
||||
const allDayEvents = events.filter(event => event.allDay);
|
||||
|
||||
const layouts = this.calculateAllDayEventsLayout(allDayEvents, headerReadyEventPayload.headerElements);
|
||||
|
||||
this.allDayEventRenderer.renderAllDayEventsForPeriod(layouts);
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
});
|
||||
|
||||
eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => {
|
||||
this.allDayEventRenderer.handleViewChanged(event as CustomEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private getAllDayContainer(): HTMLElement | null {
|
||||
return document.querySelector('swp-calendar-header swp-allday-container');
|
||||
}
|
||||
|
||||
private getCalendarHeader(): HTMLElement | null {
|
||||
return document.querySelector('swp-calendar-header');
|
||||
}
|
||||
|
||||
private getHeaderSpacer(): HTMLElement | null {
|
||||
return document.querySelector('swp-header-spacer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current max row from DOM elements
|
||||
* Excludes events marked as removing (data-removing attribute)
|
||||
*/
|
||||
private getMaxRowFromDOM(): number {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return 0;
|
||||
|
||||
let maxRow = 0;
|
||||
const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator):not([data-removing])');
|
||||
|
||||
allDayEvents.forEach((element: Element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
const row = parseInt(htmlElement.style.gridRow) || 1;
|
||||
maxRow = Math.max(maxRow, row);
|
||||
});
|
||||
|
||||
return maxRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current gridArea for an event from DOM
|
||||
*/
|
||||
private getGridAreaFromDOM(eventId: string): string | null {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const element = container.querySelector(`[data-event-id="${eventId}"]`) as HTMLElement;
|
||||
return element?.style.gridArea || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count events in a specific column by reading DOM
|
||||
*/
|
||||
private countEventsInColumnFromDOM(columnIndex: number): number {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return 0;
|
||||
|
||||
let count = 0;
|
||||
const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator)');
|
||||
|
||||
allDayEvents.forEach((element: Element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
const gridColumn = htmlElement.style.gridColumn;
|
||||
|
||||
// Parse "1 / 3" format
|
||||
const match = gridColumn.match(/(\d+)\s*\/\s*(\d+)/);
|
||||
if (match) {
|
||||
const startCol = parseInt(match[1]);
|
||||
const endCol = parseInt(match[2]) - 1; // End is exclusive in CSS
|
||||
|
||||
if (startCol <= columnIndex && endCol >= columnIndex) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate all-day height based on number of rows
|
||||
*/
|
||||
private calculateAllDayHeight(targetRows: number): {
|
||||
targetHeight: number;
|
||||
currentHeight: number;
|
||||
heightDifference: number;
|
||||
} {
|
||||
const root = document.documentElement;
|
||||
const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT;
|
||||
// Read CSS variable directly from style property or default to 0
|
||||
const currentHeightStr = root.style.getPropertyValue('--all-day-row-height') || '0px';
|
||||
const currentHeight = parseInt(currentHeightStr) || 0;
|
||||
const heightDifference = targetHeight - currentHeight;
|
||||
|
||||
return { targetHeight, currentHeight, heightDifference };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current all-day events and animate to correct height
|
||||
* Reads max row directly from DOM elements
|
||||
*/
|
||||
public checkAndAnimateAllDayHeight(): void {
|
||||
// Read max row directly from DOM
|
||||
const maxRows = this.getMaxRowFromDOM();
|
||||
|
||||
console.log('📊 AllDayManager: Height calculation', {
|
||||
maxRows,
|
||||
isExpanded: this.isExpanded
|
||||
});
|
||||
|
||||
// Store actual row count
|
||||
this.actualRowCount = maxRows;
|
||||
|
||||
// Determine what to display
|
||||
let displayRows = maxRows;
|
||||
|
||||
if (maxRows > ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS) {
|
||||
// Show chevron button
|
||||
this.updateChevronButton(true);
|
||||
|
||||
// Show 4 rows when collapsed (3 events + indicators)
|
||||
if (!this.isExpanded) {
|
||||
|
||||
displayRows = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS;
|
||||
this.updateOverflowIndicators();
|
||||
|
||||
} else {
|
||||
|
||||
this.clearOverflowIndicators();
|
||||
|
||||
}
|
||||
} else {
|
||||
|
||||
// Hide chevron - not needed
|
||||
this.updateChevronButton(false);
|
||||
this.clearOverflowIndicators();
|
||||
}
|
||||
|
||||
console.log('🎬 AllDayManager: Will animate to', {
|
||||
displayRows,
|
||||
maxRows,
|
||||
willAnimate: displayRows !== this.actualRowCount
|
||||
});
|
||||
|
||||
console.log(`🎯 AllDayManager: Animating to ${displayRows} rows`);
|
||||
|
||||
// Animate to required rows (0 = collapse, >0 = expand)
|
||||
this.animateToRows(displayRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate all-day container to specific number of rows
|
||||
*/
|
||||
public animateToRows(targetRows: number): void {
|
||||
const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows);
|
||||
|
||||
if (targetHeight === currentHeight) return; // No animation needed
|
||||
|
||||
console.log(`🎬 All-day height animation: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`);
|
||||
|
||||
// Get cached elements
|
||||
const calendarHeader = this.getCalendarHeader();
|
||||
const headerSpacer = this.getHeaderSpacer();
|
||||
const allDayContainer = this.getAllDayContainer();
|
||||
|
||||
if (!calendarHeader || !allDayContainer) return;
|
||||
|
||||
// Get current parent height for animation
|
||||
const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height);
|
||||
const targetParentHeight = currentParentHeight + heightDifference;
|
||||
|
||||
const animations = [
|
||||
calendarHeader.animate([
|
||||
{ height: `${currentParentHeight}px` },
|
||||
{ height: `${targetParentHeight}px` }
|
||||
], {
|
||||
duration: 150,
|
||||
easing: 'ease-out',
|
||||
fill: 'forwards'
|
||||
})
|
||||
];
|
||||
|
||||
// Add spacer animation if spacer exists, but don't use fill: 'forwards'
|
||||
if (headerSpacer) {
|
||||
const root = document.documentElement;
|
||||
const headerHeightStr = root.style.getPropertyValue('--header-height');
|
||||
const headerHeight = parseInt(headerHeightStr);
|
||||
const currentSpacerHeight = headerHeight + currentHeight;
|
||||
const targetSpacerHeight = headerHeight + targetHeight;
|
||||
|
||||
animations.push(
|
||||
headerSpacer.animate([
|
||||
{ height: `${currentSpacerHeight}px` },
|
||||
{ height: `${targetSpacerHeight}px` }
|
||||
], {
|
||||
duration: 150,
|
||||
easing: 'ease-out'
|
||||
// No fill: 'forwards' - let CSS calc() take over after animation
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update CSS variable after animation
|
||||
Promise.all(animations.map(anim => anim.finished)).then(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--all-day-row-height', `${targetHeight}px`);
|
||||
eventBus.emit('header:height-changed');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate layout for ALL all-day events using AllDayLayoutEngine
|
||||
* This is the correct method that processes all events together for proper overlap detection
|
||||
*/
|
||||
private calculateAllDayEventsLayout(events: ICalendarEvent[], dayHeaders: IColumnBounds[]): IEventLayout[] {
|
||||
|
||||
// Store current state
|
||||
this.currentAllDayEvents = events;
|
||||
this.currentColumns = dayHeaders;
|
||||
|
||||
// Map IColumnBounds to IColumnInfo structure (identifier + groupId)
|
||||
const columns = dayHeaders.map(column => ({
|
||||
identifier: column.identifier,
|
||||
groupId: column.element.dataset.groupId || column.identifier,
|
||||
data: new Date(), // Not used by AllDayLayoutEngine
|
||||
events: [] // Not used by AllDayLayoutEngine
|
||||
}));
|
||||
|
||||
// Initialize layout engine with column info including groupId
|
||||
let layoutEngine = new AllDayLayoutEngine(columns);
|
||||
|
||||
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
|
||||
return layoutEngine.calculateLayout(events);
|
||||
|
||||
}
|
||||
|
||||
private handleConvertToAllDay(payload: IDragMouseEnterHeaderEventPayload): void {
|
||||
|
||||
let allDayContainer = this.getAllDayContainer();
|
||||
if (!allDayContainer) return;
|
||||
|
||||
// Create SwpAllDayEventElement from ICalendarEvent
|
||||
const allDayElement = SwpAllDayEventElement.fromCalendarEvent(payload.calendarEvent);
|
||||
|
||||
// Apply grid positioning
|
||||
allDayElement.style.gridRow = '1';
|
||||
allDayElement.style.gridColumn = payload.targetColumn.index.toString();
|
||||
|
||||
// Remove old swp-event clone
|
||||
payload.draggedClone.remove();
|
||||
|
||||
// Call delegate to update DragDropManager's draggedClone reference
|
||||
payload.replaceClone(allDayElement);
|
||||
|
||||
// Append to container
|
||||
allDayContainer.appendChild(allDayElement);
|
||||
|
||||
ColumnDetectionUtils.updateColumnBoundsCache();
|
||||
|
||||
// Recalculate height after adding all-day event
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER
|
||||
*/
|
||||
private handleColumnChange(dragColumnChangeEventPayload: IDragColumnChangeEventPayload): void {
|
||||
|
||||
let allDayContainer = this.getAllDayContainer();
|
||||
if (!allDayContainer) return;
|
||||
|
||||
let targetColumn = ColumnDetectionUtils.getColumnBounds(dragColumnChangeEventPayload.mousePosition);
|
||||
|
||||
if (targetColumn == null)
|
||||
return;
|
||||
|
||||
if (!dragColumnChangeEventPayload.draggedClone)
|
||||
return;
|
||||
|
||||
// Calculate event span from original grid positioning
|
||||
const computedStyle = window.getComputedStyle(dragColumnChangeEventPayload.draggedClone);
|
||||
const gridColumnStart = parseInt(computedStyle.gridColumnStart) || targetColumn.index;
|
||||
const gridColumnEnd = parseInt(computedStyle.gridColumnEnd) || targetColumn.index + 1;
|
||||
const span = gridColumnEnd - gridColumnStart;
|
||||
|
||||
// Update clone position maintaining the span
|
||||
const newStartColumn = targetColumn.index;
|
||||
const newEndColumn = newStartColumn + span;
|
||||
dragColumnChangeEventPayload.draggedClone.style.gridColumn = `${newStartColumn} / ${newEndColumn}`;
|
||||
|
||||
}
|
||||
private fadeOutAndRemove(element: HTMLElement): void {
|
||||
console.log('🗑️ AllDayManager: About to remove all-day event', {
|
||||
eventId: element.dataset.eventId,
|
||||
element: element.tagName
|
||||
});
|
||||
|
||||
// Mark element as removing so it's excluded from height calculations
|
||||
element.setAttribute('data-removing', 'true');
|
||||
|
||||
element.style.transition = 'opacity 0.3s ease-out';
|
||||
element.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
element.remove();
|
||||
console.log('✅ AllDayManager: All-day event removed from DOM');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle timed → all-day conversion on drop
|
||||
*/
|
||||
private async handleTimedToAllDayDrop(dragEndEvent: IDragEndEventPayload): Promise<void> {
|
||||
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
|
||||
|
||||
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
|
||||
const eventId = EventId.from(clone.eventId);
|
||||
const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
|
||||
|
||||
// Determine target date based on mode
|
||||
let targetDate: Date;
|
||||
let resourceId: string | undefined;
|
||||
|
||||
if (this.dataSource.isResource()) {
|
||||
// Resource mode: keep event's existing date, set resourceId
|
||||
targetDate = clone.start;
|
||||
resourceId = columnIdentifier;
|
||||
} else {
|
||||
// Date mode: parse date from column identifier
|
||||
targetDate = this.dateService.parseISO(columnIdentifier);
|
||||
}
|
||||
|
||||
console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate, resourceId });
|
||||
|
||||
// Create new dates preserving time
|
||||
const newStart = new Date(targetDate);
|
||||
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
|
||||
|
||||
const newEnd = new Date(targetDate);
|
||||
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
|
||||
|
||||
// Build update payload
|
||||
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
allDay: true
|
||||
};
|
||||
|
||||
if (resourceId) {
|
||||
updatePayload.resourceId = resourceId;
|
||||
}
|
||||
|
||||
// Update event in repository
|
||||
await this.eventManager.updateEvent(eventId, updatePayload);
|
||||
|
||||
// Remove original timed event
|
||||
this.fadeOutAndRemove(dragEndEvent.originalElement);
|
||||
|
||||
// Add to current all-day events and recalculate layout
|
||||
const newEvent: ICalendarEvent = {
|
||||
id: eventId,
|
||||
title: clone.title,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
type: clone.type as CalendarEventType,
|
||||
allDay: true,
|
||||
syncStatus: 'synced'
|
||||
};
|
||||
|
||||
const updatedEvents = [...this.currentAllDayEvents, newEvent];
|
||||
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
|
||||
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
|
||||
|
||||
// Animate height
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle all-day → all-day drop (moving within header)
|
||||
*/
|
||||
private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise<void> {
|
||||
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
|
||||
|
||||
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
|
||||
const eventId = EventId.from(clone.eventId);
|
||||
const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
|
||||
|
||||
// Determine target date based on mode
|
||||
let targetDate: Date;
|
||||
let resourceId: string | undefined;
|
||||
|
||||
if (this.dataSource.isResource()) {
|
||||
// Resource mode: keep event's existing date, set resourceId
|
||||
targetDate = clone.start;
|
||||
resourceId = columnIdentifier;
|
||||
} else {
|
||||
// Date mode: parse date from column identifier
|
||||
targetDate = this.dateService.parseISO(columnIdentifier);
|
||||
}
|
||||
|
||||
// Calculate duration in days
|
||||
const durationDays = this.dateService.differenceInCalendarDays(clone.end, clone.start);
|
||||
|
||||
// Create new dates preserving time
|
||||
const newStart = new Date(targetDate);
|
||||
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
|
||||
|
||||
const newEnd = new Date(targetDate);
|
||||
newEnd.setDate(newEnd.getDate() + durationDays);
|
||||
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
|
||||
|
||||
// Build update payload
|
||||
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
allDay: true
|
||||
};
|
||||
|
||||
if (resourceId) {
|
||||
updatePayload.resourceId = resourceId;
|
||||
}
|
||||
|
||||
// Update event in repository
|
||||
await this.eventManager.updateEvent(eventId, updatePayload);
|
||||
|
||||
// Remove original and fade out
|
||||
this.fadeOutAndRemove(dragEndEvent.originalElement);
|
||||
|
||||
// Recalculate and re-render ALL events
|
||||
const updatedEvents = this.currentAllDayEvents.map(e =>
|
||||
e.id === eventId ? { ...e, start: newStart, end: newEnd } : e
|
||||
);
|
||||
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
|
||||
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
|
||||
|
||||
// Animate height - this also handles overflow classes!
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update chevron button visibility and state
|
||||
*/
|
||||
private updateChevronButton(show: boolean): void {
|
||||
const headerSpacer = this.getHeaderSpacer();
|
||||
if (!headerSpacer) return;
|
||||
|
||||
let chevron = headerSpacer.querySelector('.allday-chevron') as HTMLElement;
|
||||
|
||||
if (show && !chevron) {
|
||||
|
||||
chevron = document.createElement('button');
|
||||
chevron.className = 'allday-chevron collapsed';
|
||||
chevron.innerHTML = `
|
||||
<svg width="12" height="8" viewBox="0 0 12 8">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
`;
|
||||
chevron.onclick = () => this.toggleExpanded();
|
||||
headerSpacer.appendChild(chevron);
|
||||
|
||||
} else if (!show && chevron) {
|
||||
|
||||
chevron.remove();
|
||||
|
||||
} else if (chevron) {
|
||||
|
||||
chevron.classList.toggle('collapsed', !this.isExpanded);
|
||||
chevron.classList.toggle('expanded', this.isExpanded);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between expanded and collapsed state
|
||||
*/
|
||||
private toggleExpanded(): void {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
|
||||
const elements = document.querySelectorAll('swp-allday-container swp-allday-event.max-event-overflow-hide, swp-allday-container swp-allday-event.max-event-overflow-show');
|
||||
|
||||
elements.forEach((element) => {
|
||||
if (this.isExpanded) {
|
||||
// ALTID vis når expanded=true
|
||||
element.classList.remove('max-event-overflow-hide');
|
||||
element.classList.add('max-event-overflow-show');
|
||||
} else {
|
||||
// ALTID skjul når expanded=false
|
||||
element.classList.remove('max-event-overflow-show');
|
||||
element.classList.add('max-event-overflow-hide');
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Count number of events in a specific column using IColumnBounds
|
||||
* Reads directly from DOM elements
|
||||
*/
|
||||
private countEventsInColumn(columnBounds: IColumnBounds): number {
|
||||
return this.countEventsInColumnFromDOM(columnBounds.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overflow indicators for collapsed state
|
||||
*/
|
||||
private updateOverflowIndicators(): void {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return;
|
||||
|
||||
// Create overflow indicators for each column that needs them
|
||||
let columns = ColumnDetectionUtils.getColumns();
|
||||
|
||||
columns.forEach((columnBounds) => {
|
||||
let totalEventsInColumn = this.countEventsInColumn(columnBounds);
|
||||
let overflowCount = totalEventsInColumn - ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS
|
||||
|
||||
if (overflowCount > 0) {
|
||||
// Check if indicator already exists in this column
|
||||
let existingIndicator = container.querySelector(`.max-event-indicator[data-column="${columnBounds.index}"]`) as HTMLElement;
|
||||
|
||||
if (existingIndicator) {
|
||||
// Update existing indicator
|
||||
existingIndicator.innerHTML = `<span>+${overflowCount + 1} more</span>`;
|
||||
} else {
|
||||
// Create new overflow indicator element
|
||||
let overflowElement = document.createElement('swp-allday-event');
|
||||
overflowElement.className = 'max-event-indicator';
|
||||
overflowElement.setAttribute('data-column', columnBounds.index.toString());
|
||||
overflowElement.style.gridRow = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS.toString();
|
||||
overflowElement.style.gridColumn = columnBounds.index.toString();
|
||||
overflowElement.innerHTML = `<span>+${overflowCount + 1} more</span>`;
|
||||
overflowElement.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleExpanded();
|
||||
};
|
||||
|
||||
container.appendChild(overflowElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear overflow indicators and restore normal state
|
||||
*/
|
||||
private clearOverflowIndicators(): void {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return;
|
||||
|
||||
// Remove all overflow indicator elements
|
||||
container.querySelectorAll('.max-event-indicator').forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { CalendarView, IEventBus } from '../types/CalendarTypes';
|
||||
import { EventManager } from './EventManager';
|
||||
import { GridManager } from './GridManager';
|
||||
import { EventRenderingService } from '../renderers/EventRendererManager';
|
||||
import { ScrollManager } from './ScrollManager';
|
||||
|
||||
/**
|
||||
* CalendarManager - Main coordinator for all calendar managers
|
||||
*/
|
||||
export class CalendarManager {
|
||||
private eventBus: IEventBus;
|
||||
private eventManager: EventManager;
|
||||
private gridManager: GridManager;
|
||||
private eventRenderer: EventRenderingService;
|
||||
private scrollManager: ScrollManager;
|
||||
private config: Configuration;
|
||||
private currentView: CalendarView;
|
||||
private currentDate: Date = new Date();
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
constructor(
|
||||
eventBus: IEventBus,
|
||||
eventManager: EventManager,
|
||||
gridManager: GridManager,
|
||||
eventRenderingService: EventRenderingService,
|
||||
scrollManager: ScrollManager,
|
||||
config: Configuration
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.eventManager = eventManager;
|
||||
this.gridManager = gridManager;
|
||||
this.eventRenderer = eventRenderingService;
|
||||
this.scrollManager = scrollManager;
|
||||
this.config = config;
|
||||
this.currentView = this.config.currentView;
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize calendar system using simple direct calls
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Step 1: Load data
|
||||
await this.eventManager.loadData();
|
||||
|
||||
// Step 2: Render grid structure
|
||||
await this.gridManager.render();
|
||||
|
||||
this.scrollManager.initialize();
|
||||
|
||||
this.setView(this.currentView);
|
||||
this.setCurrentDate(this.currentDate);
|
||||
|
||||
this.isInitialized = true;
|
||||
|
||||
// Emit initialization complete event
|
||||
this.eventBus.emit(CoreEvents.INITIALIZED, {
|
||||
currentDate: this.currentDate,
|
||||
currentView: this.currentView
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skift calendar view (dag/uge/måned)
|
||||
*/
|
||||
public setView(view: CalendarView): void {
|
||||
if (this.currentView === view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousView = this.currentView;
|
||||
this.currentView = view;
|
||||
|
||||
|
||||
// Emit view change event
|
||||
this.eventBus.emit(CoreEvents.VIEW_CHANGED, {
|
||||
previousView,
|
||||
currentView: view,
|
||||
date: this.currentDate
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sæt aktuel dato
|
||||
*/
|
||||
public setCurrentDate(date: Date): void {
|
||||
|
||||
const previousDate = this.currentDate;
|
||||
this.currentDate = new Date(date);
|
||||
|
||||
// Emit date change event
|
||||
this.eventBus.emit(CoreEvents.DATE_CHANGED, {
|
||||
previousDate,
|
||||
currentDate: this.currentDate,
|
||||
view: this.currentView
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Setup event listeners for at håndtere events fra andre managers
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
// Listen for workweek changes only
|
||||
this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
this.handleWorkweekChange();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Calculate the current period based on view and date
|
||||
*/
|
||||
private calculateCurrentPeriod(): { start: string; end: string } {
|
||||
const current = new Date(this.currentDate);
|
||||
|
||||
switch (this.currentView) {
|
||||
case 'day':
|
||||
const dayStart = new Date(current);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(current);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
return {
|
||||
start: dayStart.toISOString(),
|
||||
end: dayEnd.toISOString()
|
||||
};
|
||||
|
||||
case 'week':
|
||||
// Find start of week (Monday)
|
||||
const weekStart = new Date(current);
|
||||
const dayOfWeek = weekStart.getDay();
|
||||
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Sunday = 0, so 6 days back to Monday
|
||||
weekStart.setDate(weekStart.getDate() - daysToMonday);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// Find end of week (Sunday)
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return {
|
||||
start: weekStart.toISOString(),
|
||||
end: weekEnd.toISOString()
|
||||
};
|
||||
|
||||
case 'month':
|
||||
const monthStart = new Date(current.getFullYear(), current.getMonth(), 1);
|
||||
const monthEnd = new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
return {
|
||||
start: monthStart.toISOString(),
|
||||
end: monthEnd.toISOString()
|
||||
};
|
||||
|
||||
default:
|
||||
// Fallback to week view
|
||||
const fallbackStart = new Date(current);
|
||||
fallbackStart.setDate(fallbackStart.getDate() - 3);
|
||||
fallbackStart.setHours(0, 0, 0, 0);
|
||||
const fallbackEnd = new Date(current);
|
||||
fallbackEnd.setDate(fallbackEnd.getDate() + 3);
|
||||
fallbackEnd.setHours(23, 59, 59, 999);
|
||||
return {
|
||||
start: fallbackStart.toISOString(),
|
||||
end: fallbackEnd.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workweek configuration changes
|
||||
*/
|
||||
private handleWorkweekChange(): void {
|
||||
// Simply relay the event - workweek info is in the WORKWEEK_CHANGED event
|
||||
this.eventBus.emit('workweek:header-update', {
|
||||
currentDate: this.currentDate,
|
||||
currentView: this.currentView
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,220 +1,140 @@
|
|||
/**
|
||||
* EdgeScrollManager - Auto-scroll when dragging near edges
|
||||
* Uses time-based scrolling with 2-zone system for variable speed
|
||||
* 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 { IDragMoveEventPayload, IDragStartEventPayload } from '../types/EventTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
|
||||
export class EdgeScrollManager {
|
||||
private scrollableContent: HTMLElement | null = null;
|
||||
private timeGrid: HTMLElement | null = null;
|
||||
private draggedClone: HTMLElement | null = null;
|
||||
private draggedElement: HTMLElement | null = null;
|
||||
private scrollRAF: number | null = null;
|
||||
private mouseY = 0;
|
||||
private isDragging = false;
|
||||
private isScrolling = false; // Track if edge-scroll is active
|
||||
private isScrolling = false;
|
||||
private lastTs = 0;
|
||||
private rect: DOMRect | null = null;
|
||||
private initialScrollTop = 0;
|
||||
private scrollListener: ((e: Event) => void) | null = null;
|
||||
|
||||
// Constants - fixed values as per requirements
|
||||
private readonly OUTER_ZONE = 100; // px from edge (slow zone)
|
||||
private readonly INNER_ZONE = 50; // px from edge (fast zone)
|
||||
private readonly SLOW_SPEED_PXS = 140; // px/sec in outer zone
|
||||
private readonly FAST_SPEED_PXS = 640; // px/sec in inner zone
|
||||
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.init();
|
||||
this.subscribeToEvents();
|
||||
document.addEventListener('pointermove', this.trackMouse);
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
// Wait for DOM to be ready
|
||||
setTimeout(() => {
|
||||
this.scrollableContent = document.querySelector('swp-scrollable-content');
|
||||
this.timeGrid = document.querySelector('swp-time-grid');
|
||||
|
||||
if (this.scrollableContent) {
|
||||
// Disable smooth scroll for instant auto-scroll
|
||||
init(scrollableContent: HTMLElement): void {
|
||||
this.scrollableContent = scrollableContent;
|
||||
this.timeGrid = scrollableContent.querySelector('swp-time-grid');
|
||||
this.scrollableContent.style.scrollBehavior = 'auto';
|
||||
|
||||
// Add scroll listener to detect actual scrolling
|
||||
this.scrollListener = this.handleScroll.bind(this);
|
||||
this.scrollableContent.addEventListener('scroll', this.scrollListener, { passive: true });
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Listen to mousemove directly from document to always get mouse coords
|
||||
document.body.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
private trackMouse = (e: PointerEvent): void => {
|
||||
if (this.isDragging) {
|
||||
this.mouseY = e.clientY;
|
||||
}
|
||||
});
|
||||
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
};
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
|
||||
// Listen to drag events from DragDropManager
|
||||
this.eventBus.on('drag:start', (event: Event) => {
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event: Event) => {
|
||||
const payload = (event as CustomEvent).detail;
|
||||
this.draggedClone = payload.draggedClone;
|
||||
this.draggedElement = payload.element;
|
||||
this.startDrag();
|
||||
});
|
||||
|
||||
this.eventBus.on('drag:end', () => this.stopDrag());
|
||||
this.eventBus.on('drag:cancelled', () => this.stopDrag());
|
||||
|
||||
// Stop scrolling when event converts to/from all-day
|
||||
this.eventBus.on('drag:mouseenter-header', () => {
|
||||
console.log('🔄 EdgeScrollManager: Event converting to all-day - stopping scroll');
|
||||
this.stopDrag();
|
||||
});
|
||||
|
||||
this.eventBus.on('drag:mouseenter-column', () => {
|
||||
this.startDrag();
|
||||
});
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => this.stopDrag());
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => this.stopDrag());
|
||||
}
|
||||
|
||||
private startDrag(): void {
|
||||
console.log('🎬 EdgeScrollManager: Starting drag');
|
||||
this.isDragging = true;
|
||||
this.isScrolling = false; // Reset scroll state
|
||||
this.lastTs = performance.now();
|
||||
|
||||
// Save initial scroll position
|
||||
if (this.scrollableContent) {
|
||||
this.initialScrollTop = this.scrollableContent.scrollTop;
|
||||
}
|
||||
this.isScrolling = false;
|
||||
this.lastTs = 0;
|
||||
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
|
||||
|
||||
if (this.scrollRAF === null) {
|
||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
||||
this.scrollRAF = requestAnimationFrame(this.scrollTick);
|
||||
}
|
||||
}
|
||||
|
||||
private stopDrag(): void {
|
||||
this.isDragging = false;
|
||||
|
||||
// Emit stopped event if we were scrolling
|
||||
if (this.isScrolling) {
|
||||
this.isScrolling = false;
|
||||
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (drag ended)');
|
||||
this.eventBus.emit('edgescroll:stopped', {});
|
||||
}
|
||||
this.setScrollingState(false);
|
||||
|
||||
if (this.scrollRAF !== null) {
|
||||
cancelAnimationFrame(this.scrollRAF);
|
||||
this.scrollRAF = null;
|
||||
}
|
||||
|
||||
this.rect = null;
|
||||
this.lastTs = 0;
|
||||
this.initialScrollTop = 0;
|
||||
}
|
||||
|
||||
private handleScroll(): void {
|
||||
if (!this.isDragging || !this.scrollableContent) return;
|
||||
private calculateVelocity(): number {
|
||||
if (!this.rect) return 0;
|
||||
|
||||
const currentScrollTop = this.scrollableContent.scrollTop;
|
||||
const scrollDelta = Math.abs(currentScrollTop - this.initialScrollTop);
|
||||
|
||||
// Only emit started event if we've actually scrolled more than 1px
|
||||
if (scrollDelta > 1 && !this.isScrolling) {
|
||||
this.isScrolling = true;
|
||||
console.log('💾 EdgeScrollManager: Edge-scroll started (actual scroll detected)', {
|
||||
initialScrollTop: this.initialScrollTop,
|
||||
currentScrollTop,
|
||||
scrollDelta
|
||||
});
|
||||
this.eventBus.emit('edgescroll:started', {});
|
||||
}
|
||||
}
|
||||
|
||||
private scrollTick(ts: number): void {
|
||||
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
|
||||
this.lastTs = ts;
|
||||
|
||||
if (!this.scrollableContent) {
|
||||
this.stopDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache rect for performance (only measure once per frame)
|
||||
if (!this.rect) {
|
||||
this.rect = this.scrollableContent.getBoundingClientRect();
|
||||
}
|
||||
|
||||
let vy = 0;
|
||||
if (this.isDragging) {
|
||||
const distTop = this.mouseY - this.rect.top;
|
||||
const distBot = this.rect.bottom - this.mouseY;
|
||||
|
||||
// Check top edge
|
||||
if (distTop < this.INNER_ZONE) {
|
||||
vy = -this.FAST_SPEED_PXS;
|
||||
} else if (distTop < this.OUTER_ZONE) {
|
||||
vy = -this.SLOW_SPEED_PXS;
|
||||
}
|
||||
// Check bottom edge
|
||||
else if (distBot < this.INNER_ZONE) {
|
||||
vy = this.FAST_SPEED_PXS;
|
||||
} else if (distBot < this.OUTER_ZONE) {
|
||||
vy = this.SLOW_SPEED_PXS;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (vy !== 0 && this.isDragging && this.timeGrid && this.draggedClone) {
|
||||
// Check if we can scroll in the requested direction
|
||||
const currentScrollTop = this.scrollableContent.scrollTop;
|
||||
const scrollableHeight = this.scrollableContent.clientHeight;
|
||||
const timeGridHeight = this.timeGrid.clientHeight;
|
||||
private isAtBoundary(velocity: number): boolean {
|
||||
if (!this.scrollableContent || !this.timeGrid || !this.draggedElement) return false;
|
||||
|
||||
// Get dragged element position and height
|
||||
const cloneRect = this.draggedClone.getBoundingClientRect();
|
||||
const cloneBottom = cloneRect.bottom;
|
||||
const timeGridRect = this.timeGrid.getBoundingClientRect();
|
||||
const timeGridBottom = timeGridRect.bottom;
|
||||
const atTop = this.scrollableContent.scrollTop <= 0 && velocity < 0;
|
||||
const atBottom = velocity > 0 &&
|
||||
this.draggedElement.getBoundingClientRect().bottom >=
|
||||
this.timeGrid.getBoundingClientRect().bottom;
|
||||
|
||||
// Check boundaries
|
||||
const atTop = currentScrollTop <= 0 && vy < 0;
|
||||
const atBottom = (cloneBottom >= timeGridBottom) && vy > 0;
|
||||
|
||||
|
||||
if (atTop || atBottom) {
|
||||
// At boundary - stop scrolling
|
||||
if (this.isScrolling) {
|
||||
this.isScrolling = false;
|
||||
this.initialScrollTop = this.scrollableContent.scrollTop;
|
||||
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (reached boundary)');
|
||||
this.eventBus.emit('edgescroll:stopped', {});
|
||||
return atTop || atBottom;
|
||||
}
|
||||
|
||||
// Continue RAF loop to detect when mouse moves away from boundary
|
||||
if (this.isDragging) {
|
||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
||||
}
|
||||
private setScrollingState(scrolling: boolean): void {
|
||||
if (this.isScrolling === scrolling) return;
|
||||
|
||||
this.isScrolling = scrolling;
|
||||
if (scrolling) {
|
||||
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STARTED, {});
|
||||
} else {
|
||||
// Not at boundary - apply scroll
|
||||
this.scrollableContent.scrollTop += vy * dt;
|
||||
this.rect = null; // Invalidate cache for next frame
|
||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
||||
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
|
||||
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
|
||||
}
|
||||
} else {
|
||||
// Mouse moved away from edge - stop scrolling
|
||||
if (this.isScrolling) {
|
||||
this.isScrolling = false;
|
||||
this.initialScrollTop = this.scrollableContent.scrollTop; // Reset for next scroll
|
||||
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (mouse left edge)');
|
||||
this.eventBus.emit('edgescroll:stopped', {});
|
||||
}
|
||||
|
||||
// Continue RAF loop even if not scrolling, to detect edge entry
|
||||
if (this.isDragging) {
|
||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
||||
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.stopDrag();
|
||||
}
|
||||
}
|
||||
this.setScrollingState(false);
|
||||
}
|
||||
|
||||
this.scrollRAF = requestAnimationFrame(this.scrollTick);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
/**
|
||||
* EventFilterManager - Handles fuzzy search filtering of calendar events
|
||||
* Uses Fuse.js for fuzzy matching (Apache 2.0 License)
|
||||
*/
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
|
||||
// Import Fuse.js from npm
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
interface FuseResult {
|
||||
item: ICalendarEvent;
|
||||
refIndex: number;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export class EventFilterManager {
|
||||
private searchInput: HTMLInputElement | null = null;
|
||||
private allEvents: ICalendarEvent[] = [];
|
||||
private matchingEventIds: Set<string> = new Set();
|
||||
private isFilterActive: boolean = false;
|
||||
private frameRequest: number | null = null;
|
||||
private fuse: Fuse<ICalendarEvent> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Wait for DOM to be ready before initializing
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.init();
|
||||
});
|
||||
} else {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
// Find search input
|
||||
this.searchInput = document.querySelector('swp-search-container input[type="search"]');
|
||||
|
||||
if (!this.searchInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
this.setupSearchListeners();
|
||||
this.subscribeToEvents();
|
||||
|
||||
// Initialization complete
|
||||
}
|
||||
|
||||
private setupSearchListeners(): void {
|
||||
if (!this.searchInput) return;
|
||||
|
||||
// Listen for input changes
|
||||
this.searchInput.addEventListener('input', (e) => {
|
||||
const query = (e.target as HTMLInputElement).value;
|
||||
this.handleSearchInput(query);
|
||||
});
|
||||
|
||||
// Listen for escape key
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.clearFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Listen for events data updates
|
||||
eventBus.on(CoreEvents.EVENTS_RENDERED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.events) {
|
||||
this.updateEventsList(detail.events);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateEventsList(events: ICalendarEvent[]): void {
|
||||
this.allEvents = events;
|
||||
|
||||
// Initialize Fuse with the new events list
|
||||
this.fuse = new Fuse(this.allEvents, {
|
||||
keys: ['title', 'description'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 2, // Minimum 2 characters for a match
|
||||
shouldSort: true,
|
||||
ignoreLocation: true // Search anywhere in the string
|
||||
});
|
||||
|
||||
|
||||
// Re-apply filter if active
|
||||
if (this.isFilterActive && this.searchInput) {
|
||||
this.applyFilter(this.searchInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearchInput(query: string): void {
|
||||
// Cancel any pending filter
|
||||
if (this.frameRequest) {
|
||||
cancelAnimationFrame(this.frameRequest);
|
||||
}
|
||||
|
||||
// Debounce with requestAnimationFrame
|
||||
this.frameRequest = requestAnimationFrame(() => {
|
||||
if (query.length === 0) {
|
||||
// Only clear when input is completely empty
|
||||
this.clearFilter();
|
||||
} else {
|
||||
// Let Fuse.js handle minimum character length via minMatchCharLength
|
||||
this.applyFilter(query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyFilter(query: string): void {
|
||||
if (!this.fuse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform fuzzy search
|
||||
const results = this.fuse.search(query);
|
||||
|
||||
// Extract matching event IDs
|
||||
this.matchingEventIds.clear();
|
||||
results.forEach((result: FuseResult) => {
|
||||
if (result.item && result.item.id) {
|
||||
this.matchingEventIds.add(result.item.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update filter state
|
||||
this.isFilterActive = true;
|
||||
|
||||
// Update visual state
|
||||
this.updateVisualState();
|
||||
|
||||
// Emit filter changed event
|
||||
eventBus.emit(CoreEvents.FILTER_CHANGED, {
|
||||
active: true,
|
||||
query: query,
|
||||
matchingIds: Array.from(this.matchingEventIds)
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private clearFilter(): void {
|
||||
this.isFilterActive = false;
|
||||
this.matchingEventIds.clear();
|
||||
|
||||
// Clear search input
|
||||
if (this.searchInput) {
|
||||
this.searchInput.value = '';
|
||||
}
|
||||
|
||||
// Update visual state
|
||||
this.updateVisualState();
|
||||
|
||||
// Emit filter cleared event
|
||||
eventBus.emit(CoreEvents.FILTER_CHANGED, {
|
||||
active: false,
|
||||
query: '',
|
||||
matchingIds: []
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private updateVisualState(): void {
|
||||
// Update search container styling
|
||||
const searchContainer = document.querySelector('swp-search-container');
|
||||
if (searchContainer) {
|
||||
if (this.isFilterActive) {
|
||||
searchContainer.classList.add('filter-active');
|
||||
} else {
|
||||
searchContainer.classList.remove('filter-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Update all events layers
|
||||
const eventsLayers = document.querySelectorAll('swp-events-layer');
|
||||
eventsLayers.forEach(layer => {
|
||||
if (this.isFilterActive) {
|
||||
layer.setAttribute('data-filter-active', 'true');
|
||||
|
||||
// Mark matching events
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
const eventId = event.getAttribute('data-event-id');
|
||||
if (eventId && this.matchingEventIds.has(eventId)) {
|
||||
event.setAttribute('data-matches', 'true');
|
||||
} else {
|
||||
event.removeAttribute('data-matches');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
layer.removeAttribute('data-filter-active');
|
||||
|
||||
// Remove all match attributes
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
event.removeAttribute('data-matches');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event matches the current filter
|
||||
*/
|
||||
public eventMatchesFilter(eventId: string): boolean {
|
||||
if (!this.isFilterActive) {
|
||||
return true; // No filter active, all events match
|
||||
}
|
||||
return this.matchingEventIds.has(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current filter state
|
||||
*/
|
||||
public getFilterState(): { active: boolean; matchingIds: string[] } {
|
||||
return {
|
||||
active: this.isFilterActive,
|
||||
matchingIds: Array.from(this.matchingEventIds)
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
/**
|
||||
* EventLayoutCoordinator - Coordinates event layout calculations
|
||||
*
|
||||
* Separates layout logic from rendering concerns.
|
||||
* Calculates stack levels, groups events, and determines rendering strategy.
|
||||
*/
|
||||
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { EventStackManager, IEventGroup, IStackLink } from './EventStackManager';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
export interface IGridGroupLayout {
|
||||
events: ICalendarEvent[];
|
||||
stackLevel: number;
|
||||
position: { top: number };
|
||||
columns: ICalendarEvent[][]; // Events grouped by column (events in same array share a column)
|
||||
}
|
||||
|
||||
export interface IStackedEventLayout {
|
||||
event: ICalendarEvent;
|
||||
stackLink: IStackLink;
|
||||
position: { top: number; height: number };
|
||||
}
|
||||
|
||||
export interface IColumnLayout {
|
||||
gridGroups: IGridGroupLayout[];
|
||||
stackedEvents: IStackedEventLayout[];
|
||||
}
|
||||
|
||||
export class EventLayoutCoordinator {
|
||||
private stackManager: EventStackManager;
|
||||
private config: Configuration;
|
||||
private positionUtils: PositionUtils;
|
||||
|
||||
constructor(stackManager: EventStackManager, config: Configuration, positionUtils: PositionUtils) {
|
||||
this.stackManager = stackManager;
|
||||
this.config = config;
|
||||
this.positionUtils = positionUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate complete layout for a column of events (recursive approach)
|
||||
*/
|
||||
public calculateColumnLayout(columnEvents: ICalendarEvent[]): IColumnLayout {
|
||||
if (columnEvents.length === 0) {
|
||||
return { gridGroups: [], stackedEvents: [] };
|
||||
}
|
||||
|
||||
const gridGroupLayouts: IGridGroupLayout[] = [];
|
||||
const stackedEventLayouts: IStackedEventLayout[] = [];
|
||||
const renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }> = [];
|
||||
let remaining = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
// Process events recursively
|
||||
while (remaining.length > 0) {
|
||||
// Take first event
|
||||
const firstEvent = remaining[0];
|
||||
|
||||
// Find events that could be in GRID with first event
|
||||
// Use expanding search to find chains (A→B→C where each conflicts with next)
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
|
||||
|
||||
// Use refactored method for expanding grid candidates
|
||||
const gridCandidates = this.expandGridCandidates(firstEvent, remaining, thresholdMinutes);
|
||||
|
||||
// Decide: should this group be GRID or STACK?
|
||||
const group: IEventGroup = {
|
||||
events: gridCandidates,
|
||||
containerType: 'NONE',
|
||||
startTime: firstEvent.start
|
||||
};
|
||||
const containerType = this.stackManager.decideContainerType(group);
|
||||
|
||||
if (containerType === 'GRID' && gridCandidates.length > 1) {
|
||||
// Render as GRID
|
||||
const gridStackLevel = this.calculateGridGroupStackLevelFromRendered(
|
||||
gridCandidates,
|
||||
renderedEventsWithLevels
|
||||
);
|
||||
|
||||
// Ensure we get the earliest event (explicit sort for robustness)
|
||||
const earliestEvent = [...gridCandidates].sort((a, b) => a.start.getTime() - b.start.getTime())[0];
|
||||
const position = this.positionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
|
||||
const columns = this.allocateColumns(gridCandidates);
|
||||
|
||||
gridGroupLayouts.push({
|
||||
events: gridCandidates,
|
||||
stackLevel: gridStackLevel,
|
||||
position: { top: position.top + 1 },
|
||||
columns
|
||||
});
|
||||
|
||||
// Mark all events in grid with their stack level
|
||||
gridCandidates.forEach(e => renderedEventsWithLevels.push({ event: e, level: gridStackLevel }));
|
||||
|
||||
// Remove all events in this grid from remaining
|
||||
remaining = remaining.filter(e => !gridCandidates.includes(e));
|
||||
} else {
|
||||
// Render first event as STACKED
|
||||
const stackLevel = this.calculateStackLevelFromRendered(
|
||||
firstEvent,
|
||||
renderedEventsWithLevels
|
||||
);
|
||||
|
||||
const position = this.positionUtils.calculateEventPosition(firstEvent.start, firstEvent.end);
|
||||
stackedEventLayouts.push({
|
||||
event: firstEvent,
|
||||
stackLink: { stackLevel },
|
||||
position: { top: position.top + 1, height: position.height - 3 }
|
||||
});
|
||||
|
||||
// Mark this event with its stack level
|
||||
renderedEventsWithLevels.push({ event: firstEvent, level: stackLevel });
|
||||
|
||||
// Remove only first event from remaining
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gridGroups: gridGroupLayouts,
|
||||
stackedEvents: stackedEventLayouts
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stack level for a grid group based on already rendered events
|
||||
*/
|
||||
private calculateGridGroupStackLevelFromRendered(
|
||||
gridEvents: ICalendarEvent[],
|
||||
renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }>
|
||||
): number {
|
||||
// Find highest stack level of any rendered event that overlaps with this grid
|
||||
let maxOverlappingLevel = -1;
|
||||
|
||||
for (const gridEvent of gridEvents) {
|
||||
for (const rendered of renderedEventsWithLevels) {
|
||||
if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) {
|
||||
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxOverlappingLevel + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stack level for a single stacked event based on already rendered events
|
||||
*/
|
||||
private calculateStackLevelFromRendered(
|
||||
event: ICalendarEvent,
|
||||
renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }>
|
||||
): number {
|
||||
// Find highest stack level of any rendered event that overlaps with this event
|
||||
let maxOverlappingLevel = -1;
|
||||
|
||||
for (const rendered of renderedEventsWithLevels) {
|
||||
if (this.stackManager.doEventsOverlap(event, rendered.event)) {
|
||||
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
|
||||
}
|
||||
}
|
||||
|
||||
return maxOverlappingLevel + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if two events have a conflict based on threshold
|
||||
*
|
||||
* @param event1 - First event
|
||||
* @param event2 - Second event
|
||||
* @param thresholdMinutes - Threshold in minutes
|
||||
* @returns true if events conflict
|
||||
*/
|
||||
private detectConflict(event1: ICalendarEvent, event2: ICalendarEvent, thresholdMinutes: number): boolean {
|
||||
// Check 1: Start-to-start conflict (starts within threshold)
|
||||
const startToStartDiff = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60);
|
||||
if (startToStartDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(event1, event2)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2: End-to-start conflict (event1 starts within threshold before event2 ends)
|
||||
const endToStartMinutes = (event2.end.getTime() - event1.start.getTime()) / (1000 * 60);
|
||||
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 3: Reverse end-to-start (event2 starts within threshold before event1 ends)
|
||||
const reverseEndToStart = (event1.end.getTime() - event2.start.getTime()) / (1000 * 60);
|
||||
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand grid candidates to find all events connected by conflict chains
|
||||
*
|
||||
* Uses expanding search to find chains (A→B→C where each conflicts with next)
|
||||
*
|
||||
* @param firstEvent - The first event to start with
|
||||
* @param remaining - Remaining events to check
|
||||
* @param thresholdMinutes - Threshold in minutes
|
||||
* @returns Array of all events in the conflict chain
|
||||
*/
|
||||
private expandGridCandidates(
|
||||
firstEvent: ICalendarEvent,
|
||||
remaining: ICalendarEvent[],
|
||||
thresholdMinutes: number
|
||||
): ICalendarEvent[] {
|
||||
const gridCandidates = [firstEvent];
|
||||
let candidatesChanged = true;
|
||||
|
||||
// Keep expanding until no new candidates can be added
|
||||
while (candidatesChanged) {
|
||||
candidatesChanged = false;
|
||||
|
||||
for (let i = 1; i < remaining.length; i++) {
|
||||
const candidate = remaining[i];
|
||||
|
||||
// Skip if already in candidates
|
||||
if (gridCandidates.includes(candidate)) continue;
|
||||
|
||||
// Check if candidate conflicts with ANY event in gridCandidates
|
||||
for (const existingCandidate of gridCandidates) {
|
||||
if (this.detectConflict(candidate, existingCandidate, thresholdMinutes)) {
|
||||
gridCandidates.push(candidate);
|
||||
candidatesChanged = true;
|
||||
break; // Found conflict, move to next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gridCandidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate events to columns within a grid group
|
||||
*
|
||||
* Events that don't overlap can share the same column.
|
||||
* Uses a greedy algorithm to minimize the number of columns.
|
||||
*
|
||||
* @param events - Events in the grid group (should already be sorted by start time)
|
||||
* @returns Array of columns, where each column is an array of events
|
||||
*/
|
||||
private allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
|
||||
if (events.length === 0) return [];
|
||||
if (events.length === 1) return [[events[0]]];
|
||||
|
||||
const columns: ICalendarEvent[][] = [];
|
||||
|
||||
// For each event, try to place it in an existing column where it doesn't overlap
|
||||
for (const event of events) {
|
||||
let placed = false;
|
||||
|
||||
// Try to find a column where this event doesn't overlap with any existing event
|
||||
for (const column of columns) {
|
||||
const hasOverlap = column.some(colEvent =>
|
||||
this.stackManager.doEventsOverlap(event, colEvent)
|
||||
);
|
||||
|
||||
if (!hasOverlap) {
|
||||
column.push(event);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no suitable column found, create a new one
|
||||
if (!placed) {
|
||||
columns.push([event]);
|
||||
}
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import { IEventBus, ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
import { IEntityService } from '../storage/IEntityService';
|
||||
|
||||
/**
|
||||
* EventManager - Event lifecycle and CRUD operations
|
||||
* Delegates all data operations to EventService
|
||||
* EventService provides CRUD operations via BaseEntityService (save, delete, getAll)
|
||||
*/
|
||||
export class EventManager {
|
||||
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private eventService: EventService;
|
||||
|
||||
constructor(
|
||||
private eventBus: IEventBus,
|
||||
dateService: DateService,
|
||||
config: Configuration,
|
||||
eventService: IEntityService<ICalendarEvent>
|
||||
) {
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.eventService = eventService as EventService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load event data from service
|
||||
* Ensures data is loaded (called during initialization)
|
||||
*/
|
||||
public async loadData(): Promise<void> {
|
||||
try {
|
||||
// Just ensure service is ready - getAll() will return data
|
||||
await this.eventService.getAll();
|
||||
} catch (error) {
|
||||
console.error('Failed to load event data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events from service
|
||||
*/
|
||||
public async getEvents(copy: boolean = false): Promise<ICalendarEvent[]> {
|
||||
const events = await this.eventService.getAll();
|
||||
return copy ? [...events] : events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event by ID from service
|
||||
*/
|
||||
public async getEventById(id: string): Promise<ICalendarEvent | undefined> {
|
||||
const event = await this.eventService.get(id);
|
||||
return event || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event by ID and return event info for navigation
|
||||
* @param id Event ID to find
|
||||
* @returns Event with navigation info or null if not found
|
||||
*/
|
||||
public async getEventForNavigation(id: string): Promise<{ event: ICalendarEvent; eventDate: Date } | null> {
|
||||
const event = await this.getEventById(id);
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate event dates
|
||||
const validation = this.dateService.validateDate(event.start);
|
||||
if (!validation.valid) {
|
||||
console.warn(`EventManager: Invalid event start date for event ${id}:`, validation.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
if (!this.dateService.isValidRange(event.start, event.end)) {
|
||||
console.warn(`EventManager: Invalid date range for event ${id}: start must be before end`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
eventDate: event.start
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific event by ID
|
||||
* Emits navigation events for other managers to handle
|
||||
* @param eventId Event ID to navigate to
|
||||
* @returns true if event found and navigation initiated, false otherwise
|
||||
*/
|
||||
public async navigateToEvent(eventId: string): Promise<boolean> {
|
||||
const eventInfo = await this.getEventForNavigation(eventId);
|
||||
if (!eventInfo) {
|
||||
console.warn(`EventManager: Event with ID ${eventId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { event, eventDate } = eventInfo;
|
||||
|
||||
// Emit navigation request event
|
||||
this.eventBus.emit(CoreEvents.NAVIGATE_TO_EVENT, {
|
||||
eventId,
|
||||
event,
|
||||
eventDate,
|
||||
eventStartTime: event.start
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events that overlap with a given time period
|
||||
*/
|
||||
public async getEventsForPeriod(startDate: Date, endDate: Date): Promise<ICalendarEvent[]> {
|
||||
const events = await this.eventService.getAll();
|
||||
// Event overlaps period if it starts before period ends AND ends after period starts
|
||||
return events.filter(event => {
|
||||
return event.start <= endDate && event.end >= startDate;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event and add it to the calendar
|
||||
* Generates ID and saves via EventService
|
||||
*/
|
||||
public async addEvent(event: Omit<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
|
||||
// Generate unique ID
|
||||
const id = `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const newEvent: ICalendarEvent = {
|
||||
...event,
|
||||
id,
|
||||
syncStatus: 'synced' // No queue yet, mark as synced
|
||||
};
|
||||
|
||||
await this.eventService.save(newEvent);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
|
||||
event: newEvent
|
||||
});
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
* Merges updates with existing event and saves
|
||||
*/
|
||||
public async updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null> {
|
||||
try {
|
||||
const existingEvent = await this.eventService.get(id);
|
||||
if (!existingEvent) {
|
||||
throw new Error(`Event with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const updatedEvent: ICalendarEvent = {
|
||||
...existingEvent,
|
||||
...updates,
|
||||
id, // Ensure ID doesn't change
|
||||
syncStatus: 'synced' // No queue yet, mark as synced
|
||||
};
|
||||
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
||||
event: updatedEvent
|
||||
});
|
||||
|
||||
return updatedEvent;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update event ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
* Calls EventService.delete()
|
||||
*/
|
||||
public async deleteEvent(id: string): Promise<boolean> {
|
||||
try {
|
||||
await this.eventService.delete(id);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_DELETED, {
|
||||
eventId: id
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete event ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
/**
|
||||
* EventStackManager - Manages visual stacking of overlapping calendar events
|
||||
*
|
||||
* This class handles the creation and maintenance of "stack chains" - doubly-linked
|
||||
* lists of overlapping events stored directly in DOM elements via data attributes.
|
||||
*
|
||||
* Implements 3-phase algorithm for grid + nested stacking:
|
||||
* Phase 1: Group events by start time proximity (configurable threshold)
|
||||
* Phase 2: Decide container type (GRID vs STACKING)
|
||||
* Phase 3: Handle late arrivals (nested stacking - NOT IMPLEMENTED)
|
||||
*
|
||||
* @see STACKING_CONCEPT.md for detailed documentation
|
||||
* @see stacking-visualization.html for visual examples
|
||||
*/
|
||||
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
export interface IStackLink {
|
||||
prev?: string; // Event ID of previous event in stack
|
||||
next?: string; // Event ID of next event in stack
|
||||
stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.)
|
||||
}
|
||||
|
||||
export interface IEventGroup {
|
||||
events: ICalendarEvent[];
|
||||
containerType: 'NONE' | 'GRID' | 'STACKING';
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
export class EventStackManager {
|
||||
private static readonly STACK_OFFSET_PX = 15;
|
||||
private config: Configuration;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PHASE 1: Start Time Grouping
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Group events by time conflicts (both start-to-start and end-to-start within threshold)
|
||||
*
|
||||
* Events are grouped if:
|
||||
* 1. They start within ±threshold minutes of each other (start-to-start)
|
||||
* 2. One event starts within threshold minutes before another ends (end-to-start conflict)
|
||||
*/
|
||||
public groupEventsByStartTime(events: ICalendarEvent[]): IEventGroup[] {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
// Get threshold from config
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
|
||||
|
||||
// Sort events by start time
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
const groups: IEventGroup[] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
// Find existing group that this event conflicts with
|
||||
const existingGroup = groups.find(group => {
|
||||
// Check if event conflicts with ANY event in the group
|
||||
return group.events.some(groupEvent => {
|
||||
// Start-to-start conflict: events start within threshold
|
||||
const startToStartMinutes = Math.abs(event.start.getTime() - groupEvent.start.getTime()) / (1000 * 60);
|
||||
if (startToStartMinutes <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// End-to-start conflict: event starts within threshold before groupEvent ends
|
||||
const endToStartMinutes = (groupEvent.end.getTime() - event.start.getTime()) / (1000 * 60);
|
||||
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check reverse: groupEvent starts within threshold before event ends
|
||||
const reverseEndToStart = (event.end.getTime() - groupEvent.start.getTime()) / (1000 * 60);
|
||||
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.events.push(event);
|
||||
} else {
|
||||
groups.push({
|
||||
events: [event],
|
||||
containerType: 'NONE',
|
||||
startTime: event.start
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// PHASE 2: Container Type Decision
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Decide container type for a group of events
|
||||
*
|
||||
* Rule: Events starting simultaneously (within threshold) should ALWAYS use GRID,
|
||||
* even if they overlap each other. This provides better visual indication that
|
||||
* events start at the same time.
|
||||
*/
|
||||
public decideContainerType(group: IEventGroup): 'NONE' | 'GRID' | 'STACKING' {
|
||||
if (group.events.length === 1) {
|
||||
return 'NONE';
|
||||
}
|
||||
|
||||
// If events are grouped together (start within threshold), they should share columns (GRID)
|
||||
// This is true EVEN if they overlap, because the visual priority is to show
|
||||
// that they start simultaneously.
|
||||
return 'GRID';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if two events overlap in time
|
||||
*/
|
||||
public doEventsOverlap(event1: ICalendarEvent, event2: ICalendarEvent): boolean {
|
||||
return event1.start < event2.end && event1.end > event2.start;
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// Stack Level Calculation
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create optimized stack links (events share levels when possible)
|
||||
*/
|
||||
public createOptimizedStackLinks(events: ICalendarEvent[]): Map<string, IStackLink> {
|
||||
const stackLinks = new Map<string, IStackLink>();
|
||||
|
||||
if (events.length === 0) return stackLinks;
|
||||
|
||||
// Sort by start time
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
// Step 1: Assign stack levels
|
||||
for (const event of sorted) {
|
||||
// Find all events this event overlaps with
|
||||
const overlapping = sorted.filter(other =>
|
||||
other !== event && this.doEventsOverlap(event, other)
|
||||
);
|
||||
|
||||
// Find the MINIMUM required level (must be above all overlapping events)
|
||||
let minRequiredLevel = 0;
|
||||
for (const other of overlapping) {
|
||||
const otherLink = stackLinks.get(other.id);
|
||||
if (otherLink) {
|
||||
// Must be at least one level above the overlapping event
|
||||
minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1);
|
||||
}
|
||||
}
|
||||
|
||||
stackLinks.set(event.id, { stackLevel: minRequiredLevel });
|
||||
}
|
||||
|
||||
// Step 2: Build prev/next chains for overlapping events at adjacent stack levels
|
||||
for (const event of sorted) {
|
||||
const currentLink = stackLinks.get(event.id)!;
|
||||
|
||||
// Find overlapping events that are directly below (stackLevel - 1)
|
||||
const overlapping = sorted.filter(other =>
|
||||
other !== event && this.doEventsOverlap(event, other)
|
||||
);
|
||||
|
||||
const directlyBelow = overlapping.filter(other => {
|
||||
const otherLink = stackLinks.get(other.id);
|
||||
return otherLink && otherLink.stackLevel === currentLink.stackLevel - 1;
|
||||
});
|
||||
|
||||
if (directlyBelow.length > 0) {
|
||||
// Use the first one in sorted order as prev
|
||||
currentLink.prev = directlyBelow[0].id;
|
||||
}
|
||||
|
||||
// Find overlapping events that are directly above (stackLevel + 1)
|
||||
const directlyAbove = overlapping.filter(other => {
|
||||
const otherLink = stackLinks.get(other.id);
|
||||
return otherLink && otherLink.stackLevel === currentLink.stackLevel + 1;
|
||||
});
|
||||
|
||||
if (directlyAbove.length > 0) {
|
||||
// Use the first one in sorted order as next
|
||||
currentLink.next = directlyAbove[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
return stackLinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate marginLeft based on stack level
|
||||
*/
|
||||
public calculateMarginLeft(stackLevel: number): number {
|
||||
return stackLevel * EventStackManager.STACK_OFFSET_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate zIndex based on stack level
|
||||
*/
|
||||
public calculateZIndex(stackLevel: number): number {
|
||||
return 100 + stackLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize stack link to JSON string
|
||||
*/
|
||||
public serializeStackLink(stackLink: IStackLink): string {
|
||||
return JSON.stringify(stackLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize JSON string to stack link
|
||||
*/
|
||||
public deserializeStackLink(json: string): IStackLink | null {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stack link to DOM element
|
||||
*/
|
||||
public applyStackLinkToElement(element: HTMLElement, stackLink: IStackLink): void {
|
||||
element.dataset.stackLink = this.serializeStackLink(stackLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stack link from DOM element
|
||||
*/
|
||||
public getStackLinkFromElement(element: HTMLElement): IStackLink | null {
|
||||
const data = element.dataset.stackLink;
|
||||
if (!data) return null;
|
||||
return this.deserializeStackLink(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply visual styling to element based on stack level
|
||||
*/
|
||||
public applyVisualStyling(element: HTMLElement, stackLevel: number): void {
|
||||
element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`;
|
||||
element.style.zIndex = `${this.calculateZIndex(stackLevel)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stack link from element
|
||||
*/
|
||||
public clearStackLinkFromElement(element: HTMLElement): void {
|
||||
delete element.dataset.stackLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear visual styling from element
|
||||
*/
|
||||
public clearVisualStyling(element: HTMLElement): void {
|
||||
element.style.marginLeft = '';
|
||||
element.style.zIndex = '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
/**
|
||||
* GridManager - Simplified grid manager using centralized GridRenderer
|
||||
* Delegates DOM rendering to GridRenderer, focuses on coordination
|
||||
*
|
||||
* Note: Events are now provided by IColumnDataSource (each column has its own events)
|
||||
*/
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { CalendarView } from '../types/CalendarTypes';
|
||||
import { GridRenderer } from '../renderers/GridRenderer';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { IColumnDataSource } from '../types/ColumnDataSource';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
/**
|
||||
* Simplified GridManager focused on coordination, delegates rendering to GridRenderer
|
||||
*/
|
||||
export class GridManager {
|
||||
private container: HTMLElement | null = null;
|
||||
private currentDate: Date = new Date();
|
||||
private currentView: CalendarView = 'week';
|
||||
private gridRenderer: GridRenderer;
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private dataSource: IColumnDataSource;
|
||||
|
||||
constructor(
|
||||
gridRenderer: GridRenderer,
|
||||
dateService: DateService,
|
||||
config: Configuration,
|
||||
dataSource: IColumnDataSource
|
||||
) {
|
||||
this.gridRenderer = gridRenderer;
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.dataSource = dataSource;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.findElements();
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
private findElements(): void {
|
||||
this.container = document.querySelector('swp-calendar-container');
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Listen for view changes
|
||||
eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.currentView = detail.currentView;
|
||||
this.dataSource.setCurrentView(this.currentView);
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Listen for navigation events from NavigationManager
|
||||
// NavigationManager has already created new grid with animation
|
||||
// GridManager only needs to update state, NOT re-render
|
||||
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.currentDate = detail.newDate;
|
||||
this.dataSource.setCurrentDate(this.currentDate);
|
||||
// Do NOT call render() - NavigationManager already created new grid
|
||||
});
|
||||
|
||||
// Listen for config changes that affect rendering
|
||||
eventBus.on(CoreEvents.REFRESH_REQUESTED, (e: Event) => {
|
||||
this.render();
|
||||
});
|
||||
|
||||
eventBus.on(CoreEvents.WORKWEEK_CHANGED, () => {
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main render method - delegates to GridRenderer
|
||||
* Note: CSS variables are automatically updated by ConfigManager when config changes
|
||||
* Note: Events are included in columns from IColumnDataSource
|
||||
*/
|
||||
public async render(): Promise<void> {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get columns from datasource - single source of truth (includes events per column)
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Set grid columns CSS variable based on actual column count
|
||||
document.documentElement.style.setProperty('--grid-columns', columns.length.toString());
|
||||
|
||||
// Delegate to GridRenderer with columns (events are inside each column)
|
||||
this.gridRenderer.renderGrid(
|
||||
this.container,
|
||||
this.currentDate,
|
||||
this.currentView,
|
||||
columns
|
||||
);
|
||||
|
||||
// Emit grid rendered event
|
||||
eventBus.emit(CoreEvents.GRID_RENDERED, {
|
||||
container: this.container,
|
||||
currentDate: this.currentDate,
|
||||
columns: columns
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import { eventBus } from '../core/EventBus';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { IHeaderRenderer, IHeaderRenderContext } from '../renderers/DateHeaderRenderer';
|
||||
import { IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IHeaderReadyEventPayload } from '../types/EventTypes';
|
||||
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
|
||||
import { IColumnDataSource } from '../types/ColumnDataSource';
|
||||
|
||||
/**
|
||||
* HeaderManager - Handles all header-related event logic
|
||||
* Separates event handling from rendering concerns
|
||||
* Uses dependency injection for renderer strategy
|
||||
*/
|
||||
export class HeaderManager {
|
||||
private headerRenderer: IHeaderRenderer;
|
||||
private config: Configuration;
|
||||
private dataSource: IColumnDataSource;
|
||||
|
||||
constructor(headerRenderer: IHeaderRenderer, config: Configuration, dataSource: IColumnDataSource) {
|
||||
this.headerRenderer = headerRenderer;
|
||||
this.config = config;
|
||||
this.dataSource = dataSource;
|
||||
|
||||
// Bind handler methods for event listeners
|
||||
this.handleDragMouseEnterHeader = this.handleDragMouseEnterHeader.bind(this);
|
||||
this.handleDragMouseLeaveHeader = this.handleDragMouseLeaveHeader.bind(this);
|
||||
|
||||
// Listen for navigation events to update header
|
||||
this.setupNavigationListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup header drag event listeners - Listen to DragDropManager events
|
||||
*/
|
||||
public setupHeaderDragListeners(): void {
|
||||
console.log('🎯 HeaderManager: Setting up drag event listeners');
|
||||
|
||||
// Subscribe to drag events from DragDropManager
|
||||
eventBus.on('drag:mouseenter-header', this.handleDragMouseEnterHeader);
|
||||
eventBus.on('drag:mouseleave-header', this.handleDragMouseLeaveHeader);
|
||||
|
||||
console.log('✅ HeaderManager: Drag event listeners attached');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag mouse enter header event
|
||||
*/
|
||||
private handleDragMouseEnterHeader(event: Event): void {
|
||||
const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } =
|
||||
(event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail;
|
||||
|
||||
console.log('🎯 HeaderManager: Received drag:mouseenter-header', {
|
||||
targetColumn: targetColumn.identifier,
|
||||
originalElement: !!originalElement,
|
||||
cloneElement: !!cloneElement
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag mouse leave header event
|
||||
*/
|
||||
private handleDragMouseLeaveHeader(event: Event): void {
|
||||
const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } =
|
||||
(event as CustomEvent<IDragMouseLeaveHeaderEventPayload>).detail;
|
||||
|
||||
console.log('🚪 HeaderManager: Received drag:mouseleave-header', {
|
||||
targetColumn: targetColumn?.identifier,
|
||||
originalElement: !!originalElement,
|
||||
cloneElement: !!cloneElement
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup navigation event listener
|
||||
*/
|
||||
private setupNavigationListener(): void {
|
||||
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (event) => {
|
||||
const { currentDate } = (event as CustomEvent).detail;
|
||||
this.updateHeader(currentDate);
|
||||
});
|
||||
|
||||
// Also listen for date changes (including initial setup)
|
||||
eventBus.on(CoreEvents.DATE_CHANGED, (event) => {
|
||||
const { currentDate } = (event as CustomEvent).detail;
|
||||
this.updateHeader(currentDate);
|
||||
});
|
||||
|
||||
// Listen for workweek header updates after grid rebuild
|
||||
//currentDate: this.currentDate,
|
||||
//currentView: this.currentView,
|
||||
//workweek: this.config.currentWorkWeek
|
||||
eventBus.on('workweek:header-update', (event) => {
|
||||
const { currentDate } = (event as CustomEvent).detail;
|
||||
this.updateHeader(currentDate);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Update header content for navigation
|
||||
*/
|
||||
private async updateHeader(currentDate: Date): Promise<void> {
|
||||
console.log('🎯 HeaderManager.updateHeader called', {
|
||||
currentDate,
|
||||
rendererType: this.headerRenderer.constructor.name
|
||||
});
|
||||
|
||||
const calendarHeader = document.querySelector('swp-calendar-header') as HTMLElement;
|
||||
if (!calendarHeader) {
|
||||
console.warn('❌ HeaderManager: No calendar header found!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing content
|
||||
calendarHeader.innerHTML = '';
|
||||
|
||||
// Update DataSource with current date and get columns
|
||||
this.dataSource.setCurrentDate(currentDate);
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Render new header content using injected renderer
|
||||
const context: IHeaderRenderContext = {
|
||||
columns: columns,
|
||||
config: this.config
|
||||
};
|
||||
|
||||
this.headerRenderer.render(calendarHeader, context);
|
||||
|
||||
// Setup event listeners on the new content
|
||||
this.setupHeaderDragListeners();
|
||||
|
||||
// Notify other managers that header is ready with period data
|
||||
const payload: IHeaderReadyEventPayload = {
|
||||
headerElements: ColumnDetectionUtils.getHeaderColumns(),
|
||||
};
|
||||
eventBus.emit('header:ready', payload);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
import { IEventBus, CalendarView } from '../types/CalendarTypes';
|
||||
import { EventRenderingService } from '../renderers/EventRendererManager';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { WeekInfoRenderer } from '../renderers/WeekInfoRenderer';
|
||||
import { GridRenderer } from '../renderers/GridRenderer';
|
||||
import { INavButtonClickedEventPayload } from '../types/EventTypes';
|
||||
import { IColumnDataSource } from '../types/ColumnDataSource';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
export class NavigationManager {
|
||||
private eventBus: IEventBus;
|
||||
private weekInfoRenderer: WeekInfoRenderer;
|
||||
private gridRenderer: GridRenderer;
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private dataSource: IColumnDataSource;
|
||||
private currentWeek: Date;
|
||||
private targetWeek: Date;
|
||||
private animationQueue: number = 0;
|
||||
|
||||
constructor(
|
||||
eventBus: IEventBus,
|
||||
eventRenderer: EventRenderingService,
|
||||
gridRenderer: GridRenderer,
|
||||
dateService: DateService,
|
||||
weekInfoRenderer: WeekInfoRenderer,
|
||||
config: Configuration,
|
||||
dataSource: IColumnDataSource
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.dateService = dateService;
|
||||
this.weekInfoRenderer = weekInfoRenderer;
|
||||
this.gridRenderer = gridRenderer;
|
||||
this.config = config;
|
||||
this.currentWeek = this.getISOWeekStart(new Date());
|
||||
this.targetWeek = new Date(this.currentWeek);
|
||||
this.dataSource = dataSource;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of the ISO week (Monday) for a given date
|
||||
* @param date - Any date in the week
|
||||
* @returns The Monday of the ISO week
|
||||
*/
|
||||
private getISOWeekStart(date: Date): Date {
|
||||
const weekBounds = this.dateService.getWeekBounds(date);
|
||||
return this.dateService.startOfDay(weekBounds.start);
|
||||
}
|
||||
|
||||
|
||||
private setupEventListeners(): void {
|
||||
|
||||
// Listen for filter changes and apply to pre-rendered grids
|
||||
this.eventBus.on(CoreEvents.FILTER_CHANGED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.weekInfoRenderer.applyFilterToPreRenderedGrids(detail);
|
||||
});
|
||||
|
||||
// Listen for navigation button clicks from NavigationButtons
|
||||
this.eventBus.on(CoreEvents.NAV_BUTTON_CLICKED, (event: Event) => {
|
||||
const { direction, newDate } = (event as CustomEvent<INavButtonClickedEventPayload>).detail;
|
||||
|
||||
// Navigate to the new date with animation
|
||||
this.navigateToDate(newDate, direction);
|
||||
});
|
||||
|
||||
// Listen for external navigation requests
|
||||
this.eventBus.on(CoreEvents.DATE_CHANGED, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const dateFromEvent = customEvent.detail.currentDate;
|
||||
|
||||
// Validate date before processing
|
||||
if (!dateFromEvent) {
|
||||
console.warn('NavigationManager: No date provided in DATE_CHANGED event');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDate = new Date(dateFromEvent);
|
||||
|
||||
// Use DateService validation
|
||||
const validation = this.dateService.validateDate(targetDate);
|
||||
if (!validation.valid) {
|
||||
console.warn('NavigationManager: Invalid date received:', validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigateToDate(targetDate);
|
||||
});
|
||||
|
||||
// Listen for event navigation requests
|
||||
this.eventBus.on(CoreEvents.NAVIGATE_TO_EVENT, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { eventDate, eventStartTime } = customEvent.detail;
|
||||
|
||||
if (!eventDate || !eventStartTime) {
|
||||
console.warn('NavigationManager: Invalid event navigation data');
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigateToEventDate(eventDate, eventStartTime);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific event date and emit scroll event after navigation
|
||||
*/
|
||||
private navigateToEventDate(eventDate: Date, eventStartTime: string): void {
|
||||
const weekStart = this.getISOWeekStart(eventDate);
|
||||
this.targetWeek = new Date(weekStart);
|
||||
|
||||
const currentTime = this.currentWeek.getTime();
|
||||
const targetTime = weekStart.getTime();
|
||||
|
||||
// Store event start time for scrolling after navigation
|
||||
const scrollAfterNavigation = () => {
|
||||
// Emit scroll request after navigation is complete
|
||||
this.eventBus.emit('scroll:to-event-time', {
|
||||
eventStartTime
|
||||
});
|
||||
};
|
||||
|
||||
if (currentTime < targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('next', weekStart);
|
||||
// Listen for navigation completion to trigger scroll
|
||||
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
|
||||
} else if (currentTime > targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('prev', weekStart);
|
||||
// Listen for navigation completion to trigger scroll
|
||||
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
|
||||
} else {
|
||||
// Already on correct week, just scroll
|
||||
scrollAfterNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private navigateToDate(date: Date, direction?: 'next' | 'previous' | 'today'): void {
|
||||
const weekStart = this.getISOWeekStart(date);
|
||||
this.targetWeek = new Date(weekStart);
|
||||
|
||||
const currentTime = this.currentWeek.getTime();
|
||||
const targetTime = weekStart.getTime();
|
||||
|
||||
// Use provided direction or calculate based on time comparison
|
||||
let animationDirection: 'next' | 'prev';
|
||||
|
||||
if (direction === 'next') {
|
||||
animationDirection = 'next';
|
||||
} else if (direction === 'previous') {
|
||||
animationDirection = 'prev';
|
||||
} else if (direction === 'today') {
|
||||
// For "today", determine direction based on current position
|
||||
animationDirection = currentTime < targetTime ? 'next' : 'prev';
|
||||
} else {
|
||||
// Fallback: calculate direction
|
||||
animationDirection = currentTime < targetTime ? 'next' : 'prev';
|
||||
}
|
||||
|
||||
if (currentTime !== targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition(animationDirection, weekStart);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation transition using pre-rendered containers when available
|
||||
*/
|
||||
private async animateTransition(direction: 'prev' | 'next', targetWeek: Date): Promise<void> {
|
||||
|
||||
const container = document.querySelector('swp-calendar-container') as HTMLElement;
|
||||
const currentGrid = document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])') as HTMLElement;
|
||||
|
||||
if (!container || !currentGrid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset all-day height BEFORE creating new grid to ensure base height
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--all-day-row-height', '0px');
|
||||
|
||||
let newGrid: HTMLElement;
|
||||
|
||||
console.group('🔧 NavigationManager.refactored');
|
||||
console.log('Calling GridRenderer instead of NavigationRenderer');
|
||||
console.log('Target week:', targetWeek);
|
||||
|
||||
// Update DataSource with target week and get columns
|
||||
this.dataSource.setCurrentDate(targetWeek);
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Always create a fresh container for consistent behavior
|
||||
newGrid = this.gridRenderer.createNavigationGrid(container, columns, targetWeek);
|
||||
|
||||
console.groupEnd();
|
||||
|
||||
|
||||
// Clear any existing transforms before animation
|
||||
newGrid.style.transform = '';
|
||||
currentGrid.style.transform = '';
|
||||
|
||||
// Animate transition using Web Animations API
|
||||
const slideOutAnimation = currentGrid.animate([
|
||||
{ transform: 'translateX(0)', opacity: '1' },
|
||||
{ transform: direction === 'next' ? 'translateX(-100%)' : 'translateX(100%)', opacity: '0.5' }
|
||||
], {
|
||||
duration: 400,
|
||||
easing: 'ease-in-out',
|
||||
fill: 'forwards'
|
||||
});
|
||||
|
||||
const slideInAnimation = newGrid.animate([
|
||||
{ transform: direction === 'next' ? 'translateX(100%)' : 'translateX(-100%)' },
|
||||
{ transform: 'translateX(0)' }
|
||||
], {
|
||||
duration: 400,
|
||||
easing: 'ease-in-out',
|
||||
fill: 'forwards'
|
||||
});
|
||||
|
||||
// Handle animation completion
|
||||
slideInAnimation.addEventListener('finish', () => {
|
||||
|
||||
// Cleanup: Remove all old grids except the new one
|
||||
const allGrids = container.querySelectorAll('swp-grid-container');
|
||||
for (let i = 0; i < allGrids.length - 1; i++) {
|
||||
allGrids[i].remove();
|
||||
}
|
||||
|
||||
// Reset positioning
|
||||
newGrid.style.position = 'relative';
|
||||
newGrid.removeAttribute('data-prerendered');
|
||||
|
||||
// Update state
|
||||
this.currentWeek = new Date(targetWeek);
|
||||
this.animationQueue--;
|
||||
|
||||
// If this was the last queued animation, ensure we're in sync
|
||||
if (this.animationQueue === 0) {
|
||||
this.currentWeek = new Date(this.targetWeek);
|
||||
}
|
||||
|
||||
// Emit navigation completed event
|
||||
this.eventBus.emit(CoreEvents.NAVIGATION_COMPLETED, {
|
||||
direction,
|
||||
newDate: this.currentWeek
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
import { eventBus } from '../core/EventBus';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IResizeEndEventPayload } from '../types/EventTypes';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
|
||||
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
|
||||
|
||||
export class ResizeHandleManager {
|
||||
private isResizing = false;
|
||||
private targetEl: SwpEventEl | null = null;
|
||||
|
||||
private startY = 0;
|
||||
private startDurationMin = 0;
|
||||
|
||||
private snapMin: number;
|
||||
private minDurationMin: number;
|
||||
private animationId: number | null = null;
|
||||
private currentHeight = 0;
|
||||
private targetHeight = 0;
|
||||
|
||||
private pointerCaptured = false;
|
||||
private prevZ?: string;
|
||||
|
||||
// Constants for better maintainability
|
||||
private readonly ANIMATION_SPEED = 0.35;
|
||||
private readonly Z_INDEX_RESIZING = '1000';
|
||||
private readonly EVENT_REFRESH_THRESHOLD = 0.5;
|
||||
|
||||
constructor(
|
||||
private config: Configuration,
|
||||
private positionUtils: PositionUtils
|
||||
) {
|
||||
const grid = this.config.gridSettings;
|
||||
this.snapMin = grid.snapInterval;
|
||||
this.minDurationMin = this.snapMin;
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
this.attachGlobalListeners();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.removeEventListeners();
|
||||
}
|
||||
|
||||
private removeEventListeners(): void {
|
||||
const calendarContainer = document.querySelector('swp-calendar-container');
|
||||
if (calendarContainer) {
|
||||
calendarContainer.removeEventListener('mouseover', this.onMouseOver, true);
|
||||
}
|
||||
|
||||
document.removeEventListener('pointerdown', this.onPointerDown, true);
|
||||
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||
}
|
||||
|
||||
private createResizeHandle(): HTMLElement {
|
||||
const handle = document.createElement('swp-resize-handle');
|
||||
handle.setAttribute('aria-label', 'Resize event');
|
||||
handle.setAttribute('role', 'separator');
|
||||
return handle;
|
||||
}
|
||||
|
||||
private attachGlobalListeners(): void {
|
||||
const calendarContainer = document.querySelector('swp-calendar-container');
|
||||
|
||||
if (calendarContainer) {
|
||||
calendarContainer.addEventListener('mouseover', this.onMouseOver, true);
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', this.onPointerDown, true);
|
||||
document.addEventListener('pointermove', this.onPointerMove, true);
|
||||
document.addEventListener('pointerup', this.onPointerUp, true);
|
||||
}
|
||||
|
||||
private onMouseOver = (e: Event): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
const eventElement = target.closest<SwpEventEl>('swp-event');
|
||||
|
||||
if (eventElement && !this.isResizing) {
|
||||
// Check if handle already exists
|
||||
if (!eventElement.querySelector(':scope > swp-resize-handle')) {
|
||||
const handle = this.createResizeHandle();
|
||||
eventElement.appendChild(handle);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onPointerDown = (e: PointerEvent): void => {
|
||||
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
|
||||
if (!handle) return;
|
||||
|
||||
const element = handle.parentElement as SwpEventEl;
|
||||
this.startResizing(element, e);
|
||||
};
|
||||
|
||||
private startResizing(element: SwpEventEl, event: PointerEvent): void {
|
||||
this.targetEl = element;
|
||||
this.isResizing = true;
|
||||
this.startY = event.clientY;
|
||||
|
||||
const startHeight = element.offsetHeight;
|
||||
this.startDurationMin = Math.max(
|
||||
this.minDurationMin,
|
||||
Math.round(this.positionUtils.pixelsToMinutes(startHeight))
|
||||
);
|
||||
|
||||
this.setZIndexForResizing(element);
|
||||
this.capturePointer(event);
|
||||
document.documentElement.classList.add('swp--resizing');
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private setZIndexForResizing(element: SwpEventEl): void {
|
||||
const container = element.closest<HTMLElement>('swp-event-group') ?? element;
|
||||
this.prevZ = container.style.zIndex;
|
||||
container.style.zIndex = this.Z_INDEX_RESIZING;
|
||||
}
|
||||
|
||||
private capturePointer(event: PointerEvent): void {
|
||||
try {
|
||||
(event.target as Element).setPointerCapture?.(event.pointerId);
|
||||
this.pointerCaptured = true;
|
||||
} catch (error) {
|
||||
console.warn('Pointer capture failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerMove = (e: PointerEvent): void => {
|
||||
if (!this.isResizing || !this.targetEl) return;
|
||||
|
||||
this.updateResizeHeight(e.clientY);
|
||||
};
|
||||
|
||||
private updateResizeHeight(currentY: number): void {
|
||||
const deltaY = currentY - this.startY;
|
||||
|
||||
const startHeight = this.positionUtils.minutesToPixels(this.startDurationMin);
|
||||
const rawHeight = startHeight + deltaY;
|
||||
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
|
||||
|
||||
this.targetHeight = Math.max(minHeight, rawHeight);
|
||||
|
||||
if (this.animationId == null) {
|
||||
this.currentHeight = this.targetEl?.offsetHeight!!;
|
||||
this.animate();
|
||||
}
|
||||
}
|
||||
|
||||
private animate = (): void => {
|
||||
if (!this.isResizing || !this.targetEl) {
|
||||
this.animationId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = this.targetHeight - this.currentHeight;
|
||||
|
||||
if (Math.abs(diff) > this.EVENT_REFRESH_THRESHOLD) {
|
||||
this.currentHeight += diff * this.ANIMATION_SPEED;
|
||||
this.targetEl.updateHeight?.(this.currentHeight);
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
} else {
|
||||
this.finalizeAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
private finalizeAnimation(): void {
|
||||
if (!this.targetEl) return;
|
||||
|
||||
this.currentHeight = this.targetHeight;
|
||||
this.targetEl.updateHeight?.(this.currentHeight);
|
||||
this.animationId = null;
|
||||
}
|
||||
|
||||
private onPointerUp = (e: PointerEvent): void => {
|
||||
if (!this.isResizing || !this.targetEl) return;
|
||||
|
||||
this.cleanupAnimation();
|
||||
this.snapToGrid();
|
||||
this.emitResizeEndEvent();
|
||||
this.cleanupResizing(e);
|
||||
};
|
||||
|
||||
private cleanupAnimation(): void {
|
||||
if (this.animationId != null) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private snapToGrid(): void {
|
||||
if (!this.targetEl) return;
|
||||
|
||||
const currentHeight = this.targetEl.offsetHeight;
|
||||
const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin);
|
||||
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
|
||||
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
|
||||
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines
|
||||
|
||||
this.targetEl.updateHeight?.(finalHeight);
|
||||
}
|
||||
|
||||
private emitResizeEndEvent(): void {
|
||||
if (!this.targetEl) return;
|
||||
|
||||
const eventId = this.targetEl.dataset.eventId || '';
|
||||
const resizeEndPayload: IResizeEndEventPayload = {
|
||||
eventId,
|
||||
element: this.targetEl,
|
||||
finalHeight: this.targetEl.offsetHeight
|
||||
};
|
||||
|
||||
eventBus.emit('resize:end', resizeEndPayload);
|
||||
}
|
||||
|
||||
private cleanupResizing(event: PointerEvent): void {
|
||||
this.restoreZIndex();
|
||||
this.releasePointer(event);
|
||||
|
||||
this.isResizing = false;
|
||||
this.targetEl = null;
|
||||
|
||||
document.documentElement.classList.remove('swp--resizing');
|
||||
}
|
||||
|
||||
private restoreZIndex(): void {
|
||||
if (!this.targetEl || this.prevZ === undefined) return;
|
||||
|
||||
const container = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
|
||||
container.style.zIndex = this.prevZ;
|
||||
this.prevZ = undefined;
|
||||
}
|
||||
|
||||
private releasePointer(event: PointerEvent): void {
|
||||
if (!this.pointerCaptured) return;
|
||||
|
||||
try {
|
||||
(event.target as Element).releasePointerCapture?.(event.pointerId);
|
||||
this.pointerCaptured = false;
|
||||
} catch (error) {
|
||||
console.warn('Pointer release failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
// Custom scroll management for calendar week container
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
|
||||
/**
|
||||
* Manages scrolling functionality for the calendar using native scrollbars
|
||||
*/
|
||||
export class ScrollManager {
|
||||
private scrollableContent: HTMLElement | null = null;
|
||||
private calendarContainer: HTMLElement | null = null;
|
||||
private timeAxis: HTMLElement | null = null;
|
||||
private calendarHeader: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private positionUtils: PositionUtils;
|
||||
|
||||
constructor(positionUtils: PositionUtils) {
|
||||
this.positionUtils = positionUtils;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to initialize scroll after grid is rendered
|
||||
*/
|
||||
public initialize(): void {
|
||||
this.setupScrolling();
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Handle navigation animation completion - sync time axis position
|
||||
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
|
||||
this.syncTimeAxisPosition();
|
||||
this.setupScrolling();
|
||||
});
|
||||
|
||||
// Handle all-day row height changes
|
||||
eventBus.on('header:height-changed', () => {
|
||||
this.updateScrollableHeight();
|
||||
});
|
||||
|
||||
// Handle header ready - refresh header reference and re-sync
|
||||
eventBus.on('header:ready', () => {
|
||||
this.calendarHeader = document.querySelector('swp-calendar-header');
|
||||
if (this.scrollableContent && this.calendarHeader) {
|
||||
this.setupHorizontalScrollSynchronization();
|
||||
this.syncCalendarHeaderPosition(); // Immediately sync position
|
||||
}
|
||||
this.updateScrollableHeight(); // Update height calculations
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
this.updateScrollableHeight();
|
||||
});
|
||||
|
||||
// Listen for scroll to event time requests
|
||||
eventBus.on('scroll:to-event-time', (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { eventStartTime } = customEvent.detail;
|
||||
|
||||
if (eventStartTime) {
|
||||
this.scrollToEventTime(eventStartTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scrolling functionality after grid is rendered
|
||||
*/
|
||||
private setupScrolling(): void {
|
||||
this.findElements();
|
||||
|
||||
if (this.scrollableContent && this.calendarContainer) {
|
||||
this.setupResizeObserver();
|
||||
this.updateScrollableHeight();
|
||||
this.setupScrollSynchronization();
|
||||
}
|
||||
|
||||
// Setup horizontal scrolling synchronization
|
||||
if (this.scrollableContent && this.calendarHeader) {
|
||||
this.setupHorizontalScrollSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find DOM elements needed for scrolling
|
||||
*/
|
||||
private findElements(): void {
|
||||
this.scrollableContent = document.querySelector('swp-scrollable-content');
|
||||
this.calendarContainer = document.querySelector('swp-calendar-container');
|
||||
this.timeAxis = document.querySelector('swp-time-axis');
|
||||
this.calendarHeader = document.querySelector('swp-calendar-header');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific position
|
||||
*/
|
||||
scrollTo(scrollTop: number): void {
|
||||
if (!this.scrollableContent) return;
|
||||
|
||||
this.scrollableContent.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific hour using PositionUtils
|
||||
*/
|
||||
scrollToHour(hour: number): void {
|
||||
// Create time string for the hour
|
||||
const timeString = `${hour.toString().padStart(2, '0')}:00`;
|
||||
const scrollTop = this.positionUtils.timeToPixels(timeString);
|
||||
|
||||
this.scrollTo(scrollTop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific event time
|
||||
* @param eventStartTime ISO string of event start time
|
||||
*/
|
||||
scrollToEventTime(eventStartTime: string): void {
|
||||
try {
|
||||
const eventDate = new Date(eventStartTime);
|
||||
const eventHour = eventDate.getHours();
|
||||
const eventMinutes = eventDate.getMinutes();
|
||||
|
||||
// Convert to decimal hour (e.g., 14:30 becomes 14.5)
|
||||
const decimalHour = eventHour + (eventMinutes / 60);
|
||||
|
||||
this.scrollToHour(decimalHour);
|
||||
} catch (error) {
|
||||
console.warn('ScrollManager: Failed to scroll to event time:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup ResizeObserver to monitor container size changes
|
||||
*/
|
||||
private setupResizeObserver(): void {
|
||||
if (!this.calendarContainer) return;
|
||||
|
||||
// Clean up existing observer
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
this.updateScrollableHeight();
|
||||
}
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(this.calendarContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and update scrollable content height dynamically
|
||||
*/
|
||||
private updateScrollableHeight(): void {
|
||||
if (!this.scrollableContent || !this.calendarContainer) return;
|
||||
|
||||
// Get calendar container height
|
||||
const containerRect = this.calendarContainer.getBoundingClientRect();
|
||||
|
||||
// Find navigation height
|
||||
const navigation = document.querySelector('swp-calendar-nav');
|
||||
const navHeight = navigation ? navigation.getBoundingClientRect().height : 0;
|
||||
|
||||
// Find calendar header height
|
||||
const calendarHeaderElement = document.querySelector('swp-calendar-header');
|
||||
const headerHeight = calendarHeaderElement ? calendarHeaderElement.getBoundingClientRect().height : 80;
|
||||
|
||||
// Calculate available height for scrollable content
|
||||
const availableHeight = containerRect.height - headerHeight;
|
||||
|
||||
// Calculate available width (container width minus time-axis)
|
||||
const availableWidth = containerRect.width - 60; // 60px time-axis
|
||||
|
||||
// Set the height and width on scrollable content
|
||||
if (availableHeight > 0) {
|
||||
this.scrollableContent.style.height = `${availableHeight}px`;
|
||||
}
|
||||
if (availableWidth > 0) {
|
||||
this.scrollableContent.style.width = `${availableWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scroll synchronization between scrollable content and time axis
|
||||
*/
|
||||
private setupScrollSynchronization(): void {
|
||||
if (!this.scrollableContent || !this.timeAxis) return;
|
||||
|
||||
// Throttle scroll events for better performance
|
||||
let scrollTimeout: number | null = null;
|
||||
|
||||
this.scrollableContent.addEventListener('scroll', () => {
|
||||
if (scrollTimeout) {
|
||||
cancelAnimationFrame(scrollTimeout);
|
||||
}
|
||||
|
||||
scrollTimeout = requestAnimationFrame(() => {
|
||||
this.syncTimeAxisPosition();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize time axis position with scrollable content
|
||||
*/
|
||||
private syncTimeAxisPosition(): void {
|
||||
if (!this.scrollableContent || !this.timeAxis) return;
|
||||
|
||||
const scrollTop = this.scrollableContent.scrollTop;
|
||||
const timeAxisContent = this.timeAxis.querySelector('swp-time-axis-content');
|
||||
|
||||
if (timeAxisContent) {
|
||||
// Use transform for smooth performance
|
||||
(timeAxisContent as HTMLElement).style.transform = `translateY(-${scrollTop}px)`;
|
||||
|
||||
// Debug logging (can be removed later)
|
||||
if (scrollTop % 100 === 0) { // Only log every 100px to avoid spam
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup horizontal scroll synchronization between scrollable content and calendar header
|
||||
*/
|
||||
private setupHorizontalScrollSynchronization(): void {
|
||||
if (!this.scrollableContent || !this.calendarHeader) return;
|
||||
|
||||
|
||||
// Listen to horizontal scroll events
|
||||
this.scrollableContent.addEventListener('scroll', () => {
|
||||
this.syncCalendarHeaderPosition();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize calendar header position with scrollable content horizontal scroll
|
||||
*/
|
||||
private syncCalendarHeaderPosition(): void {
|
||||
if (!this.scrollableContent || !this.calendarHeader) return;
|
||||
|
||||
const scrollLeft = this.scrollableContent.scrollLeft;
|
||||
|
||||
// Use transform for smooth performance
|
||||
this.calendarHeader.style.transform = `translateX(-${scrollLeft}px)`;
|
||||
|
||||
// Debug logging (can be removed later)
|
||||
if (scrollLeft % 100 === 0) { // Only log every 100px to avoid spam
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
// Work hours management for per-column scheduling
|
||||
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
|
||||
/**
|
||||
* Work hours for a specific day
|
||||
*/
|
||||
export interface IDayWorkHours {
|
||||
start: number; // Hour (0-23)
|
||||
end: number; // Hour (0-23)
|
||||
}
|
||||
|
||||
/**
|
||||
* Work schedule configuration
|
||||
*/
|
||||
export interface IWorkScheduleConfig {
|
||||
weeklyDefault: {
|
||||
monday: IDayWorkHours | 'off';
|
||||
tuesday: IDayWorkHours | 'off';
|
||||
wednesday: IDayWorkHours | 'off';
|
||||
thursday: IDayWorkHours | 'off';
|
||||
friday: IDayWorkHours | 'off';
|
||||
saturday: IDayWorkHours | 'off';
|
||||
sunday: IDayWorkHours | 'off';
|
||||
};
|
||||
dateOverrides: {
|
||||
[dateString: string]: IDayWorkHours | 'off'; // YYYY-MM-DD format
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages work hours scheduling with weekly defaults and date-specific overrides
|
||||
*/
|
||||
export class WorkHoursManager {
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private positionUtils: PositionUtils;
|
||||
private workSchedule: IWorkScheduleConfig;
|
||||
|
||||
constructor(dateService: DateService, config: Configuration, positionUtils: PositionUtils) {
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.positionUtils = positionUtils;
|
||||
|
||||
// Default work schedule - will be loaded from JSON later
|
||||
this.workSchedule = {
|
||||
weeklyDefault: {
|
||||
monday: { start: 9, end: 17 },
|
||||
tuesday: { start: 9, end: 17 },
|
||||
wednesday: { start: 9, end: 17 },
|
||||
thursday: { start: 9, end: 17 },
|
||||
friday: { start: 9, end: 15 },
|
||||
saturday: 'off',
|
||||
sunday: 'off'
|
||||
},
|
||||
dateOverrides: {
|
||||
'2025-01-20': { start: 10, end: 16 },
|
||||
'2025-01-21': { start: 8, end: 14 },
|
||||
'2025-01-22': 'off'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get work hours for a specific date
|
||||
*/
|
||||
getWorkHoursForDate(date: Date): IDayWorkHours | 'off' {
|
||||
const dateString = this.dateService.formatISODate(date);
|
||||
|
||||
// Check for date-specific override first
|
||||
if (this.workSchedule.dateOverrides[dateString]) {
|
||||
return this.workSchedule.dateOverrides[dateString];
|
||||
}
|
||||
|
||||
// Fall back to weekly default
|
||||
const dayName = this.getDayName(date);
|
||||
return this.workSchedule.weeklyDefault[dayName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get work hours for multiple dates (used by GridManager)
|
||||
*/
|
||||
getWorkHoursForDateRange(dates: Date[]): Map<string, IDayWorkHours | 'off'> {
|
||||
const workHoursMap = new Map<string, IDayWorkHours | 'off'>();
|
||||
|
||||
dates.forEach(date => {
|
||||
const dateString = this.dateService.formatISODate(date);
|
||||
const workHours = this.getWorkHoursForDate(date);
|
||||
workHoursMap.set(dateString, workHours);
|
||||
});
|
||||
|
||||
return workHoursMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate CSS custom properties for non-work hour overlays using PositionUtils
|
||||
*/
|
||||
calculateNonWorkHoursStyle(workHours: IDayWorkHours | 'off'): { beforeWorkHeight: number; afterWorkTop: number } | null {
|
||||
if (workHours === 'off') {
|
||||
return null; // Full day will be colored via CSS background
|
||||
}
|
||||
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const dayStartHour = gridSettings.dayStartHour;
|
||||
const hourHeight = gridSettings.hourHeight;
|
||||
|
||||
// Before work: from day start to work start
|
||||
const beforeWorkHeight = (workHours.start - dayStartHour) * hourHeight;
|
||||
|
||||
// After work: from work end to day end
|
||||
const afterWorkTop = (workHours.end - dayStartHour) * hourHeight;
|
||||
|
||||
return {
|
||||
beforeWorkHeight: Math.max(0, beforeWorkHeight),
|
||||
afterWorkTop: Math.max(0, afterWorkTop)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate CSS custom properties for work hours overlay using PositionUtils
|
||||
*/
|
||||
calculateWorkHoursStyle(workHours: IDayWorkHours | 'off'): { top: number; height: number } | null {
|
||||
if (workHours === 'off') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create dummy time strings for start and end of work hours
|
||||
const startTime = `${workHours.start.toString().padStart(2, '0')}:00`;
|
||||
const endTime = `${workHours.end.toString().padStart(2, '0')}:00`;
|
||||
|
||||
// Use PositionUtils for consistent position calculation
|
||||
const position = this.positionUtils.calculateEventPosition(startTime, endTime);
|
||||
|
||||
return { top: position.top, height: position.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load work schedule from JSON (future implementation)
|
||||
*/
|
||||
async loadWorkSchedule(jsonData: IWorkScheduleConfig): Promise<void> {
|
||||
this.workSchedule = jsonData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current work schedule configuration
|
||||
*/
|
||||
getWorkSchedule(): IWorkScheduleConfig {
|
||||
return this.workSchedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Date to day name key
|
||||
*/
|
||||
private getDayName(date: Date): keyof IWorkScheduleConfig['weeklyDefault'] {
|
||||
const dayNames: (keyof IWorkScheduleConfig['weeklyDefault'])[] = [
|
||||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'
|
||||
];
|
||||
return dayNames[date.getDay()];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
|
||||
import { IEventLayout } from '../utils/AllDayLayoutEngine';
|
||||
import { IColumnBounds } from '../utils/ColumnDetectionUtils';
|
||||
import { EventManager } from '../managers/EventManager';
|
||||
import { IDragStartEventPayload } from '../types/EventTypes';
|
||||
import { IEventRenderer } from './EventRenderer';
|
||||
|
||||
export class AllDayEventRenderer {
|
||||
|
||||
private container: HTMLElement | null = null;
|
||||
private originalEvent: HTMLElement | null = null;
|
||||
private draggedClone: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
this.getContainer();
|
||||
}
|
||||
|
||||
|
||||
private getContainer(): HTMLElement | null {
|
||||
|
||||
const header = document.querySelector('swp-calendar-header');
|
||||
if (header) {
|
||||
this.container = header.querySelector('swp-allday-container');
|
||||
|
||||
if (!this.container) {
|
||||
this.container = document.createElement('swp-allday-container');
|
||||
header.appendChild(this.container);
|
||||
}
|
||||
}
|
||||
return this.container;
|
||||
}
|
||||
|
||||
|
||||
private getAllDayContainer(): HTMLElement | null {
|
||||
return document.querySelector('swp-calendar-header swp-allday-container');
|
||||
}
|
||||
/**
|
||||
* Handle drag start for all-day events
|
||||
*/
|
||||
public handleDragStart(payload: IDragStartEventPayload): void {
|
||||
|
||||
this.originalEvent = payload.originalElement;;
|
||||
this.draggedClone = payload.draggedClone;
|
||||
|
||||
if (this.draggedClone) {
|
||||
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return;
|
||||
|
||||
this.draggedClone.style.gridColumn = this.originalEvent.style.gridColumn;
|
||||
this.draggedClone.style.gridRow = this.originalEvent.style.gridRow;
|
||||
console.log('handleDragStart:this.draggedClone', this.draggedClone);
|
||||
container.appendChild(this.draggedClone);
|
||||
|
||||
// Add dragging style
|
||||
this.draggedClone.classList.add('dragging');
|
||||
this.draggedClone.style.zIndex = '1000';
|
||||
this.draggedClone.style.cursor = 'grabbing';
|
||||
|
||||
// Make original semi-transparent
|
||||
this.originalEvent.style.opacity = '0.3';
|
||||
this.originalEvent.style.userSelect = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Render an all-day event with pre-calculated layout
|
||||
*/
|
||||
private renderAllDayEventWithLayout(
|
||||
event: ICalendarEvent,
|
||||
layout: IEventLayout
|
||||
) {
|
||||
const container = this.getContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const dayEvent = SwpAllDayEventElement.fromCalendarEvent(event);
|
||||
dayEvent.applyGridPositioning(layout.row, layout.startColumn, layout.endColumn);
|
||||
|
||||
// Apply highlight class to show events with highlight color
|
||||
dayEvent.classList.add('highlight');
|
||||
|
||||
container.appendChild(dayEvent);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove an all-day event by ID
|
||||
*/
|
||||
public removeAllDayEvent(eventId: string): void {
|
||||
const container = this.getContainer();
|
||||
if (!container) return;
|
||||
|
||||
const eventElement = container.querySelector(`swp-allday-event[data-event-id="${eventId}"]`);
|
||||
if (eventElement) {
|
||||
eventElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache when DOM changes
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all-day events for specific period using AllDayEventRenderer
|
||||
*/
|
||||
public renderAllDayEventsForPeriod(eventLayouts: IEventLayout[]): void {
|
||||
this.clearAllDayEvents();
|
||||
|
||||
eventLayouts.forEach(layout => {
|
||||
this.renderAllDayEventWithLayout(layout.calenderEvent, layout);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private clearAllDayEvents(): void {
|
||||
const allDayContainer = document.querySelector('swp-allday-container');
|
||||
if (allDayContainer) {
|
||||
allDayContainer.querySelectorAll('swp-allday-event:not(.max-event-indicator)').forEach(event => event.remove());
|
||||
}
|
||||
}
|
||||
|
||||
public handleViewChanged(event: CustomEvent): void {
|
||||
this.clearAllDayEvents();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
// Column rendering strategy interface and implementations
|
||||
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { WorkHoursManager } from '../managers/WorkHoursManager';
|
||||
import { IColumnInfo } from '../types/ColumnDataSource';
|
||||
|
||||
/**
|
||||
* Interface for column rendering strategies
|
||||
*/
|
||||
export interface IColumnRenderer {
|
||||
render(columnContainer: HTMLElement, context: IColumnRenderContext): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for column rendering
|
||||
*/
|
||||
export interface IColumnRenderContext {
|
||||
columns: IColumnInfo[];
|
||||
config: Configuration;
|
||||
currentDate?: Date; // Optional: Only used by ResourceColumnRenderer in resource mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-based column renderer (original functionality)
|
||||
*/
|
||||
export class DateColumnRenderer implements IColumnRenderer {
|
||||
private dateService: DateService;
|
||||
private workHoursManager: WorkHoursManager;
|
||||
|
||||
constructor(
|
||||
dateService: DateService,
|
||||
workHoursManager: WorkHoursManager
|
||||
) {
|
||||
this.dateService = dateService;
|
||||
this.workHoursManager = workHoursManager;
|
||||
}
|
||||
|
||||
render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
|
||||
const { columns } = context;
|
||||
|
||||
columns.forEach((columnInfo) => {
|
||||
const date = columnInfo.data as Date;
|
||||
const column = document.createElement('swp-day-column');
|
||||
|
||||
column.dataset.columnId = columnInfo.identifier;
|
||||
column.dataset.date = this.dateService.formatISODate(date);
|
||||
|
||||
// Apply work hours styling
|
||||
this.applyWorkHoursToColumn(column, date);
|
||||
|
||||
const eventsLayer = document.createElement('swp-events-layer');
|
||||
column.appendChild(eventsLayer);
|
||||
|
||||
columnContainer.appendChild(column);
|
||||
});
|
||||
}
|
||||
|
||||
private applyWorkHoursToColumn(column: HTMLElement, date: Date): void {
|
||||
const workHours = this.workHoursManager.getWorkHoursForDate(date);
|
||||
|
||||
if (workHours === 'off') {
|
||||
// No work hours - mark as off day (full day will be colored)
|
||||
(column as any).dataset.workHours = 'off';
|
||||
} else {
|
||||
// Calculate and apply non-work hours overlays (before and after work)
|
||||
const nonWorkStyle = this.workHoursManager.calculateNonWorkHoursStyle(workHours);
|
||||
if (nonWorkStyle) {
|
||||
// Before work overlay (::before pseudo-element)
|
||||
column.style.setProperty('--before-work-height', `${nonWorkStyle.beforeWorkHeight}px`);
|
||||
|
||||
// After work overlay (::after pseudo-element)
|
||||
column.style.setProperty('--after-work-top', `${nonWorkStyle.afterWorkTop}px`);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// Header rendering strategy interface and implementations
|
||||
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { IColumnInfo } from '../types/ColumnDataSource';
|
||||
|
||||
/**
|
||||
* Interface for header rendering strategies
|
||||
*/
|
||||
export interface IHeaderRenderer {
|
||||
render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Context for header rendering
|
||||
*/
|
||||
export interface IHeaderRenderContext {
|
||||
columns: IColumnInfo[];
|
||||
config: Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-based header renderer (original functionality)
|
||||
*/
|
||||
export class DateHeaderRenderer implements IHeaderRenderer {
|
||||
private dateService!: DateService;
|
||||
|
||||
render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void {
|
||||
const { columns, config } = context;
|
||||
|
||||
// FIRST: Always create all-day container as part of standard header structure
|
||||
const allDayContainer = document.createElement('swp-allday-container');
|
||||
calendarHeader.appendChild(allDayContainer);
|
||||
|
||||
// Initialize date service with timezone and locale from config
|
||||
const locale = config.timeFormatConfig.locale;
|
||||
this.dateService = new DateService(config);
|
||||
|
||||
columns.forEach((columnInfo) => {
|
||||
const date = columnInfo.data as Date;
|
||||
const header = document.createElement('swp-day-header');
|
||||
|
||||
if (this.dateService.isSameDay(date, new Date())) {
|
||||
header.dataset.today = 'true';
|
||||
}
|
||||
|
||||
const dayName = this.dateService.getDayName(date, 'long', locale).toUpperCase();
|
||||
|
||||
header.innerHTML = `
|
||||
<swp-day-name>${dayName}</swp-day-name>
|
||||
<swp-day-date>${date.getDate()}</swp-day-date>
|
||||
`;
|
||||
|
||||
header.dataset.columnId = columnInfo.identifier;
|
||||
header.dataset.groupId = columnInfo.groupId;
|
||||
|
||||
calendarHeader.appendChild(header);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
// Event rendering strategy interface and implementations
|
||||
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { IColumnInfo } from '../types/ColumnDataSource';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { SwpEventElement } from '../elements/SwpEventElement';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
import { IColumnBounds } from '../utils/ColumnDetectionUtils';
|
||||
import { IDragColumnChangeEventPayload, IDragMoveEventPayload, IDragStartEventPayload, IDragMouseEnterColumnEventPayload } from '../types/EventTypes';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { EventStackManager } from '../managers/EventStackManager';
|
||||
import { EventLayoutCoordinator, IGridGroupLayout, IStackedEventLayout } from '../managers/EventLayoutCoordinator';
|
||||
import { EventId } from '../types/EventId';
|
||||
|
||||
/**
|
||||
* Interface for event rendering strategies
|
||||
*
|
||||
* Note: renderEvents now receives columns with pre-filtered events,
|
||||
* not a flat array of events. Each column contains its own events.
|
||||
*/
|
||||
export interface IEventRenderer {
|
||||
renderEvents(columns: IColumnInfo[], container: HTMLElement): void;
|
||||
clearEvents(container?: HTMLElement): void;
|
||||
renderSingleColumnEvents?(column: IColumnBounds, events: ICalendarEvent[]): void;
|
||||
handleDragStart?(payload: IDragStartEventPayload): void;
|
||||
handleDragMove?(payload: IDragMoveEventPayload): void;
|
||||
handleDragAutoScroll?(eventId: string, snappedY: number): void;
|
||||
handleDragEnd?(originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: IColumnBounds, finalY: number): void;
|
||||
handleEventClick?(eventId: string, originalElement: HTMLElement): void;
|
||||
handleColumnChange?(payload: IDragColumnChangeEventPayload): void;
|
||||
handleNavigationCompleted?(): void;
|
||||
handleConvertAllDayToTimed?(payload: IDragMouseEnterColumnEventPayload): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-based event renderer
|
||||
*/
|
||||
export class DateEventRenderer implements IEventRenderer {
|
||||
|
||||
private dateService: DateService;
|
||||
private stackManager: EventStackManager;
|
||||
private layoutCoordinator: EventLayoutCoordinator;
|
||||
private config: Configuration;
|
||||
private positionUtils: PositionUtils;
|
||||
private draggedClone: HTMLElement | null = null;
|
||||
private originalEvent: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
dateService: DateService,
|
||||
stackManager: EventStackManager,
|
||||
layoutCoordinator: EventLayoutCoordinator,
|
||||
config: Configuration,
|
||||
positionUtils: PositionUtils
|
||||
) {
|
||||
this.dateService = dateService;
|
||||
this.stackManager = stackManager;
|
||||
this.layoutCoordinator = layoutCoordinator;
|
||||
this.config = config;
|
||||
this.positionUtils = positionUtils;
|
||||
}
|
||||
|
||||
private applyDragStyling(element: HTMLElement): void {
|
||||
element.classList.add('dragging');
|
||||
element.style.removeProperty("margin-left");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handle drag start event
|
||||
*/
|
||||
public handleDragStart(payload: IDragStartEventPayload): void {
|
||||
|
||||
this.originalEvent = payload.originalElement;;
|
||||
|
||||
// Use the clone from the payload instead of creating a new one
|
||||
this.draggedClone = payload.draggedClone;
|
||||
|
||||
if (this.draggedClone && payload.columnBounds) {
|
||||
// Apply drag styling
|
||||
this.applyDragStyling(this.draggedClone);
|
||||
|
||||
// Add to current column's events layer (not directly to column)
|
||||
const eventsLayer = payload.columnBounds.element.querySelector('swp-events-layer');
|
||||
if (eventsLayer) {
|
||||
eventsLayer.appendChild(this.draggedClone);
|
||||
|
||||
// Set initial position to prevent "jump to top" effect
|
||||
// Calculate absolute Y position from original element
|
||||
const originalRect = this.originalEvent.getBoundingClientRect();
|
||||
const columnRect = payload.columnBounds.boundingClientRect;
|
||||
const initialTop = originalRect.top - columnRect.top;
|
||||
|
||||
this.draggedClone.style.top = `${initialTop}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// Make original semi-transparent
|
||||
this.originalEvent.style.opacity = '0.3';
|
||||
this.originalEvent.style.userSelect = 'none';
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag move event
|
||||
* Only updates visual position and time - date stays the same
|
||||
*/
|
||||
public handleDragMove(payload: IDragMoveEventPayload): void {
|
||||
const swpEvent = payload.draggedClone as SwpEventElement;
|
||||
swpEvent.updatePosition(payload.snappedY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle column change during drag
|
||||
* Only moves the element visually - no data updates here
|
||||
* Data updates happen on drag:end in EventRenderingService
|
||||
*/
|
||||
public handleColumnChange(payload: IDragColumnChangeEventPayload): void {
|
||||
const eventsLayer = payload.newColumn.element.querySelector('swp-events-layer');
|
||||
if (eventsLayer && payload.draggedClone.parentElement !== eventsLayer) {
|
||||
eventsLayer.appendChild(payload.draggedClone);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle conversion of all-day event to timed event
|
||||
*/
|
||||
public handleConvertAllDayToTimed(payload: IDragMouseEnterColumnEventPayload): void {
|
||||
|
||||
console.log('🎯 DateEventRenderer: Converting all-day to timed event', {
|
||||
eventId: payload.calendarEvent.id,
|
||||
targetColumn: payload.targetColumn.identifier,
|
||||
snappedY: payload.snappedY
|
||||
});
|
||||
|
||||
let timedClone = SwpEventElement.fromCalendarEvent(payload.calendarEvent);
|
||||
let position = this.calculateEventPosition(payload.calendarEvent);
|
||||
|
||||
// Set position at snapped Y
|
||||
//timedClone.style.top = `${snappedY}px`;
|
||||
|
||||
// Set complete styling for dragged clone (matching normal event rendering)
|
||||
timedClone.style.height = `${position.height - 3}px`;
|
||||
timedClone.style.left = '2px';
|
||||
timedClone.style.right = '2px';
|
||||
timedClone.style.width = 'auto';
|
||||
timedClone.style.pointerEvents = 'none';
|
||||
|
||||
// Apply drag styling
|
||||
this.applyDragStyling(timedClone);
|
||||
|
||||
// Find the events layer in the target column
|
||||
let eventsLayer = payload.targetColumn.element.querySelector('swp-events-layer');
|
||||
|
||||
// Add "clone-" prefix to match clone ID pattern
|
||||
//timedClone.dataset.eventId = `clone-${payload.calendarEvent.id}`;
|
||||
|
||||
// Remove old all-day clone and replace with new timed clone
|
||||
payload.draggedClone.remove();
|
||||
payload.replaceClone(timedClone);
|
||||
eventsLayer!!.appendChild(timedClone);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag end event
|
||||
*/
|
||||
public handleDragEnd(originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: IColumnBounds, finalY: number): void {
|
||||
|
||||
// Only fade out and remove if it's a swp-event (not swp-allday-event)
|
||||
// AllDayManager handles removal of swp-allday-event elements
|
||||
if (originalElement.tagName === 'SWP-EVENT') {
|
||||
this.fadeOutAndRemove(originalElement);
|
||||
}
|
||||
|
||||
draggedClone.dataset.eventId = EventId.from(draggedClone.dataset.eventId!);
|
||||
|
||||
// Fully normalize the clone to be a regular event
|
||||
draggedClone.classList.remove('dragging');
|
||||
draggedClone.style.pointerEvents = ''; // Re-enable pointer events
|
||||
|
||||
// Clean up instance state
|
||||
this.draggedClone = null;
|
||||
this.originalEvent = null;
|
||||
|
||||
|
||||
// Clean up any remaining day event clones
|
||||
const dayEventClone = document.querySelector(`swp-event[data-event-id="${draggedClone.dataset.eventId}"]`);
|
||||
if (dayEventClone) {
|
||||
dayEventClone.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation completed event
|
||||
*/
|
||||
public handleNavigationCompleted(): void {
|
||||
// Default implementation - can be overridden by subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade out and remove element
|
||||
*/
|
||||
private fadeOutAndRemove(element: HTMLElement): void {
|
||||
element.style.transition = 'opacity 0.3s ease-out';
|
||||
element.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
element.remove();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
renderEvents(columns: IColumnInfo[], container: HTMLElement): void {
|
||||
// Find column DOM elements in the container
|
||||
const columnElements = this.getColumns(container);
|
||||
|
||||
// Render events for each column using pre-filtered events from IColumnInfo
|
||||
columns.forEach((columnInfo, index) => {
|
||||
const columnElement = columnElements[index];
|
||||
if (!columnElement) return;
|
||||
|
||||
// Filter out all-day events - they should be handled by AllDayEventRenderer
|
||||
const timedEvents = columnInfo.events.filter(event => !event.allDay);
|
||||
|
||||
const eventsLayer = columnElement.querySelector('swp-events-layer') as HTMLElement;
|
||||
if (eventsLayer && timedEvents.length > 0) {
|
||||
this.renderColumnEvents(timedEvents, eventsLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render events for a single column
|
||||
* Note: events are already filtered for this column
|
||||
*/
|
||||
public renderSingleColumnEvents(column: IColumnBounds, events: ICalendarEvent[]): void {
|
||||
// Filter out all-day events
|
||||
const timedEvents = events.filter(event => !event.allDay);
|
||||
const eventsLayer = column.element.querySelector('swp-events-layer') as HTMLElement;
|
||||
|
||||
if (eventsLayer && timedEvents.length > 0) {
|
||||
this.renderColumnEvents(timedEvents, eventsLayer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render events in a column using combined stacking + grid algorithm
|
||||
*/
|
||||
private renderColumnEvents(columnEvents: ICalendarEvent[], eventsLayer: HTMLElement): void {
|
||||
if (columnEvents.length === 0) return;
|
||||
|
||||
// Get layout from coordinator
|
||||
const layout = this.layoutCoordinator.calculateColumnLayout(columnEvents);
|
||||
|
||||
// Render grid groups
|
||||
layout.gridGroups.forEach(gridGroup => {
|
||||
this.renderGridGroup(gridGroup, eventsLayer);
|
||||
});
|
||||
|
||||
// Render stacked events
|
||||
layout.stackedEvents.forEach(stackedEvent => {
|
||||
const element = this.renderEvent(stackedEvent.event);
|
||||
this.stackManager.applyStackLinkToElement(element, stackedEvent.stackLink);
|
||||
this.stackManager.applyVisualStyling(element, stackedEvent.stackLink.stackLevel);
|
||||
eventsLayer.appendChild(element);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Render events in a grid container (side-by-side with column sharing)
|
||||
*/
|
||||
private renderGridGroup(gridGroup: IGridGroupLayout, eventsLayer: HTMLElement): void {
|
||||
const groupElement = document.createElement('swp-event-group');
|
||||
|
||||
// Add grid column class based on number of columns (not events)
|
||||
const colCount = gridGroup.columns.length;
|
||||
groupElement.classList.add(`cols-${colCount}`);
|
||||
|
||||
// Add stack level class for margin-left offset
|
||||
groupElement.classList.add(`stack-level-${gridGroup.stackLevel}`);
|
||||
|
||||
// Position from layout
|
||||
groupElement.style.top = `${gridGroup.position.top}px`;
|
||||
|
||||
// Add stack-link attribute for drag-drop (group acts as a stacked item)
|
||||
const stackLink = {
|
||||
stackLevel: gridGroup.stackLevel
|
||||
};
|
||||
this.stackManager.applyStackLinkToElement(groupElement, stackLink);
|
||||
|
||||
// Apply visual styling (margin-left and z-index) using StackManager
|
||||
this.stackManager.applyVisualStyling(groupElement, gridGroup.stackLevel);
|
||||
|
||||
// Render each column
|
||||
const earliestEvent = gridGroup.events[0];
|
||||
gridGroup.columns.forEach((columnEvents: ICalendarEvent[]) => {
|
||||
const columnContainer = this.renderGridColumn(columnEvents, earliestEvent.start);
|
||||
groupElement.appendChild(columnContainer);
|
||||
});
|
||||
|
||||
eventsLayer.appendChild(groupElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single column within a grid group
|
||||
* Column may contain multiple events that don't overlap
|
||||
*/
|
||||
private renderGridColumn(columnEvents: ICalendarEvent[], containerStart: Date): HTMLElement {
|
||||
const columnContainer = document.createElement('div');
|
||||
columnContainer.style.position = 'relative';
|
||||
|
||||
columnEvents.forEach(event => {
|
||||
const element = this.renderEventInGrid(event, containerStart);
|
||||
columnContainer.appendChild(element);
|
||||
});
|
||||
|
||||
return columnContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render event within a grid container (absolute positioning within column)
|
||||
*/
|
||||
private renderEventInGrid(event: ICalendarEvent, containerStart: Date): HTMLElement {
|
||||
const element = SwpEventElement.fromCalendarEvent(event);
|
||||
|
||||
// Calculate event height
|
||||
const position = this.calculateEventPosition(event);
|
||||
|
||||
// Calculate relative top offset if event starts after container start
|
||||
// (e.g., if container starts at 07:00 and event starts at 08:15, offset = 75 min)
|
||||
const timeDiffMs = event.start.getTime() - containerStart.getTime();
|
||||
const timeDiffMinutes = timeDiffMs / (1000 * 60);
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const relativeTop = timeDiffMinutes > 0 ? (timeDiffMinutes / 60) * gridSettings.hourHeight : 0;
|
||||
|
||||
// Events in grid columns are positioned absolutely within their column container
|
||||
element.style.position = 'absolute';
|
||||
element.style.top = `${relativeTop}px`;
|
||||
element.style.height = `${position.height - 3}px`;
|
||||
element.style.left = '0';
|
||||
element.style.right = '0';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
private renderEvent(event: ICalendarEvent): HTMLElement {
|
||||
const element = SwpEventElement.fromCalendarEvent(event);
|
||||
|
||||
// Apply positioning (moved from SwpEventElement.applyPositioning)
|
||||
const position = this.calculateEventPosition(event);
|
||||
element.style.position = 'absolute';
|
||||
element.style.top = `${position.top + 1}px`;
|
||||
element.style.height = `${position.height - 3}px`;
|
||||
element.style.left = '2px';
|
||||
element.style.right = '2px';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
protected calculateEventPosition(event: ICalendarEvent): { top: number; height: number } {
|
||||
// Delegate to PositionUtils for centralized position calculation
|
||||
return this.positionUtils.calculateEventPosition(event.start, event.end);
|
||||
}
|
||||
|
||||
clearEvents(container?: HTMLElement): void {
|
||||
const eventSelector = 'swp-event';
|
||||
const groupSelector = 'swp-event-group';
|
||||
|
||||
const existingEvents = container
|
||||
? container.querySelectorAll(eventSelector)
|
||||
: document.querySelectorAll(eventSelector);
|
||||
|
||||
const existingGroups = container
|
||||
? container.querySelectorAll(groupSelector)
|
||||
: document.querySelectorAll(groupSelector);
|
||||
|
||||
existingEvents.forEach(event => event.remove());
|
||||
existingGroups.forEach(group => group.remove());
|
||||
}
|
||||
|
||||
protected getColumns(container: HTMLElement): HTMLElement[] {
|
||||
const columns = container.querySelectorAll('swp-day-column');
|
||||
return Array.from(columns) as HTMLElement[];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { IColumnInfo, IColumnDataSource } from '../types/ColumnDataSource';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { EventManager } from '../managers/EventManager';
|
||||
import { IEventRenderer } from './EventRenderer';
|
||||
import { SwpEventElement } from '../elements/SwpEventElement';
|
||||
import { IDragStartEventPayload, IDragMoveEventPayload, IDragEndEventPayload, IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IDragMouseEnterColumnEventPayload, IDragColumnChangeEventPayload, IResizeEndEventPayload } from '../types/EventTypes';
|
||||
import { DateService } from '../utils/DateService';
|
||||
|
||||
/**
|
||||
* EventRenderingService - Render events i DOM med positionering using Strategy Pattern
|
||||
* Håndterer event positioning og overlap detection
|
||||
*/
|
||||
export class EventRenderingService {
|
||||
private eventBus: IEventBus;
|
||||
private eventManager: EventManager;
|
||||
private strategy: IEventRenderer;
|
||||
private dataSource: IColumnDataSource;
|
||||
private dateService: DateService;
|
||||
|
||||
private dragMouseLeaveHeaderListener: ((event: Event) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
eventBus: IEventBus,
|
||||
eventManager: EventManager,
|
||||
strategy: IEventRenderer,
|
||||
dataSource: IColumnDataSource,
|
||||
dateService: DateService
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.eventManager = eventManager;
|
||||
this.strategy = strategy;
|
||||
this.dataSource = dataSource;
|
||||
this.dateService = dateService;
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
|
||||
this.eventBus.on(CoreEvents.GRID_RENDERED, (event: Event) => {
|
||||
this.handleGridRendered(event as CustomEvent);
|
||||
});
|
||||
|
||||
this.eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => {
|
||||
this.handleViewChanged(event as CustomEvent);
|
||||
});
|
||||
|
||||
|
||||
// Handle all drag events and delegate to appropriate renderer
|
||||
this.setupDragEventListeners();
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle GRID_RENDERED event - render events in the current grid
|
||||
* Events are now pre-filtered per column by IColumnDataSource
|
||||
*/
|
||||
private handleGridRendered(event: CustomEvent): void {
|
||||
const { container, columns } = event.detail;
|
||||
|
||||
if (!container || !columns || columns.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Render events directly from columns (pre-filtered by IColumnDataSource)
|
||||
this.renderEventsFromColumns(container, columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render events from pre-filtered columns
|
||||
* Each column already contains its events (filtered by IColumnDataSource)
|
||||
*/
|
||||
private renderEventsFromColumns(container: HTMLElement, columns: IColumnInfo[]): void {
|
||||
this.strategy.clearEvents(container);
|
||||
this.strategy.renderEvents(columns, container);
|
||||
|
||||
// Emit EVENTS_RENDERED for filtering system
|
||||
const allEvents = columns.flatMap(col => col.events);
|
||||
this.eventBus.emit(CoreEvents.EVENTS_RENDERED, {
|
||||
events: allEvents,
|
||||
container: container
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle VIEW_CHANGED event - clear and re-render for new view
|
||||
*/
|
||||
private handleViewChanged(event: CustomEvent): void {
|
||||
// Clear all existing events since view structure may have changed
|
||||
this.clearEvents();
|
||||
|
||||
// New rendering will be triggered by subsequent GRID_RENDERED event
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Setup all drag event listeners - moved from EventRenderer for better separation of concerns
|
||||
*/
|
||||
private setupDragEventListeners(): void {
|
||||
this.setupDragStartListener();
|
||||
this.setupDragMoveListener();
|
||||
this.setupDragEndListener();
|
||||
this.setupDragColumnChangeListener();
|
||||
this.setupDragMouseLeaveHeaderListener();
|
||||
this.setupDragMouseEnterColumnListener();
|
||||
this.setupResizeEndListener();
|
||||
this.setupNavigationCompletedListener();
|
||||
}
|
||||
|
||||
private setupDragStartListener(): void {
|
||||
this.eventBus.on('drag:start', (event: Event) => {
|
||||
const dragStartPayload = (event as CustomEvent<IDragStartEventPayload>).detail;
|
||||
|
||||
if (dragStartPayload.originalElement.hasAttribute('data-allday')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragStartPayload.originalElement && this.strategy.handleDragStart && dragStartPayload.columnBounds) {
|
||||
this.strategy.handleDragStart(dragStartPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupDragMoveListener(): void {
|
||||
this.eventBus.on('drag:move', (event: Event) => {
|
||||
let dragEvent = (event as CustomEvent<IDragMoveEventPayload>).detail;
|
||||
|
||||
if (dragEvent.draggedClone.hasAttribute('data-allday')) {
|
||||
return;
|
||||
}
|
||||
if (this.strategy.handleDragMove) {
|
||||
this.strategy.handleDragMove(dragEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupDragEndListener(): void {
|
||||
this.eventBus.on('drag:end', async (event: Event) => {
|
||||
const { originalElement, draggedClone, finalPosition, target } = (event as CustomEvent<IDragEndEventPayload>).detail;
|
||||
const finalColumn = finalPosition.column;
|
||||
const finalY = finalPosition.snappedY;
|
||||
|
||||
// Only handle day column drops
|
||||
if (target === 'swp-day-column' && finalColumn) {
|
||||
const element = draggedClone as SwpEventElement;
|
||||
|
||||
if (originalElement && draggedClone && this.strategy.handleDragEnd) {
|
||||
this.strategy.handleDragEnd(originalElement, draggedClone, finalColumn, finalY);
|
||||
}
|
||||
|
||||
// Build update payload based on mode
|
||||
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
|
||||
start: element.start,
|
||||
end: element.end,
|
||||
allDay: false
|
||||
};
|
||||
|
||||
if (this.dataSource.isResource()) {
|
||||
// Resource mode: update resourceId, keep existing date
|
||||
updatePayload.resourceId = finalColumn.identifier;
|
||||
} else {
|
||||
// Date mode: update date from column, keep existing time
|
||||
const newDate = this.dateService.parseISO(finalColumn.identifier);
|
||||
const startTimeMinutes = this.dateService.getMinutesSinceMidnight(element.start);
|
||||
const endTimeMinutes = this.dateService.getMinutesSinceMidnight(element.end);
|
||||
updatePayload.start = this.dateService.createDateAtTime(newDate, startTimeMinutes);
|
||||
updatePayload.end = this.dateService.createDateAtTime(newDate, endTimeMinutes);
|
||||
}
|
||||
|
||||
await this.eventManager.updateEvent(element.eventId, updatePayload);
|
||||
|
||||
// Trigger full refresh to re-render with updated data
|
||||
this.eventBus.emit(CoreEvents.REFRESH_REQUESTED, {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupDragColumnChangeListener(): void {
|
||||
this.eventBus.on('drag:column-change', (event: Event) => {
|
||||
let columnChangeEvent = (event as CustomEvent<IDragColumnChangeEventPayload>).detail;
|
||||
|
||||
// Filter: Only handle events where clone is NOT an all-day event (normal timed events)
|
||||
if (columnChangeEvent.draggedClone && columnChangeEvent.draggedClone.hasAttribute('data-allday')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.strategy.handleColumnChange) {
|
||||
this.strategy.handleColumnChange(columnChangeEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupDragMouseLeaveHeaderListener(): void {
|
||||
|
||||
this.dragMouseLeaveHeaderListener = (event: Event) => {
|
||||
const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } = (event as CustomEvent<IDragMouseLeaveHeaderEventPayload>).detail;
|
||||
|
||||
if (cloneElement)
|
||||
cloneElement.style.display = '';
|
||||
|
||||
console.log('🚪 EventRendererManager: Received drag:mouseleave-header', {
|
||||
targetColumn: targetColumn?.identifier,
|
||||
originalElement: originalElement,
|
||||
cloneElement: cloneElement
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
this.eventBus.on('drag:mouseleave-header', this.dragMouseLeaveHeaderListener);
|
||||
}
|
||||
|
||||
private setupDragMouseEnterColumnListener(): void {
|
||||
this.eventBus.on('drag:mouseenter-column', (event: Event) => {
|
||||
const payload = (event as CustomEvent<IDragMouseEnterColumnEventPayload>).detail;
|
||||
|
||||
// Only handle if clone is an all-day event
|
||||
if (!payload.draggedClone.hasAttribute('data-allday')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎯 EventRendererManager: Received drag:mouseenter-column', {
|
||||
targetColumn: payload.targetColumn,
|
||||
snappedY: payload.snappedY,
|
||||
calendarEvent: payload.calendarEvent
|
||||
});
|
||||
|
||||
// Delegate to strategy for conversion
|
||||
if (this.strategy.handleConvertAllDayToTimed) {
|
||||
this.strategy.handleConvertAllDayToTimed(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupResizeEndListener(): void {
|
||||
this.eventBus.on('resize:end', async (event: Event) => {
|
||||
const { eventId, element } = (event as CustomEvent<IResizeEndEventPayload>).detail;
|
||||
|
||||
const swpEvent = element as SwpEventElement;
|
||||
await this.eventManager.updateEvent(eventId, {
|
||||
start: swpEvent.start,
|
||||
end: swpEvent.end
|
||||
});
|
||||
|
||||
// Trigger full refresh to re-render with updated data
|
||||
this.eventBus.emit(CoreEvents.REFRESH_REQUESTED, {});
|
||||
});
|
||||
}
|
||||
|
||||
private setupNavigationCompletedListener(): void {
|
||||
this.eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
|
||||
// Delegate to strategy if it handles navigation
|
||||
if (this.strategy.handleNavigationCompleted) {
|
||||
this.strategy.handleNavigationCompleted();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private clearEvents(container?: HTMLElement): void {
|
||||
this.strategy.clearEvents(container);
|
||||
}
|
||||
|
||||
public refresh(container?: HTMLElement): void {
|
||||
this.clearEvents(container);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { CalendarView } from '../types/CalendarTypes';
|
||||
import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer';
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { TimeFormatter } from '../utils/TimeFormatter';
|
||||
import { IColumnInfo } from '../types/ColumnDataSource';
|
||||
|
||||
/**
|
||||
* GridRenderer - Centralized DOM rendering for calendar grid structure
|
||||
*
|
||||
* ARCHITECTURE OVERVIEW:
|
||||
* =====================
|
||||
* GridRenderer is responsible for creating and managing the complete DOM structure
|
||||
* of the calendar grid. It follows the Strategy Pattern by delegating specific
|
||||
* rendering tasks to specialized renderers (DateHeaderRenderer, ColumnRenderer).
|
||||
*
|
||||
* RESPONSIBILITY HIERARCHY:
|
||||
* ========================
|
||||
* GridRenderer (this file)
|
||||
* ├─ Creates overall grid skeleton
|
||||
* ├─ Manages time axis (hour markers)
|
||||
* └─ Delegates to specialized renderers:
|
||||
* ├─ DateHeaderRenderer → Renders date headers
|
||||
* └─ ColumnRenderer → Renders day columns
|
||||
*
|
||||
* DOM STRUCTURE CREATED:
|
||||
* =====================
|
||||
* <swp-calendar-container>
|
||||
* <swp-header-spacer /> ← GridRenderer
|
||||
* <swp-time-axis> ← GridRenderer
|
||||
* <swp-hour-marker>00:00</...> ← GridRenderer (iterates hours)
|
||||
* </swp-time-axis>
|
||||
* <swp-grid-container> ← GridRenderer
|
||||
* <swp-calendar-header> ← GridRenderer creates container
|
||||
* <swp-day-header /> ← DateHeaderRenderer (iterates dates)
|
||||
* </swp-calendar-header>
|
||||
* <swp-scrollable-content> ← GridRenderer
|
||||
* <swp-time-grid> ← GridRenderer
|
||||
* <swp-grid-lines /> ← GridRenderer
|
||||
* <swp-day-columns> ← GridRenderer creates container
|
||||
* <swp-day-column /> ← ColumnRenderer (iterates dates)
|
||||
* </swp-day-columns>
|
||||
* </swp-time-grid>
|
||||
* </swp-scrollable-content>
|
||||
* </swp-grid-container>
|
||||
* </swp-calendar-container>
|
||||
*
|
||||
* RENDERING FLOW:
|
||||
* ==============
|
||||
* 1. renderGrid() - Entry point called by GridManager
|
||||
* ├─ First render: createCompleteGridStructure()
|
||||
* └─ Updates: updateGridContent()
|
||||
*
|
||||
* 2. createCompleteGridStructure()
|
||||
* ├─ Creates header spacer
|
||||
* ├─ Creates time axis (calls createOptimizedTimeAxis)
|
||||
* └─ Creates grid container (calls createOptimizedGridContainer)
|
||||
*
|
||||
* 3. createOptimizedGridContainer()
|
||||
* ├─ Creates calendar header container
|
||||
* ├─ Creates scrollable content structure
|
||||
* └─ Creates column container (calls renderColumnContainer)
|
||||
*
|
||||
* 4. renderColumnContainer()
|
||||
* └─ Delegates to ColumnRenderer.render()
|
||||
* └─ ColumnRenderer iterates dates and creates columns
|
||||
*
|
||||
* OPTIMIZATION STRATEGY:
|
||||
* =====================
|
||||
* - Caches DOM references (cachedGridContainer, cachedTimeAxis)
|
||||
* - Uses DocumentFragment for batch DOM insertions
|
||||
* - Only updates changed content on re-renders
|
||||
* - Delegates specialized tasks to strategy renderers
|
||||
*
|
||||
* USAGE EXAMPLE:
|
||||
* =============
|
||||
* const gridRenderer = new GridRenderer(columnRenderer, dateService, config);
|
||||
* gridRenderer.renderGrid(containerElement, new Date(), 'week');
|
||||
*/
|
||||
export class GridRenderer {
|
||||
private cachedGridContainer: HTMLElement | null = null;
|
||||
private cachedTimeAxis: HTMLElement | null = null;
|
||||
private dateService: DateService;
|
||||
private columnRenderer: IColumnRenderer;
|
||||
private config: Configuration;
|
||||
|
||||
constructor(
|
||||
columnRenderer: IColumnRenderer,
|
||||
dateService: DateService,
|
||||
config: Configuration
|
||||
) {
|
||||
this.dateService = dateService;
|
||||
this.columnRenderer = columnRenderer;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for rendering the complete calendar grid
|
||||
*
|
||||
* This method decides between full render (first time) or optimized update.
|
||||
* It caches the grid reference for performance.
|
||||
*
|
||||
* @param grid - Container element where grid will be rendered
|
||||
* @param currentDate - Base date for the current view (e.g., any date in the week)
|
||||
* @param view - Calendar view type (day/week/month)
|
||||
* @param columns - Array of columns to render (each column contains its events)
|
||||
*/
|
||||
public renderGrid(
|
||||
grid: HTMLElement,
|
||||
currentDate: Date,
|
||||
view: CalendarView = 'week',
|
||||
columns: IColumnInfo[] = []
|
||||
): void {
|
||||
|
||||
if (!grid || !currentDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache grid reference for performance
|
||||
this.cachedGridContainer = grid;
|
||||
|
||||
// Only clear and rebuild if grid is empty (first render)
|
||||
if (grid.children.length === 0) {
|
||||
this.createCompleteGridStructure(grid, currentDate, view, columns);
|
||||
} else {
|
||||
// Optimized update - only refresh dynamic content
|
||||
this.updateGridContent(grid, currentDate, view, columns);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the complete grid structure from scratch
|
||||
*
|
||||
* Uses DocumentFragment for optimal performance by minimizing reflows.
|
||||
* Creates all child elements in memory first, then appends everything at once.
|
||||
*
|
||||
* Structure created:
|
||||
* 1. Header spacer (placeholder for alignment)
|
||||
* 2. Time axis (hour markers 00:00-23:00)
|
||||
* 3. Grid container (header + scrollable content)
|
||||
*
|
||||
* @param grid - Parent container
|
||||
* @param currentDate - Current view date
|
||||
* @param view - View type
|
||||
* @param columns - Array of columns to render (each column contains its events)
|
||||
*/
|
||||
private createCompleteGridStructure(
|
||||
grid: HTMLElement,
|
||||
currentDate: Date,
|
||||
view: CalendarView,
|
||||
columns: IColumnInfo[]
|
||||
): void {
|
||||
// Create all elements in memory first for better performance
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Create header spacer
|
||||
const headerSpacer = document.createElement('swp-header-spacer');
|
||||
fragment.appendChild(headerSpacer);
|
||||
|
||||
// Create time axis with caching
|
||||
const timeAxis = this.createOptimizedTimeAxis();
|
||||
this.cachedTimeAxis = timeAxis;
|
||||
fragment.appendChild(timeAxis);
|
||||
|
||||
// Create grid container with caching
|
||||
const gridContainer = this.createOptimizedGridContainer(columns, currentDate);
|
||||
this.cachedGridContainer = gridContainer;
|
||||
fragment.appendChild(gridContainer);
|
||||
|
||||
// Append all at once to minimize reflows
|
||||
grid.appendChild(fragment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the time axis with hour markers
|
||||
*
|
||||
* Iterates from dayStartHour to dayEndHour (configured in GridSettings).
|
||||
* Each marker shows the hour in the configured time format.
|
||||
*
|
||||
* @returns Time axis element with all hour markers
|
||||
*/
|
||||
private createOptimizedTimeAxis(): HTMLElement {
|
||||
const timeAxis = document.createElement('swp-time-axis');
|
||||
const timeAxisContent = document.createElement('swp-time-axis-content');
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const startHour = gridSettings.dayStartHour;
|
||||
const endHour = gridSettings.dayEndHour;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let hour = startHour; hour < endHour; hour++) {
|
||||
const marker = document.createElement('swp-hour-marker');
|
||||
const date = new Date(2024, 0, 1, hour, 0);
|
||||
marker.textContent = TimeFormatter.formatTime(date);
|
||||
fragment.appendChild(marker);
|
||||
}
|
||||
|
||||
timeAxisContent.appendChild(fragment);
|
||||
timeAxisContent.style.top = '-1px';
|
||||
timeAxis.appendChild(timeAxisContent);
|
||||
return timeAxis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the main grid container with header and columns
|
||||
*
|
||||
* This is the scrollable area containing:
|
||||
* - Calendar header (dates/resources) - created here, populated by DateHeaderRenderer
|
||||
* - Time grid (grid lines + day columns) - structure created here
|
||||
* - Column container - created here, populated by ColumnRenderer
|
||||
*
|
||||
* @param columns - Array of columns to render (each column contains its events)
|
||||
* @param currentDate - Current view date
|
||||
* @returns Complete grid container element
|
||||
*/
|
||||
private createOptimizedGridContainer(
|
||||
columns: IColumnInfo[],
|
||||
currentDate: Date
|
||||
): HTMLElement {
|
||||
const gridContainer = document.createElement('swp-grid-container');
|
||||
|
||||
// Create calendar header as first child - always exists now!
|
||||
const calendarHeader = document.createElement('swp-calendar-header');
|
||||
gridContainer.appendChild(calendarHeader);
|
||||
|
||||
// Create scrollable content structure
|
||||
const scrollableContent = document.createElement('swp-scrollable-content');
|
||||
const timeGrid = document.createElement('swp-time-grid');
|
||||
|
||||
// Add grid lines
|
||||
const gridLines = document.createElement('swp-grid-lines');
|
||||
timeGrid.appendChild(gridLines);
|
||||
|
||||
// Create column container
|
||||
const columnContainer = document.createElement('swp-day-columns');
|
||||
this.renderColumnContainer(columnContainer, columns, currentDate);
|
||||
timeGrid.appendChild(columnContainer);
|
||||
|
||||
scrollableContent.appendChild(timeGrid);
|
||||
gridContainer.appendChild(scrollableContent);
|
||||
|
||||
return gridContainer;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Renders columns by delegating to ColumnRenderer
|
||||
*
|
||||
* GridRenderer delegates column creation to ColumnRenderer.
|
||||
* Event rendering is handled by EventRenderingService listening to GRID_RENDERED.
|
||||
*
|
||||
* @param columnContainer - Empty container to populate
|
||||
* @param columns - Array of columns to render (each column contains its events)
|
||||
* @param currentDate - Current view date
|
||||
*/
|
||||
private renderColumnContainer(
|
||||
columnContainer: HTMLElement,
|
||||
columns: IColumnInfo[],
|
||||
currentDate: Date
|
||||
): void {
|
||||
// Delegate to ColumnRenderer
|
||||
this.columnRenderer.render(columnContainer, {
|
||||
columns: columns,
|
||||
config: this.config,
|
||||
currentDate: currentDate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized update of grid content without full rebuild
|
||||
*
|
||||
* Only updates the column container content, leaving the structure intact.
|
||||
* This is much faster than recreating the entire grid.
|
||||
*
|
||||
* @param grid - Existing grid element
|
||||
* @param currentDate - New view date
|
||||
* @param view - View type
|
||||
* @param columns - Array of columns to render (each column contains its events)
|
||||
*/
|
||||
private updateGridContent(
|
||||
grid: HTMLElement,
|
||||
currentDate: Date,
|
||||
view: CalendarView,
|
||||
columns: IColumnInfo[]
|
||||
): void {
|
||||
// Update column container if needed
|
||||
const columnContainer = grid.querySelector('swp-day-columns');
|
||||
if (columnContainer) {
|
||||
columnContainer.innerHTML = '';
|
||||
this.renderColumnContainer(columnContainer as HTMLElement, columns, currentDate);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Creates a new grid for slide animations during navigation
|
||||
*
|
||||
* Used by NavigationManager for smooth week-to-week transitions.
|
||||
* Creates a complete grid positioned absolutely for animation.
|
||||
*
|
||||
* Note: Positioning is handled by Animation API, not here.
|
||||
* Events will be rendered by EventRenderingService when GRID_RENDERED emits.
|
||||
*
|
||||
* @param parentContainer - Container for the new grid
|
||||
* @param columns - Array of columns to render
|
||||
* @param currentDate - Current view date
|
||||
* @returns New grid element ready for animation
|
||||
*/
|
||||
public createNavigationGrid(parentContainer: HTMLElement, columns: IColumnInfo[], currentDate: Date): HTMLElement {
|
||||
// Create grid structure (events are in columns, rendered by EventRenderingService)
|
||||
const newGrid = this.createOptimizedGridContainer(columns, currentDate);
|
||||
|
||||
// Position new grid for animation - NO transform here, let Animation API handle it
|
||||
newGrid.style.position = 'absolute';
|
||||
newGrid.style.top = '0';
|
||||
newGrid.style.left = '0';
|
||||
newGrid.style.width = '100%';
|
||||
newGrid.style.height = '100%';
|
||||
|
||||
// Add to parent container
|
||||
parentContainer.appendChild(newGrid);
|
||||
|
||||
return newGrid;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { WorkHoursManager } from '../managers/WorkHoursManager';
|
||||
import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer';
|
||||
import { DateService } from '../utils/DateService';
|
||||
|
||||
/**
|
||||
* Resource-based column renderer
|
||||
*
|
||||
* In resource mode, columns represent resources (people, rooms, etc.)
|
||||
* Work hours are hardcoded (09:00-18:00) for all columns.
|
||||
* TODO: Each resource should have its own work hours.
|
||||
*/
|
||||
export class ResourceColumnRenderer implements IColumnRenderer {
|
||||
private workHoursManager: WorkHoursManager;
|
||||
private dateService: DateService;
|
||||
|
||||
constructor(workHoursManager: WorkHoursManager, dateService: DateService) {
|
||||
this.workHoursManager = workHoursManager;
|
||||
this.dateService = dateService;
|
||||
}
|
||||
|
||||
render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
|
||||
const { columns, currentDate } = context;
|
||||
|
||||
if (!currentDate) {
|
||||
throw new Error('ResourceColumnRenderer requires currentDate in context');
|
||||
}
|
||||
|
||||
// Hardcoded work hours for all resources: 09:00 - 18:00
|
||||
const workHours = { start: 9, end: 18 };
|
||||
|
||||
columns.forEach((columnInfo) => {
|
||||
const column = document.createElement('swp-day-column');
|
||||
|
||||
column.dataset.columnId = columnInfo.identifier;
|
||||
column.dataset.date = this.dateService.formatISODate(currentDate);
|
||||
|
||||
// Apply hardcoded work hours to all resource columns
|
||||
this.applyWorkHoursToColumn(column, workHours);
|
||||
|
||||
const eventsLayer = document.createElement('swp-events-layer');
|
||||
column.appendChild(eventsLayer);
|
||||
|
||||
columnContainer.appendChild(column);
|
||||
});
|
||||
}
|
||||
|
||||
private applyWorkHoursToColumn(column: HTMLElement, workHours: { start: number; end: number }): void {
|
||||
const nonWorkStyle = this.workHoursManager.calculateNonWorkHoursStyle(workHours);
|
||||
if (nonWorkStyle) {
|
||||
column.style.setProperty('--before-work-height', `${nonWorkStyle.beforeWorkHeight}px`);
|
||||
column.style.setProperty('--after-work-top', `${nonWorkStyle.afterWorkTop}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { IHeaderRenderer, IHeaderRenderContext } from './DateHeaderRenderer';
|
||||
import { IResource } from '../types/ResourceTypes';
|
||||
|
||||
/**
|
||||
* ResourceHeaderRenderer - Renders resource-based headers
|
||||
*
|
||||
* Displays resource information (avatar, name) instead of dates.
|
||||
* Used in resource mode where columns represent people/rooms/equipment.
|
||||
*/
|
||||
export class ResourceHeaderRenderer implements IHeaderRenderer {
|
||||
render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void {
|
||||
const { columns } = context;
|
||||
|
||||
// Create all-day container (same structure as date mode)
|
||||
const allDayContainer = document.createElement('swp-allday-container');
|
||||
calendarHeader.appendChild(allDayContainer);
|
||||
|
||||
columns.forEach((columnInfo) => {
|
||||
const resource = columnInfo.data as IResource;
|
||||
const header = document.createElement('swp-day-header');
|
||||
|
||||
// Build header content
|
||||
let avatarHtml = '';
|
||||
if (resource.avatarUrl) {
|
||||
avatarHtml = `<img class="swp-resource-avatar" src="${resource.avatarUrl}" alt="${resource.displayName}" />`;
|
||||
} else {
|
||||
// Fallback: initials
|
||||
const initials = this.getInitials(resource.displayName);
|
||||
const bgColor = resource.color || '#6366f1';
|
||||
avatarHtml = `<span class="swp-resource-initials" style="background-color: ${bgColor}">${initials}</span>`;
|
||||
}
|
||||
|
||||
header.innerHTML = `
|
||||
<div class="swp-resource-header">
|
||||
${avatarHtml}
|
||||
<span class="swp-resource-name">${resource.displayName}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
header.dataset.columnId = columnInfo.identifier;
|
||||
header.dataset.resourceId = resource.id;
|
||||
header.dataset.groupId = columnInfo.groupId;
|
||||
|
||||
calendarHeader.appendChild(header);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initials from display name
|
||||
*/
|
||||
private getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.substring(0, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { EventRenderingService } from './EventRendererManager';
|
||||
import { DateService } from '../utils/DateService';
|
||||
|
||||
/**
|
||||
* WeekInfoRenderer - Handles DOM rendering for week info display
|
||||
* Updates swp-week-number and swp-date-range elements
|
||||
*
|
||||
* Renamed from NavigationRenderer to better reflect its actual responsibility
|
||||
*/
|
||||
|
||||
export class WeekInfoRenderer {
|
||||
private eventBus: IEventBus;
|
||||
private dateService: DateService;
|
||||
|
||||
constructor(
|
||||
eventBus: IEventBus,
|
||||
eventRenderer: EventRenderingService,
|
||||
dateService: DateService
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.dateService = dateService;
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Setup event listeners for DOM updates
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
this.eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { newDate } = customEvent.detail;
|
||||
|
||||
// Calculate week number and date range from the new date
|
||||
const weekNumber = this.dateService.getWeekNumber(newDate);
|
||||
const weekEnd = this.dateService.addDays(newDate, 6);
|
||||
const dateRange = this.dateService.formatDateRange(newDate, weekEnd);
|
||||
|
||||
this.updateWeekInfoInDOM(weekNumber, dateRange);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private updateWeekInfoInDOM(weekNumber: number, dateRange: string): void {
|
||||
|
||||
const weekNumberElement = document.querySelector('swp-week-number');
|
||||
const dateRangeElement = document.querySelector('swp-date-range');
|
||||
|
||||
if (weekNumberElement) {
|
||||
weekNumberElement.textContent = `Week ${weekNumber}`;
|
||||
}
|
||||
|
||||
if (dateRangeElement) {
|
||||
dateRangeElement.textContent = dateRange;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filter state to pre-rendered grids
|
||||
*/
|
||||
public applyFilterToPreRenderedGrids(filterState: { active: boolean; matchingIds: string[] }): void {
|
||||
// Find all grid containers (including pre-rendered ones)
|
||||
const allGridContainers = document.querySelectorAll('swp-grid-container');
|
||||
|
||||
allGridContainers.forEach(container => {
|
||||
const eventsLayers = container.querySelectorAll('swp-events-layer');
|
||||
|
||||
eventsLayers.forEach(layer => {
|
||||
if (filterState.active) {
|
||||
// Apply filter active state
|
||||
layer.setAttribute('data-filter-active', 'true');
|
||||
|
||||
// Mark matching events in this layer
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
const eventId = event.getAttribute('data-event-id');
|
||||
if (eventId && filterState.matchingIds.includes(eventId)) {
|
||||
event.setAttribute('data-matches', 'true');
|
||||
} else {
|
||||
event.removeAttribute('data-matches');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Remove filter state
|
||||
layer.removeAttribute('data-filter-active');
|
||||
|
||||
// Remove all match attributes
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
event.removeAttribute('data-matches');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { IBooking } from '../types/BookingTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
/**
|
||||
* ApiBookingRepository
|
||||
* Handles communication with backend API for bookings
|
||||
*
|
||||
* Implements IApiRepository<IBooking> for generic sync infrastructure.
|
||||
* Used by SyncManager to send queued booking operations to the server.
|
||||
*/
|
||||
export class ApiBookingRepository implements IApiRepository<IBooking> {
|
||||
readonly entityType: EntityType = 'Booking';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.apiEndpoint = config.apiEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send create operation to API
|
||||
*/
|
||||
async sendCreate(booking: IBooking): Promise<IBooking> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/bookings`, {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(booking)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API create failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiBookingRepository.sendCreate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update operation to API
|
||||
*/
|
||||
async sendUpdate(id: string, updates: Partial<IBooking>): Promise<IBooking> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/bookings/${id}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API update failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiBookingRepository.sendUpdate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delete operation to API
|
||||
*/
|
||||
async sendDelete(id: string): Promise<void> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/bookings/${id}`, {
|
||||
// method: 'DELETE'
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API delete failed: ${response.statusText}`);
|
||||
// }
|
||||
|
||||
throw new Error('ApiBookingRepository.sendDelete not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all bookings from API
|
||||
*/
|
||||
async fetchAll(): Promise<IBooking[]> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/bookings`);
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API fetch failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiBookingRepository.fetchAll not implemented yet');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { ICustomer } from '../types/CustomerTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
/**
|
||||
* ApiCustomerRepository
|
||||
* Handles communication with backend API for customers
|
||||
*
|
||||
* Implements IApiRepository<ICustomer> for generic sync infrastructure.
|
||||
* Used by SyncManager to send queued customer operations to the server.
|
||||
*/
|
||||
export class ApiCustomerRepository implements IApiRepository<ICustomer> {
|
||||
readonly entityType: EntityType = 'Customer';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.apiEndpoint = config.apiEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send create operation to API
|
||||
*/
|
||||
async sendCreate(customer: ICustomer): Promise<ICustomer> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/customers`, {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(customer)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API create failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiCustomerRepository.sendCreate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update operation to API
|
||||
*/
|
||||
async sendUpdate(id: string, updates: Partial<ICustomer>): Promise<ICustomer> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/customers/${id}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API update failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiCustomerRepository.sendUpdate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delete operation to API
|
||||
*/
|
||||
async sendDelete(id: string): Promise<void> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/customers/${id}`, {
|
||||
// method: 'DELETE'
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API delete failed: ${response.statusText}`);
|
||||
// }
|
||||
|
||||
throw new Error('ApiCustomerRepository.sendDelete not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all customers from API
|
||||
*/
|
||||
async fetchAll(): Promise<ICustomer[]> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/customers`);
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API fetch failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiCustomerRepository.fetchAll not implemented yet');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { ICalendarEvent, EntityType } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
/**
|
||||
* ApiEventRepository
|
||||
* Handles communication with backend API for calendar events
|
||||
*
|
||||
* Implements IApiRepository<ICalendarEvent> for generic sync infrastructure.
|
||||
* Used by SyncManager to send queued operations to the server.
|
||||
* NOT used directly by EventManager (which uses IndexedDBEventRepository).
|
||||
*
|
||||
* Future enhancements:
|
||||
* - SignalR real-time updates
|
||||
* - Conflict resolution
|
||||
* - Batch operations
|
||||
*/
|
||||
export class ApiEventRepository implements IApiRepository<ICalendarEvent> {
|
||||
readonly entityType: EntityType = 'Event';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.apiEndpoint = config.apiEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send create operation to API
|
||||
*/
|
||||
async sendCreate(event: ICalendarEvent): Promise<ICalendarEvent> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/events`, {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(event)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API create failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiEventRepository.sendCreate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update operation to API
|
||||
*/
|
||||
async sendUpdate(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/events/${id}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API update failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiEventRepository.sendUpdate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delete operation to API
|
||||
*/
|
||||
async sendDelete(id: string): Promise<void> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/events/${id}`, {
|
||||
// method: 'DELETE'
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API delete failed: ${response.statusText}`);
|
||||
// }
|
||||
|
||||
throw new Error('ApiEventRepository.sendDelete not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all events from API
|
||||
*/
|
||||
async fetchAll(): Promise<ICalendarEvent[]> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/events`);
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API fetch failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiEventRepository.fetchAll not implemented yet');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Future: SignalR Integration
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Initialize SignalR connection
|
||||
* Placeholder for future implementation
|
||||
*/
|
||||
async initializeSignalR(): Promise<void> {
|
||||
// TODO: Setup SignalR connection
|
||||
// - Connect to hub
|
||||
// - Register event handlers
|
||||
// - Handle reconnection
|
||||
//
|
||||
// Example:
|
||||
// const connection = new signalR.HubConnectionBuilder()
|
||||
// .withUrl(`${this.apiEndpoint}/hubs/calendar`)
|
||||
// .build();
|
||||
//
|
||||
// connection.on('EventCreated', (event: ICalendarEvent) => {
|
||||
// // Handle remote create
|
||||
// });
|
||||
//
|
||||
// connection.on('EventUpdated', (event: ICalendarEvent) => {
|
||||
// // Handle remote update
|
||||
// });
|
||||
//
|
||||
// connection.on('EventDeleted', (eventId: string) => {
|
||||
// // Handle remote delete
|
||||
// });
|
||||
//
|
||||
// await connection.start();
|
||||
|
||||
throw new Error('SignalR not implemented yet');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { IResource } from '../types/ResourceTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
/**
|
||||
* ApiResourceRepository
|
||||
* Handles communication with backend API for resources
|
||||
*
|
||||
* Implements IApiRepository<IResource> for generic sync infrastructure.
|
||||
* Used by SyncManager to send queued resource operations to the server.
|
||||
*/
|
||||
export class ApiResourceRepository implements IApiRepository<IResource> {
|
||||
readonly entityType: EntityType = 'Resource';
|
||||
private apiEndpoint: string;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.apiEndpoint = config.apiEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send create operation to API
|
||||
*/
|
||||
async sendCreate(resource: IResource): Promise<IResource> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/resources`, {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(resource)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API create failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiResourceRepository.sendCreate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update operation to API
|
||||
*/
|
||||
async sendUpdate(id: string, updates: Partial<IResource>): Promise<IResource> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/resources/${id}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates)
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API update failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiResourceRepository.sendUpdate not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delete operation to API
|
||||
*/
|
||||
async sendDelete(id: string): Promise<void> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/resources/${id}`, {
|
||||
// method: 'DELETE'
|
||||
// });
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API delete failed: ${response.statusText}`);
|
||||
// }
|
||||
|
||||
throw new Error('ApiResourceRepository.sendDelete not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all resources from API
|
||||
*/
|
||||
async fetchAll(): Promise<IResource[]> {
|
||||
// TODO: Implement API call
|
||||
// const response = await fetch(`${this.apiEndpoint}/resources`);
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`API fetch failed: ${response.statusText}`);
|
||||
// }
|
||||
//
|
||||
// return await response.json();
|
||||
|
||||
throw new Error('ApiResourceRepository.fetchAll not implemented yet');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,58 +3,31 @@ import { EntityType } from '../types/CalendarTypes';
|
|||
/**
|
||||
* IApiRepository<T> - Generic interface for backend API communication
|
||||
*
|
||||
* All entity-specific API repositories (Event, Booking, Customer, Resource)
|
||||
* must implement this interface to ensure consistent sync behavior.
|
||||
*
|
||||
* Used by SyncManager to route operations to the correct API endpoints
|
||||
* based on entity type (dataEntity.typename).
|
||||
*
|
||||
* Pattern:
|
||||
* - Each entity has its own concrete implementation (ApiEventRepository, ApiBookingRepository, etc.)
|
||||
* - SyncManager maintains a map of entityType → IApiRepository<T>
|
||||
* - Operations are routed at runtime based on IQueueOperation.dataEntity.typename
|
||||
* Used by DataSeeder to fetch initial data and by SyncManager for sync operations.
|
||||
*/
|
||||
export interface IApiRepository<T> {
|
||||
/**
|
||||
* Entity type discriminator - used for runtime routing
|
||||
* Must match EntityType values ('Event', 'Booking', 'Customer', 'Resource')
|
||||
*/
|
||||
readonly entityType: EntityType;
|
||||
|
||||
/**
|
||||
* Send create operation to backend API
|
||||
*
|
||||
* @param data - Entity data to create
|
||||
* @returns Promise<T> - Created entity from server (with server-generated fields)
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
sendCreate(data: T): Promise<T>;
|
||||
|
||||
/**
|
||||
* Send update operation to backend API
|
||||
*
|
||||
* @param id - Entity ID
|
||||
* @param updates - Partial entity data to update
|
||||
* @returns Promise<T> - Updated entity from server
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
sendUpdate(id: string, updates: Partial<T>): Promise<T>;
|
||||
|
||||
/**
|
||||
* Send delete operation to backend API
|
||||
*
|
||||
* @param id - Entity ID to delete
|
||||
* @returns Promise<void>
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
sendDelete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetch all entities from backend API
|
||||
* Used for initial sync and full refresh
|
||||
*
|
||||
* @returns Promise<T[]> - Array of all entities
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
fetchAll(): Promise<T[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export class MockAuditRepository implements IApiRepository<IAuditEntry> {
|
|||
return entity;
|
||||
}
|
||||
|
||||
async sendUpdate(_id: string, entity: IAuditEntry): Promise<IAuditEntry> {
|
||||
async sendUpdate(_id: string, _entity: IAuditEntry): Promise<IAuditEntry> {
|
||||
// Audit entries are immutable - updates should not happen
|
||||
throw new Error('Audit entries cannot be updated');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { IBooking, IBookingService, BookingStatus } from '../types/BookingTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { IBooking, IBookingService, BookingStatus, EntityType } from '../types/CalendarTypes';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
interface RawBookingData {
|
||||
|
|
@ -25,22 +24,11 @@ interface RawBookingService {
|
|||
|
||||
/**
|
||||
* MockBookingRepository - Loads booking data from local JSON file
|
||||
*
|
||||
* This repository implementation fetches mock booking data from a static JSON file.
|
||||
* Used for development and testing instead of API calls.
|
||||
*
|
||||
* Data Source: data/mock-bookings.json
|
||||
*
|
||||
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||
* Only fetchAll() is implemented for loading initial mock data.
|
||||
*/
|
||||
export class MockBookingRepository implements IApiRepository<IBooking> {
|
||||
public readonly entityType: EntityType = 'Booking';
|
||||
private readonly dataUrl = 'data/mock-bookings.json';
|
||||
|
||||
/**
|
||||
* Fetch all bookings from mock JSON file
|
||||
*/
|
||||
public async fetchAll(): Promise<IBooking[]> {
|
||||
try {
|
||||
const response = await fetch(this.dataUrl);
|
||||
|
|
@ -50,7 +38,6 @@ export class MockBookingRepository implements IApiRepository<IBooking> {
|
|||
}
|
||||
|
||||
const rawData: RawBookingData[] = await response.json();
|
||||
|
||||
return this.processBookingData(rawData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load booking data:', error);
|
||||
|
|
@ -58,32 +45,28 @@ export class MockBookingRepository implements IApiRepository<IBooking> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockBookingRepository is read-only
|
||||
*/
|
||||
public async sendCreate(booking: IBooking): Promise<IBooking> {
|
||||
public async sendCreate(_booking: IBooking): Promise<IBooking> {
|
||||
throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockBookingRepository is read-only
|
||||
*/
|
||||
public async sendUpdate(id: string, updates: Partial<IBooking>): Promise<IBooking> {
|
||||
public async sendUpdate(_id: string, _updates: Partial<IBooking>): Promise<IBooking> {
|
||||
throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockBookingRepository is read-only
|
||||
*/
|
||||
public async sendDelete(id: string): Promise<void> {
|
||||
public async sendDelete(_id: string): Promise<void> {
|
||||
throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.');
|
||||
}
|
||||
|
||||
private processBookingData(data: RawBookingData[]): IBooking[] {
|
||||
return data.map((booking): IBooking => ({
|
||||
...booking,
|
||||
createdAt: new Date(booking.createdAt),
|
||||
id: booking.id,
|
||||
customerId: booking.customerId,
|
||||
status: booking.status as BookingStatus,
|
||||
createdAt: new Date(booking.createdAt),
|
||||
services: booking.services as IBookingService[],
|
||||
totalPrice: booking.totalPrice,
|
||||
tags: booking.tags,
|
||||
notes: booking.notes,
|
||||
syncStatus: 'synced' as const
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { ICustomer } from '../types/CustomerTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { ICustomer, EntityType } from '../types/CalendarTypes';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
interface RawCustomerData {
|
||||
|
|
@ -7,28 +6,17 @@ interface RawCustomerData {
|
|||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
metadata?: Record<string, any>;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* MockCustomerRepository - Loads customer data from local JSON file
|
||||
*
|
||||
* This repository implementation fetches mock customer data from a static JSON file.
|
||||
* Used for development and testing instead of API calls.
|
||||
*
|
||||
* Data Source: data/mock-customers.json
|
||||
*
|
||||
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||
* Only fetchAll() is implemented for loading initial mock data.
|
||||
*/
|
||||
export class MockCustomerRepository implements IApiRepository<ICustomer> {
|
||||
public readonly entityType: EntityType = 'Customer';
|
||||
private readonly dataUrl = 'data/mock-customers.json';
|
||||
|
||||
/**
|
||||
* Fetch all customers from mock JSON file
|
||||
*/
|
||||
public async fetchAll(): Promise<ICustomer[]> {
|
||||
try {
|
||||
const response = await fetch(this.dataUrl);
|
||||
|
|
@ -38,7 +26,6 @@ export class MockCustomerRepository implements IApiRepository<ICustomer> {
|
|||
}
|
||||
|
||||
const rawData: RawCustomerData[] = await response.json();
|
||||
|
||||
return this.processCustomerData(rawData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load customer data:', error);
|
||||
|
|
@ -46,30 +33,25 @@ export class MockCustomerRepository implements IApiRepository<ICustomer> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockCustomerRepository is read-only
|
||||
*/
|
||||
public async sendCreate(customer: ICustomer): Promise<ICustomer> {
|
||||
public async sendCreate(_customer: ICustomer): Promise<ICustomer> {
|
||||
throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockCustomerRepository is read-only
|
||||
*/
|
||||
public async sendUpdate(id: string, updates: Partial<ICustomer>): Promise<ICustomer> {
|
||||
public async sendUpdate(_id: string, _updates: Partial<ICustomer>): Promise<ICustomer> {
|
||||
throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockCustomerRepository is read-only
|
||||
*/
|
||||
public async sendDelete(id: string): Promise<void> {
|
||||
public async sendDelete(_id: string): Promise<void> {
|
||||
throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
|
||||
}
|
||||
|
||||
private processCustomerData(data: RawCustomerData[]): ICustomer[] {
|
||||
return data.map((customer): ICustomer => ({
|
||||
...customer,
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
phone: customer.phone,
|
||||
email: customer.email,
|
||||
metadata: customer.metadata,
|
||||
syncStatus: 'synced' as const
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,26 @@
|
|||
import { ICalendarEvent, EntityType } from '../types/CalendarTypes';
|
||||
import { CalendarEventType } from '../types/BookingTypes';
|
||||
import { ICalendarEvent, EntityType, CalendarEventType } from '../types/CalendarTypes';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
|
||||
interface RawEventData {
|
||||
// Core fields (required)
|
||||
id: string;
|
||||
title: string;
|
||||
start: string | Date;
|
||||
end: string | Date;
|
||||
type: string;
|
||||
allDay?: boolean;
|
||||
|
||||
// Denormalized references (CRITICAL for booking architecture)
|
||||
bookingId?: string; // Reference to booking (customer events only)
|
||||
resourceId?: string; // Which resource owns this slot
|
||||
customerId?: string; // Customer reference (denormalized from booking)
|
||||
|
||||
// Optional fields
|
||||
description?: string; // Detailed event notes
|
||||
recurringId?: string; // For recurring events
|
||||
metadata?: Record<string, any>; // Flexible metadata
|
||||
|
||||
// Legacy (deprecated, keep for backward compatibility)
|
||||
color?: string; // UI-specific field
|
||||
bookingId?: string;
|
||||
resourceId?: string;
|
||||
customerId?: string;
|
||||
description?: string;
|
||||
recurringId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* MockEventRepository - Loads event data from local JSON file
|
||||
*
|
||||
* This repository implementation fetches mock event data from a static JSON file.
|
||||
* Used for development and testing instead of API calls.
|
||||
*
|
||||
* Data Source: data/mock-events.json
|
||||
*
|
||||
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||
* Only fetchAll() is implemented for loading initial mock data.
|
||||
* Used for development and testing. Only fetchAll() is implemented.
|
||||
*/
|
||||
export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
||||
public readonly entityType: EntityType = 'Event';
|
||||
|
|
@ -53,7 +38,6 @@ export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
|||
}
|
||||
|
||||
const rawData: RawEventData[] = await response.json();
|
||||
|
||||
return this.processCalendarData(rawData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load event data:', error);
|
||||
|
|
@ -61,40 +45,25 @@ export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockEventRepository is read-only
|
||||
*/
|
||||
public async sendCreate(event: ICalendarEvent): Promise<ICalendarEvent> {
|
||||
public async sendCreate(_event: ICalendarEvent): Promise<ICalendarEvent> {
|
||||
throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockEventRepository is read-only
|
||||
*/
|
||||
public async sendUpdate(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
|
||||
public async sendUpdate(_id: string, _updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
|
||||
throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockEventRepository is read-only
|
||||
*/
|
||||
public async sendDelete(id: string): Promise<void> {
|
||||
public async sendDelete(_id: string): Promise<void> {
|
||||
throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.');
|
||||
}
|
||||
|
||||
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
||||
return data.map((event): ICalendarEvent => {
|
||||
// Validate event type constraints
|
||||
// Validate customer event constraints
|
||||
if (event.type === 'customer') {
|
||||
if (!event.bookingId) {
|
||||
console.warn(`Customer event ${event.id} missing bookingId`);
|
||||
}
|
||||
if (!event.resourceId) {
|
||||
console.warn(`Customer event ${event.id} missing resourceId`);
|
||||
}
|
||||
if (!event.customerId) {
|
||||
console.warn(`Customer event ${event.id} missing customerId`);
|
||||
}
|
||||
if (!event.bookingId) console.warn(`Customer event ${event.id} missing bookingId`);
|
||||
if (!event.resourceId) console.warn(`Customer event ${event.id} missing resourceId`);
|
||||
if (!event.customerId) console.warn(`Customer event ${event.id} missing customerId`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -105,16 +74,11 @@ export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
|||
end: new Date(event.end),
|
||||
type: event.type as CalendarEventType,
|
||||
allDay: event.allDay || false,
|
||||
|
||||
// Denormalized references (CRITICAL for booking architecture)
|
||||
bookingId: event.bookingId,
|
||||
resourceId: event.resourceId,
|
||||
customerId: event.customerId,
|
||||
|
||||
// Optional fields
|
||||
recurringId: event.recurringId,
|
||||
metadata: event.metadata,
|
||||
|
||||
syncStatus: 'synced' as const
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { IResource, ResourceType } from '../types/ResourceTypes';
|
||||
import { EntityType } from '../types/CalendarTypes';
|
||||
import { IResource, ResourceType, EntityType } from '../types/CalendarTypes';
|
||||
import { IApiRepository } from './IApiRepository';
|
||||
import { IWeekSchedule } from '../types/ScheduleTypes';
|
||||
|
||||
interface RawResourceData {
|
||||
id: string;
|
||||
|
|
@ -10,28 +10,17 @@ interface RawResourceData {
|
|||
avatarUrl?: string;
|
||||
color?: string;
|
||||
isActive?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
[key: string]: unknown;
|
||||
defaultSchedule?: IWeekSchedule;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MockResourceRepository - Loads resource data from local JSON file
|
||||
*
|
||||
* This repository implementation fetches mock resource data from a static JSON file.
|
||||
* Used for development and testing instead of API calls.
|
||||
*
|
||||
* Data Source: data/mock-resources.json
|
||||
*
|
||||
* NOTE: Create/Update/Delete operations are not supported - throws errors.
|
||||
* Only fetchAll() is implemented for loading initial mock data.
|
||||
*/
|
||||
export class MockResourceRepository implements IApiRepository<IResource> {
|
||||
public readonly entityType: EntityType = 'Resource';
|
||||
private readonly dataUrl = 'data/mock-resources.json';
|
||||
|
||||
/**
|
||||
* Fetch all resources from mock JSON file
|
||||
*/
|
||||
public async fetchAll(): Promise<IResource[]> {
|
||||
try {
|
||||
const response = await fetch(this.dataUrl);
|
||||
|
|
@ -41,7 +30,6 @@ export class MockResourceRepository implements IApiRepository<IResource> {
|
|||
}
|
||||
|
||||
const rawData: RawResourceData[] = await response.json();
|
||||
|
||||
return this.processResourceData(rawData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load resource data:', error);
|
||||
|
|
@ -49,31 +37,29 @@ export class MockResourceRepository implements IApiRepository<IResource> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockResourceRepository is read-only
|
||||
*/
|
||||
public async sendCreate(resource: IResource): Promise<IResource> {
|
||||
public async sendCreate(_resource: IResource): Promise<IResource> {
|
||||
throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockResourceRepository is read-only
|
||||
*/
|
||||
public async sendUpdate(id: string, updates: Partial<IResource>): Promise<IResource> {
|
||||
public async sendUpdate(_id: string, _updates: Partial<IResource>): Promise<IResource> {
|
||||
throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.');
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT SUPPORTED - MockResourceRepository is read-only
|
||||
*/
|
||||
public async sendDelete(id: string): Promise<void> {
|
||||
public async sendDelete(_id: string): Promise<void> {
|
||||
throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
|
||||
}
|
||||
|
||||
private processResourceData(data: RawResourceData[]): IResource[] {
|
||||
return data.map((resource): IResource => ({
|
||||
...resource,
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
displayName: resource.displayName,
|
||||
type: resource.type as ResourceType,
|
||||
avatarUrl: resource.avatarUrl,
|
||||
color: resource.color,
|
||||
isActive: resource.isActive,
|
||||
defaultSchedule: resource.defaultSchedule,
|
||||
metadata: resource.metadata,
|
||||
syncStatus: 'synced' as const
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue