Moving away from Azure Devops #1
200 changed files with 2331 additions and 16193 deletions
|
|
@ -13,7 +13,10 @@
|
||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
"Bash(npm run css:analyze:*)",
|
"Bash(npm run css:analyze:*)",
|
||||||
"Bash(npm run test:run:*)",
|
"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": [],
|
"deny": [],
|
||||||
"ask": []
|
"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
|
// Build with esbuild
|
||||||
async function build() {
|
async function build() {
|
||||||
try {
|
try {
|
||||||
// Main calendar bundle (with DI)
|
// Calendar standalone bundle (no DI)
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
entryPoints: ['src/index.ts'],
|
entryPoints: ['src/entry.ts'],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
outfile: 'wwwroot/js/calendar.js',
|
outfile: 'wwwroot/js/calendar.js',
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
|
|
@ -42,40 +42,26 @@ async function build() {
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
minify: false,
|
minify: false,
|
||||||
keepNames: true,
|
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',
|
platform: 'browser',
|
||||||
plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true, performanceLogging: true })]
|
plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true, performanceLogging: true })]
|
||||||
});
|
});
|
||||||
|
|
||||||
// V2 standalone bundle (no DI, no dependencies on main calendar)
|
console.log('Demo bundle created: wwwroot/js/demo.js');
|
||||||
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');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Build failed:', error);
|
console.error('Build failed:', error);
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Total CSS Size</div>
|
<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>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">CSS Files</div>
|
<div class="stat-label">CSS Files</div>
|
||||||
|
|
@ -149,11 +149,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card warning">
|
<div class="stat-card warning">
|
||||||
<div class="stat-label">Unused CSS Rules</div>
|
<div class="stat-label">Unused CSS Rules</div>
|
||||||
<div class="stat-value">23</div>
|
<div class="stat-value">43</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card success">
|
<div class="stat-card success">
|
||||||
<div class="stat-label">Potential Removal</div>
|
<div class="stat-label">Potential Removal</div>
|
||||||
<div class="stat-value">0.15%</div>
|
<div class="stat-value">0.27%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -195,12 +195,12 @@
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>calendar-v2-layout.css</strong></td>
|
<td><strong>calendar-v2-layout.css</strong></td>
|
||||||
<td>6.39 KB</td>
|
<td>8.65 KB</td>
|
||||||
<td>308</td>
|
<td>428</td>
|
||||||
<td>38</td>
|
<td>56</td>
|
||||||
<td>48</td>
|
<td>71</td>
|
||||||
<td>153</td>
|
<td>219</td>
|
||||||
<td>1</td>
|
<td>2</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -237,17 +237,17 @@
|
||||||
<h3>calendar-v2-layout.css</h3>
|
<h3>calendar-v2-layout.css</h3>
|
||||||
<p>
|
<p>
|
||||||
<span class="badge badge-success">
|
<span class="badge badge-success">
|
||||||
3 unused rules
|
16 unused rules
|
||||||
</span>
|
</span>
|
||||||
<span style="margin-left: 10px; color: #666;">
|
<span style="margin-left: 10px; color: #666;">
|
||||||
Original: 6275 | After purge: 6203
|
Original: 7087 | After purge: 6800
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
|
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
|
||||||
<div class="rejected-list">
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -257,19 +257,19 @@
|
||||||
<div class="file-detail">
|
<div class="file-detail">
|
||||||
<h3>calendar-v2-events.css</h3>
|
<h3>calendar-v2-events.css</h3>
|
||||||
<p>
|
<p>
|
||||||
<span class="badge badge-success">
|
<span class="badge badge-warning">
|
||||||
20 unused rules
|
26 unused rules
|
||||||
</span>
|
</span>
|
||||||
<span style="margin-left: 10px; color: #666;">
|
<span style="margin-left: 10px; color: #666;">
|
||||||
Original: 7298 | After purge: 6810
|
Original: 7047 | After purge: 6504
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
|
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
|
||||||
<div class="rejected-list">
|
<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>
|
&.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-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
|
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>
|
</div>
|
||||||
</details>
|
</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>
|
<h3>calendar-v2-base.css</h3>
|
||||||
<p>
|
<p>
|
||||||
<span class="badge badge-success">
|
<span class="badge badge-success">
|
||||||
0 unused rules
|
1 unused rules
|
||||||
</span>
|
</span>
|
||||||
<span style="margin-left: 10px; color: #666;">
|
<span style="margin-left: 10px; color: #666;">
|
||||||
Original: 1701 | After purge: 1701
|
Original: 1574 | After purge: 1570
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
</section>
|
</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>✅ CSS usage is relatively clean.</li>
|
||||||
|
|
||||||
<li>📦 Consider consolidating similar styles to reduce duplication.</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>
|
<li>🔄 Implement a build process to automatically remove unused CSS in production.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,15 @@
|
||||||
"mediaQueries": 0
|
"mediaQueries": 0
|
||||||
},
|
},
|
||||||
"calendar-v2-layout.css": {
|
"calendar-v2-layout.css": {
|
||||||
"lines": 308,
|
"lines": 428,
|
||||||
"size": "6.39 KB",
|
"size": "8.65 KB",
|
||||||
"sizeBytes": 6548,
|
"sizeBytes": 8857,
|
||||||
"rules": 38,
|
"rules": 56,
|
||||||
"selectors": 48,
|
"selectors": 71,
|
||||||
"properties": 153,
|
"properties": 219,
|
||||||
"uniqueColors": 1,
|
"uniqueColors": 2,
|
||||||
"colors": [
|
"colors": [
|
||||||
|
"rgba(0,0,0,0.1)",
|
||||||
"rgba(0, 0, 0, 0.05)"
|
"rgba(0, 0, 0, 0.05)"
|
||||||
],
|
],
|
||||||
"mediaQueries": 0
|
"mediaQueries": 0
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"summary": {
|
"summary": {
|
||||||
"totalFiles": 4,
|
"totalFiles": 4,
|
||||||
"totalOriginalSize": 15460,
|
"totalOriginalSize": 15894,
|
||||||
"totalPurgedSize": 14900,
|
"totalPurgedSize": 15060,
|
||||||
"totalRejected": 23,
|
"totalRejected": 43,
|
||||||
"percentageRemoved": "0.15%",
|
"percentageRemoved": "0.27%",
|
||||||
"potentialSavings": 560
|
"potentialSavings": 834
|
||||||
},
|
},
|
||||||
"fileDetails": {
|
"fileDetails": {
|
||||||
"calendar-v2.css": {
|
"calendar-v2.css": {
|
||||||
|
|
@ -15,20 +15,34 @@
|
||||||
"rejected": []
|
"rejected": []
|
||||||
},
|
},
|
||||||
"calendar-v2-layout.css": {
|
"calendar-v2-layout.css": {
|
||||||
"originalSize": 6275,
|
"originalSize": 7087,
|
||||||
"purgedSize": 6203,
|
"purgedSize": 6800,
|
||||||
"rejectedCount": 3,
|
"rejectedCount": 16,
|
||||||
"rejected": [
|
"rejected": [
|
||||||
|
".view-chip",
|
||||||
"&:hover",
|
"&: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=\"resource date\"]",
|
||||||
"&[data-levels=\"team resource date\"]"
|
"&[data-levels=\"team resource date\"]",
|
||||||
|
"&[data-levels=\"department resource date\"]",
|
||||||
|
"&[data-hidden=\"true\"]"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"calendar-v2-events.css": {
|
"calendar-v2-events.css": {
|
||||||
"originalSize": 7298,
|
"originalSize": 7047,
|
||||||
"purgedSize": 6810,
|
"purgedSize": 6504,
|
||||||
"rejectedCount": 20,
|
"rejectedCount": 26,
|
||||||
"rejected": [
|
"rejected": [
|
||||||
|
"&.drag-ghost",
|
||||||
"&:hover",
|
"&:hover",
|
||||||
"&[data-continues-before=\"true\"]",
|
"&[data-continues-before=\"true\"]",
|
||||||
"&[data-continues-after=\"true\"]",
|
"&[data-continues-after=\"true\"]",
|
||||||
|
|
@ -36,26 +50,33 @@
|
||||||
"swp-events-layer[data-filter-active=\"true\"] swp-event[data-matches=\"true\"]",
|
"swp-events-layer[data-filter-active=\"true\"] swp-event[data-matches=\"true\"]",
|
||||||
"swp-event[data-stack-link]:not([data-stack-link*='\"stackLevel\":0'])",
|
"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",
|
"\nswp-event-group[data-stack-link]:not([data-stack-link*='\"stackLevel\":0']) swp-event",
|
||||||
|
".is-red",
|
||||||
".is-pink",
|
".is-pink",
|
||||||
".is-magenta",
|
".is-magenta",
|
||||||
|
".is-purple",
|
||||||
".is-violet",
|
".is-violet",
|
||||||
".is-deep-purple",
|
".is-deep-purple",
|
||||||
".is-indigo",
|
".is-indigo",
|
||||||
|
".is-blue",
|
||||||
".is-light-blue",
|
".is-light-blue",
|
||||||
".is-cyan",
|
".is-cyan",
|
||||||
".is-teal",
|
".is-teal",
|
||||||
|
".is-green",
|
||||||
".is-light-green",
|
".is-light-green",
|
||||||
".is-lime",
|
".is-lime",
|
||||||
".is-yellow",
|
".is-yellow",
|
||||||
|
".is-amber",
|
||||||
".is-orange",
|
".is-orange",
|
||||||
".is-deep-orange"
|
".is-deep-orange"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"calendar-v2-base.css": {
|
"calendar-v2-base.css": {
|
||||||
"originalSize": 1701,
|
"originalSize": 1574,
|
||||||
"purgedSize": 1701,
|
"purgedSize": 1570,
|
||||||
"rejectedCount": 0,
|
"rejectedCount": 1,
|
||||||
"rejected": []
|
"rejected": [
|
||||||
|
"body"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +95,7 @@ const defaultGridConfig: IGridConfig = {
|
||||||
gridStartThresholdMinutes: 30
|
gridStartThresholdMinutes: 30
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createV2Container(): Container {
|
export function createContainer(): Container {
|
||||||
const container = new Container();
|
const container = new Container();
|
||||||
const builder = container.builder();
|
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
|
* CoreEvents - Consolidated essential events for the calendar
|
||||||
* Reduces complexity from 102+ events to ~20 core events
|
|
||||||
*/
|
*/
|
||||||
export const CoreEvents = {
|
export const CoreEvents = {
|
||||||
// Lifecycle events (3)
|
// Lifecycle events
|
||||||
INITIALIZED: 'core:initialized',
|
INITIALIZED: 'core:initialized',
|
||||||
READY: 'core:ready',
|
READY: 'core:ready',
|
||||||
DESTROYED: 'core:destroyed',
|
DESTROYED: 'core:destroyed',
|
||||||
|
|
||||||
// View events (3)
|
// View events
|
||||||
VIEW_CHANGED: 'view:changed',
|
VIEW_CHANGED: 'view:changed',
|
||||||
VIEW_RENDERED: 'view:rendered',
|
VIEW_RENDERED: 'view:rendered',
|
||||||
WORKWEEK_CHANGED: 'workweek:changed',
|
|
||||||
|
|
||||||
// Navigation events (4)
|
// Navigation events
|
||||||
NAV_BUTTON_CLICKED: 'nav:button-clicked',
|
|
||||||
DATE_CHANGED: 'nav:date-changed',
|
DATE_CHANGED: 'nav:date-changed',
|
||||||
NAVIGATION_COMPLETED: 'nav:navigation-completed',
|
NAVIGATION_COMPLETED: 'nav:navigation-completed',
|
||||||
NAVIGATE_TO_EVENT: 'nav:navigate-to-event',
|
|
||||||
|
|
||||||
// Data events (5)
|
// Data events
|
||||||
DATA_LOADING: 'data:loading',
|
DATA_LOADING: 'data:loading',
|
||||||
DATA_LOADED: 'data:loaded',
|
DATA_LOADED: 'data:loaded',
|
||||||
DATA_ERROR: 'data:error',
|
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_RENDERED: 'grid:rendered',
|
||||||
GRID_CLICKED: 'grid:clicked',
|
GRID_CLICKED: 'grid:clicked',
|
||||||
CELL_SELECTED: 'grid:cell-selected',
|
|
||||||
|
|
||||||
// Event management (4)
|
// Event management
|
||||||
EVENT_CREATED: 'event:created',
|
EVENT_CREATED: 'event:created',
|
||||||
EVENT_UPDATED: 'event:updated',
|
EVENT_UPDATED: 'event:updated',
|
||||||
EVENT_DELETED: 'event:deleted',
|
EVENT_DELETED: 'event:deleted',
|
||||||
EVENT_SELECTED: 'event:selected',
|
EVENT_SELECTED: 'event:selected',
|
||||||
|
|
||||||
// System events (3)
|
// Event drag-drop
|
||||||
ERROR: 'system:error',
|
EVENT_DRAG_START: 'event:drag-start',
|
||||||
REFRESH_REQUESTED: 'system:refresh',
|
EVENT_DRAG_MOVE: 'event:drag-move',
|
||||||
OFFLINE_MODE_CHANGED: 'system:offline-mode-changed',
|
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_STARTED: 'sync:started',
|
||||||
SYNC_COMPLETED: 'sync:completed',
|
SYNC_COMPLETED: 'sync:completed',
|
||||||
SYNC_FAILED: 'sync:failed',
|
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_SAVED: 'entity:saved',
|
||||||
ENTITY_DELETED: 'entity:deleted',
|
ENTITY_DELETED: 'entity:deleted',
|
||||||
|
|
||||||
|
// Audit events
|
||||||
AUDIT_LOGGED: 'audit:logged',
|
AUDIT_LOGGED: 'audit:logged',
|
||||||
|
|
||||||
// Filter events (1)
|
// Rendering events
|
||||||
FILTER_CHANGED: 'filter:changed',
|
|
||||||
|
|
||||||
// Rendering events (1)
|
|
||||||
EVENTS_RENDERED: 'events:rendered'
|
EVENTS_RENDERED: 'events:rendered'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -11,11 +11,15 @@ import { ResizeManager } from '../managers/ResizeManager';
|
||||||
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
|
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
|
||||||
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||||
import { SettingsService } from '../storage/settings/SettingsService';
|
import { SettingsService } from '../storage/settings/SettingsService';
|
||||||
import { ResourceService } from '../storage/resources/ResourceService';
|
|
||||||
import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService';
|
import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService';
|
||||||
import { IWorkweekPreset } from '../types/SettingsTypes';
|
import { IWorkweekPreset } from '../types/SettingsTypes';
|
||||||
import { IEventBus } from '../types/CalendarTypes';
|
import { IEventBus } from '../types/CalendarTypes';
|
||||||
import { CalendarEvents } from './CalendarEvents';
|
import {
|
||||||
|
CalendarEvents,
|
||||||
|
RenderPayload,
|
||||||
|
WorkweekChangePayload,
|
||||||
|
ViewUpdatePayload
|
||||||
|
} from './CalendarEvents';
|
||||||
|
|
||||||
export class CalendarApp {
|
export class CalendarApp {
|
||||||
private animator!: NavigationAnimator;
|
private animator!: NavigationAnimator;
|
||||||
|
|
@ -37,7 +41,6 @@ export class CalendarApp {
|
||||||
private headerDrawerRenderer: HeaderDrawerRenderer,
|
private headerDrawerRenderer: HeaderDrawerRenderer,
|
||||||
private eventPersistenceManager: EventPersistenceManager,
|
private eventPersistenceManager: EventPersistenceManager,
|
||||||
private settingsService: SettingsService,
|
private settingsService: SettingsService,
|
||||||
private resourceService: ResourceService,
|
|
||||||
private viewConfigService: ViewConfigService,
|
private viewConfigService: ViewConfigService,
|
||||||
private eventBus: IEventBus
|
private eventBus: IEventBus
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -45,7 +48,12 @@ export class CalendarApp {
|
||||||
async init(container: HTMLElement): Promise<void> {
|
async init(container: HTMLElement): Promise<void> {
|
||||||
this.container = container;
|
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();
|
this.workweekPreset = await this.settingsService.getDefaultWorkweekPreset();
|
||||||
|
|
||||||
// Create NavigationAnimator with DOM elements
|
// Create NavigationAnimator with DOM elements
|
||||||
|
|
@ -54,11 +62,11 @@ export class CalendarApp {
|
||||||
container.querySelector('swp-content-track') as HTMLElement
|
container.querySelector('swp-content-track') as HTMLElement
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render time axis (from settings later, hardcoded for now)
|
// Render time axis from settings
|
||||||
this.timeAxisRenderer.render(
|
this.timeAxisRenderer.render(
|
||||||
container.querySelector('#time-axis') as HTMLElement,
|
container.querySelector('#time-axis') as HTMLElement,
|
||||||
6,
|
gridSettings.dayStartHour,
|
||||||
18
|
gridSettings.dayEndHour
|
||||||
);
|
);
|
||||||
|
|
||||||
// Init managers
|
// Init managers
|
||||||
|
|
@ -93,22 +101,22 @@ export class CalendarApp {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render command via EventBus
|
// Render command via EventBus
|
||||||
this.eventBus.on(CalendarEvents.CMD_RENDER, ((e: CustomEvent) => {
|
this.eventBus.on(CalendarEvents.CMD_RENDER, (e: Event) => {
|
||||||
const { viewId } = e.detail;
|
const { viewId } = (e as CustomEvent<RenderPayload>).detail;
|
||||||
this.handleRenderCommand(viewId);
|
this.handleRenderCommand(viewId);
|
||||||
}) as EventListener);
|
});
|
||||||
|
|
||||||
// Workweek change via EventBus
|
// Workweek change via EventBus
|
||||||
this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, ((e: CustomEvent) => {
|
this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, (e: Event) => {
|
||||||
const { presetId } = e.detail;
|
const { presetId } = (e as CustomEvent<WorkweekChangePayload>).detail;
|
||||||
this.handleWorkweekChange(presetId);
|
this.handleWorkweekChange(presetId);
|
||||||
}) as EventListener);
|
});
|
||||||
|
|
||||||
// View update via EventBus
|
// View update via EventBus
|
||||||
this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, ((e: CustomEvent) => {
|
this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, (e: Event) => {
|
||||||
const { type, values } = e.detail;
|
const { type, values } = (e as CustomEvent<ViewUpdatePayload>).detail;
|
||||||
this.handleViewUpdate(type, values);
|
this.handleViewUpdate(type, values);
|
||||||
}) as EventListener);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRenderCommand(viewId: string): Promise<void> {
|
private async handleRenderCommand(viewId: string): Promise<void> {
|
||||||
|
|
@ -10,3 +10,19 @@ export const CalendarEvents = {
|
||||||
CMD_WORKWEEK_CHANGE: 'calendar:cmd:workweek:change',
|
CMD_WORKWEEK_CHANGE: 'calendar:cmd:workweek:change',
|
||||||
CMD_VIEW_UPDATE: 'calendar:cmd:view:update'
|
CMD_VIEW_UPDATE: 'calendar:cmd:view:update'
|
||||||
} as const;
|
} 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';
|
import { IEventLogEntry, IListenerEntry, IEventBus } from '../types/CalendarTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -89,7 +88,7 @@ export class EventBus implements IEventBus {
|
||||||
/**
|
/**
|
||||||
* Log event with console grouping
|
* 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')
|
// Extract category from event type (e.g., 'calendar:datechanged' → 'calendar')
|
||||||
const category = this.extractCategory(eventType);
|
const category = this.extractCategory(eventType);
|
||||||
|
|
||||||
|
|
@ -98,10 +97,8 @@ export class EventBus implements IEventBus {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get category emoji and color
|
// Get category emoji and color (used for future console styling)
|
||||||
const { emoji, color } = this.getCategoryStyle(category);
|
this.getCategoryStyle(category);
|
||||||
|
|
||||||
// Use collapsed group to reduce visual noise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -132,12 +129,12 @@ export class EventBus implements IEventBus {
|
||||||
*/
|
*/
|
||||||
private getCategoryStyle(category: string): { emoji: string; color: string } {
|
private getCategoryStyle(category: string): { emoji: string; color: string } {
|
||||||
const styles: { [key: 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' },
|
grid: { emoji: '📊', color: '#4CAF50' },
|
||||||
event: { emoji: '📅', color: '#FF9800' },
|
event: { emoji: '📌', color: '#FF9800' },
|
||||||
scroll: { emoji: '📜', color: '#9C27B0' },
|
scroll: { emoji: '📜', color: '#9C27B0' },
|
||||||
navigation: { emoji: '🧭', color: '#F44336' },
|
navigation: { emoji: '🧭', color: '#F44336' },
|
||||||
view: { emoji: '👁️', color: '#00BCD4' },
|
view: { emoji: '👁', color: '#00BCD4' },
|
||||||
default: { emoji: '📢', color: '#607D8B' }
|
default: { emoji: '📢', color: '#607D8B' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -175,6 +172,3 @@ export class EventBus implements IEventBus {
|
||||||
this.debug = enabled;
|
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:
|
* Supports two layout modes:
|
||||||
* - GRID: Events starting at same time rendered side-by-side
|
* - GRID: Events starting at same time rendered side-by-side
|
||||||
* - STACKING: Overlapping events with margin-left offset (15px per level)
|
* - 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';
|
import { ICalendarEvent } from '../../types/CalendarTypes';
|
||||||
297
src/index.ts
297
src/index.ts
|
|
@ -1,284 +1,17 @@
|
||||||
// Main entry point for Calendar Plantempus
|
// Core exports
|
||||||
import { Container } from '@novadi/core';
|
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
|
||||||
import { eventBus } from './core/EventBus';
|
export { IRenderer as Renderer, IRenderContext as RenderContext } from './core/IGroupingRenderer';
|
||||||
import { ConfigManager } from './configurations/ConfigManager';
|
export { IGroupingStore } from './core/IGroupingStore';
|
||||||
import { Configuration } from './configurations/CalendarConfig';
|
export { CalendarOrchestrator } from './core/CalendarOrchestrator';
|
||||||
import { URLManager } from './utils/URLManager';
|
export { NavigationAnimator } from './core/NavigationAnimator';
|
||||||
import { ICalendarEvent, IEventBus } from './types/CalendarTypes';
|
export { buildPipeline, Pipeline } from './core/RenderBuilder';
|
||||||
|
|
||||||
// Import all managers
|
// Feature exports
|
||||||
import { EventManager } from './managers/EventManager';
|
export { DateRenderer } from './features/date';
|
||||||
import { EventRenderingService } from './renderers/EventRendererManager';
|
export { DateService } from './core/DateService';
|
||||||
import { GridManager } from './managers/GridManager';
|
export { ITimeFormatConfig } from './core/ITimeFormatConfig';
|
||||||
import { ScrollManager } from './managers/ScrollManager';
|
export { EventRenderer } from './features/event';
|
||||||
import { NavigationManager } from './managers/NavigationManager';
|
export { ResourceRenderer } from './features/resource';
|
||||||
import { NavigationButtons } from './components/NavigationButtons';
|
export { TeamRenderer } from './features/team';
|
||||||
import { ViewSelector } from './components/ViewSelector';
|
export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
* EdgeScrollManager - Auto-scroll when dragging near viewport edges
|
||||||
* Uses time-based scrolling with 2-zone system for variable speed
|
*
|
||||||
|
* 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 { IEventBus } from '../types/CalendarTypes';
|
||||||
import { IDragMoveEventPayload, IDragStartEventPayload } from '../types/EventTypes';
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
|
|
||||||
export class EdgeScrollManager {
|
export class EdgeScrollManager {
|
||||||
private scrollableContent: HTMLElement | null = null;
|
private scrollableContent: HTMLElement | null = null;
|
||||||
private timeGrid: HTMLElement | null = null;
|
private timeGrid: HTMLElement | null = null;
|
||||||
private draggedClone: HTMLElement | null = null;
|
private draggedElement: HTMLElement | null = null;
|
||||||
private scrollRAF: number | null = null;
|
private scrollRAF: number | null = null;
|
||||||
private mouseY = 0;
|
private mouseY = 0;
|
||||||
private isDragging = false;
|
private isDragging = false;
|
||||||
private isScrolling = false; // Track if edge-scroll is active
|
private isScrolling = false;
|
||||||
private lastTs = 0;
|
private lastTs = 0;
|
||||||
private rect: DOMRect | null = null;
|
private rect: DOMRect | null = null;
|
||||||
private initialScrollTop = 0;
|
private initialScrollTop = 0;
|
||||||
private scrollListener: ((e: Event) => void) | null = null;
|
|
||||||
|
|
||||||
// Constants - fixed values as per requirements
|
private readonly OUTER_ZONE = 100;
|
||||||
private readonly OUTER_ZONE = 100; // px from edge (slow zone)
|
private readonly INNER_ZONE = 50;
|
||||||
private readonly INNER_ZONE = 50; // px from edge (fast zone)
|
private readonly SLOW_SPEED = 140;
|
||||||
private readonly SLOW_SPEED_PXS = 140; // px/sec in outer zone
|
private readonly FAST_SPEED = 640;
|
||||||
private readonly FAST_SPEED_PXS = 640; // px/sec in inner zone
|
|
||||||
|
|
||||||
constructor(private eventBus: IEventBus) {
|
constructor(private eventBus: IEventBus) {
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
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) => {
|
|
||||||
if (this.isDragging) {
|
|
||||||
this.mouseY = e.clientY;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.subscribeToEvents();
|
this.subscribeToEvents();
|
||||||
|
document.addEventListener('pointermove', this.trackMouse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(scrollableContent: HTMLElement): void {
|
||||||
|
this.scrollableContent = scrollableContent;
|
||||||
|
this.timeGrid = scrollableContent.querySelector('swp-time-grid');
|
||||||
|
this.scrollableContent.style.scrollBehavior = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackMouse = (e: PointerEvent): void => {
|
||||||
|
if (this.isDragging) {
|
||||||
|
this.mouseY = e.clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private subscribeToEvents(): void {
|
private subscribeToEvents(): void {
|
||||||
|
this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event: Event) => {
|
||||||
// Listen to drag events from DragDropManager
|
|
||||||
this.eventBus.on('drag:start', (event: Event) => {
|
|
||||||
const payload = (event as CustomEvent).detail;
|
const payload = (event as CustomEvent).detail;
|
||||||
this.draggedClone = payload.draggedClone;
|
this.draggedElement = payload.element;
|
||||||
this.startDrag();
|
this.startDrag();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventBus.on('drag:end', () => this.stopDrag());
|
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => this.stopDrag());
|
||||||
this.eventBus.on('drag:cancelled', () => this.stopDrag());
|
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => 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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startDrag(): void {
|
private startDrag(): void {
|
||||||
console.log('🎬 EdgeScrollManager: Starting drag');
|
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
this.isScrolling = false; // Reset scroll state
|
this.isScrolling = false;
|
||||||
this.lastTs = performance.now();
|
this.lastTs = 0;
|
||||||
|
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
|
||||||
// Save initial scroll position
|
|
||||||
if (this.scrollableContent) {
|
|
||||||
this.initialScrollTop = this.scrollableContent.scrollTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.scrollRAF === null) {
|
if (this.scrollRAF === null) {
|
||||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
this.scrollRAF = requestAnimationFrame(this.scrollTick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopDrag(): void {
|
private stopDrag(): void {
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
|
this.setScrollingState(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', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.scrollRAF !== null) {
|
if (this.scrollRAF !== null) {
|
||||||
cancelAnimationFrame(this.scrollRAF);
|
cancelAnimationFrame(this.scrollRAF);
|
||||||
this.scrollRAF = null;
|
this.scrollRAF = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rect = null;
|
this.rect = null;
|
||||||
this.lastTs = 0;
|
this.lastTs = 0;
|
||||||
this.initialScrollTop = 0;
|
this.initialScrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleScroll(): void {
|
private calculateVelocity(): number {
|
||||||
|
if (!this.rect) return 0;
|
||||||
|
|
||||||
|
const distTop = this.mouseY - this.rect.top;
|
||||||
|
const distBot = this.rect.bottom - this.mouseY;
|
||||||
|
|
||||||
|
if (distTop < this.INNER_ZONE) return -this.FAST_SPEED;
|
||||||
|
if (distTop < this.OUTER_ZONE) return -this.SLOW_SPEED;
|
||||||
|
if (distBot < this.INNER_ZONE) return this.FAST_SPEED;
|
||||||
|
if (distBot < this.OUTER_ZONE) return this.SLOW_SPEED;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAtBoundary(velocity: number): boolean {
|
||||||
|
if (!this.scrollableContent || !this.timeGrid || !this.draggedElement) return false;
|
||||||
|
|
||||||
|
const atTop = this.scrollableContent.scrollTop <= 0 && velocity < 0;
|
||||||
|
const atBottom = velocity > 0 &&
|
||||||
|
this.draggedElement.getBoundingClientRect().bottom >=
|
||||||
|
this.timeGrid.getBoundingClientRect().bottom;
|
||||||
|
|
||||||
|
return atTop || atBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setScrollingState(scrolling: boolean): void {
|
||||||
|
if (this.isScrolling === scrolling) return;
|
||||||
|
|
||||||
|
this.isScrolling = scrolling;
|
||||||
|
if (scrolling) {
|
||||||
|
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STARTED, {});
|
||||||
|
} else {
|
||||||
|
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
|
||||||
|
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollTick = (ts: number): void => {
|
||||||
if (!this.isDragging || !this.scrollableContent) return;
|
if (!this.isDragging || !this.scrollableContent) return;
|
||||||
|
|
||||||
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;
|
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
|
||||||
this.lastTs = ts;
|
this.lastTs = ts;
|
||||||
|
this.rect ??= this.scrollableContent.getBoundingClientRect();
|
||||||
|
|
||||||
if (!this.scrollableContent) {
|
const velocity = this.calculateVelocity();
|
||||||
this.stopDrag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache rect for performance (only measure once per frame)
|
if (velocity !== 0 && !this.isAtBoundary(velocity)) {
|
||||||
if (!this.rect) {
|
const scrollDelta = velocity * dt;
|
||||||
this.rect = this.scrollableContent.getBoundingClientRect();
|
this.scrollableContent.scrollTop += scrollDelta;
|
||||||
}
|
this.rect = null;
|
||||||
|
this.eventBus.emit(CoreEvents.EDGE_SCROLL_TICK, { scrollDelta });
|
||||||
let vy = 0;
|
this.setScrollingState(true);
|
||||||
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 (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;
|
|
||||||
|
|
||||||
// Get dragged element position and height
|
|
||||||
const cloneRect = this.draggedClone.getBoundingClientRect();
|
|
||||||
const cloneBottom = cloneRect.bottom;
|
|
||||||
const timeGridRect = this.timeGrid.getBoundingClientRect();
|
|
||||||
const timeGridBottom = timeGridRect.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', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue RAF loop to detect when mouse moves away from boundary
|
|
||||||
if (this.isDragging) {
|
|
||||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
|
||||||
}
|
|
||||||
} 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));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Mouse moved away from edge - stop scrolling
|
this.setScrollingState(false);
|
||||||
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));
|
|
||||||
} else {
|
|
||||||
this.stopDrag();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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
|
* IApiRepository<T> - Generic interface for backend API communication
|
||||||
*
|
*
|
||||||
* All entity-specific API repositories (Event, Booking, Customer, Resource)
|
* Used by DataSeeder to fetch initial data and by SyncManager for sync operations.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
export interface IApiRepository<T> {
|
export interface IApiRepository<T> {
|
||||||
/**
|
/**
|
||||||
* Entity type discriminator - used for runtime routing
|
* Entity type discriminator - used for runtime routing
|
||||||
* Must match EntityType values ('Event', 'Booking', 'Customer', 'Resource')
|
|
||||||
*/
|
*/
|
||||||
readonly entityType: EntityType;
|
readonly entityType: EntityType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send create operation to backend API
|
* 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>;
|
sendCreate(data: T): Promise<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send update operation to backend API
|
* 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>;
|
sendUpdate(id: string, updates: Partial<T>): Promise<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send delete operation to backend API
|
* 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>;
|
sendDelete(id: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all entities from backend API
|
* 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[]>;
|
fetchAll(): Promise<T[]>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export class MockAuditRepository implements IApiRepository<IAuditEntry> {
|
||||||
return entity;
|
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
|
// Audit entries are immutable - updates should not happen
|
||||||
throw new Error('Audit entries cannot be updated');
|
throw new Error('Audit entries cannot be updated');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { IBooking, IBookingService, BookingStatus } from '../types/BookingTypes';
|
import { IBooking, IBookingService, BookingStatus, EntityType } from '../types/CalendarTypes';
|
||||||
import { EntityType } from '../types/CalendarTypes';
|
|
||||||
import { IApiRepository } from './IApiRepository';
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
interface RawBookingData {
|
interface RawBookingData {
|
||||||
|
|
@ -25,22 +24,11 @@ interface RawBookingService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MockBookingRepository - Loads booking data from local JSON file
|
* 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> {
|
export class MockBookingRepository implements IApiRepository<IBooking> {
|
||||||
public readonly entityType: EntityType = 'Booking';
|
public readonly entityType: EntityType = 'Booking';
|
||||||
private readonly dataUrl = 'data/mock-bookings.json';
|
private readonly dataUrl = 'data/mock-bookings.json';
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all bookings from mock JSON file
|
|
||||||
*/
|
|
||||||
public async fetchAll(): Promise<IBooking[]> {
|
public async fetchAll(): Promise<IBooking[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.dataUrl);
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
@ -50,7 +38,6 @@ export class MockBookingRepository implements IApiRepository<IBooking> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData: RawBookingData[] = await response.json();
|
const rawData: RawBookingData[] = await response.json();
|
||||||
|
|
||||||
return this.processBookingData(rawData);
|
return this.processBookingData(rawData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load booking data:', error);
|
console.error('Failed to load booking data:', error);
|
||||||
|
|
@ -58,32 +45,28 @@ export class MockBookingRepository implements IApiRepository<IBooking> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendCreate(_booking: IBooking): Promise<IBooking> {
|
||||||
* NOT SUPPORTED - MockBookingRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendCreate(booking: IBooking): Promise<IBooking> {
|
|
||||||
throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.');
|
throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendUpdate(_id: string, _updates: Partial<IBooking>): Promise<IBooking> {
|
||||||
* NOT SUPPORTED - MockBookingRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendUpdate(id: string, updates: Partial<IBooking>): Promise<IBooking> {
|
|
||||||
throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.');
|
throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
* NOT SUPPORTED - MockBookingRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendDelete(id: string): Promise<void> {
|
|
||||||
throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.');
|
throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private processBookingData(data: RawBookingData[]): IBooking[] {
|
private processBookingData(data: RawBookingData[]): IBooking[] {
|
||||||
return data.map((booking): IBooking => ({
|
return data.map((booking): IBooking => ({
|
||||||
...booking,
|
id: booking.id,
|
||||||
createdAt: new Date(booking.createdAt),
|
customerId: booking.customerId,
|
||||||
status: booking.status as BookingStatus,
|
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
|
syncStatus: 'synced' as const
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { ICustomer } from '../types/CustomerTypes';
|
import { ICustomer, EntityType } from '../types/CalendarTypes';
|
||||||
import { EntityType } from '../types/CalendarTypes';
|
|
||||||
import { IApiRepository } from './IApiRepository';
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
interface RawCustomerData {
|
interface RawCustomerData {
|
||||||
|
|
@ -7,28 +6,17 @@ interface RawCustomerData {
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MockCustomerRepository - Loads customer data from local JSON file
|
* 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> {
|
export class MockCustomerRepository implements IApiRepository<ICustomer> {
|
||||||
public readonly entityType: EntityType = 'Customer';
|
public readonly entityType: EntityType = 'Customer';
|
||||||
private readonly dataUrl = 'data/mock-customers.json';
|
private readonly dataUrl = 'data/mock-customers.json';
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all customers from mock JSON file
|
|
||||||
*/
|
|
||||||
public async fetchAll(): Promise<ICustomer[]> {
|
public async fetchAll(): Promise<ICustomer[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.dataUrl);
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
@ -38,7 +26,6 @@ export class MockCustomerRepository implements IApiRepository<ICustomer> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData: RawCustomerData[] = await response.json();
|
const rawData: RawCustomerData[] = await response.json();
|
||||||
|
|
||||||
return this.processCustomerData(rawData);
|
return this.processCustomerData(rawData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load customer data:', error);
|
console.error('Failed to load customer data:', error);
|
||||||
|
|
@ -46,30 +33,25 @@ export class MockCustomerRepository implements IApiRepository<ICustomer> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendCreate(_customer: ICustomer): Promise<ICustomer> {
|
||||||
* NOT SUPPORTED - MockCustomerRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendCreate(customer: ICustomer): Promise<ICustomer> {
|
|
||||||
throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.');
|
throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendUpdate(_id: string, _updates: Partial<ICustomer>): Promise<ICustomer> {
|
||||||
* NOT SUPPORTED - MockCustomerRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendUpdate(id: string, updates: Partial<ICustomer>): Promise<ICustomer> {
|
|
||||||
throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.');
|
throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
* NOT SUPPORTED - MockCustomerRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendDelete(id: string): Promise<void> {
|
|
||||||
throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
|
throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private processCustomerData(data: RawCustomerData[]): ICustomer[] {
|
private processCustomerData(data: RawCustomerData[]): ICustomer[] {
|
||||||
return data.map((customer): 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
|
syncStatus: 'synced' as const
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,26 @@
|
||||||
import { ICalendarEvent, EntityType } from '../types/CalendarTypes';
|
import { ICalendarEvent, EntityType, CalendarEventType } from '../types/CalendarTypes';
|
||||||
import { CalendarEventType } from '../types/BookingTypes';
|
|
||||||
import { IApiRepository } from './IApiRepository';
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
|
||||||
interface RawEventData {
|
interface RawEventData {
|
||||||
// Core fields (required)
|
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
start: string | Date;
|
start: string | Date;
|
||||||
end: string | Date;
|
end: string | Date;
|
||||||
type: string;
|
type: string;
|
||||||
allDay?: boolean;
|
allDay?: boolean;
|
||||||
|
bookingId?: string;
|
||||||
// Denormalized references (CRITICAL for booking architecture)
|
resourceId?: string;
|
||||||
bookingId?: string; // Reference to booking (customer events only)
|
customerId?: string;
|
||||||
resourceId?: string; // Which resource owns this slot
|
description?: string;
|
||||||
customerId?: string; // Customer reference (denormalized from booking)
|
recurringId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
// 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
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MockEventRepository - Loads event data from local JSON file
|
* 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. Only fetchAll() is implemented.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
||||||
public readonly entityType: EntityType = 'Event';
|
public readonly entityType: EntityType = 'Event';
|
||||||
|
|
@ -53,7 +38,6 @@ export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData: RawEventData[] = await response.json();
|
const rawData: RawEventData[] = await response.json();
|
||||||
|
|
||||||
return this.processCalendarData(rawData);
|
return this.processCalendarData(rawData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load event data:', error);
|
console.error('Failed to load event data:', error);
|
||||||
|
|
@ -61,40 +45,25 @@ export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendCreate(_event: ICalendarEvent): Promise<ICalendarEvent> {
|
||||||
* NOT SUPPORTED - MockEventRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendCreate(event: ICalendarEvent): Promise<ICalendarEvent> {
|
|
||||||
throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.');
|
throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendUpdate(_id: string, _updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
|
||||||
* NOT SUPPORTED - MockEventRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendUpdate(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
|
|
||||||
throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.');
|
throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
* NOT SUPPORTED - MockEventRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendDelete(id: string): Promise<void> {
|
|
||||||
throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.');
|
throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
|
||||||
return data.map((event): ICalendarEvent => {
|
return data.map((event): ICalendarEvent => {
|
||||||
// Validate event type constraints
|
// Validate customer event constraints
|
||||||
if (event.type === 'customer') {
|
if (event.type === 'customer') {
|
||||||
if (!event.bookingId) {
|
if (!event.bookingId) console.warn(`Customer event ${event.id} missing 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.resourceId) {
|
|
||||||
console.warn(`Customer event ${event.id} missing resourceId`);
|
|
||||||
}
|
|
||||||
if (!event.customerId) {
|
|
||||||
console.warn(`Customer event ${event.id} missing customerId`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -105,16 +74,11 @@ export class MockEventRepository implements IApiRepository<ICalendarEvent> {
|
||||||
end: new Date(event.end),
|
end: new Date(event.end),
|
||||||
type: event.type as CalendarEventType,
|
type: event.type as CalendarEventType,
|
||||||
allDay: event.allDay || false,
|
allDay: event.allDay || false,
|
||||||
|
|
||||||
// Denormalized references (CRITICAL for booking architecture)
|
|
||||||
bookingId: event.bookingId,
|
bookingId: event.bookingId,
|
||||||
resourceId: event.resourceId,
|
resourceId: event.resourceId,
|
||||||
customerId: event.customerId,
|
customerId: event.customerId,
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
recurringId: event.recurringId,
|
recurringId: event.recurringId,
|
||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
|
|
||||||
syncStatus: 'synced' as const
|
syncStatus: 'synced' as const
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { IResource, ResourceType } from '../types/ResourceTypes';
|
import { IResource, ResourceType, EntityType } from '../types/CalendarTypes';
|
||||||
import { EntityType } from '../types/CalendarTypes';
|
|
||||||
import { IApiRepository } from './IApiRepository';
|
import { IApiRepository } from './IApiRepository';
|
||||||
|
import { IWeekSchedule } from '../types/ScheduleTypes';
|
||||||
|
|
||||||
interface RawResourceData {
|
interface RawResourceData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -10,28 +10,17 @@ interface RawResourceData {
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
metadata?: Record<string, any>;
|
defaultSchedule?: IWeekSchedule;
|
||||||
[key: string]: unknown;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MockResourceRepository - Loads resource data from local JSON file
|
* 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> {
|
export class MockResourceRepository implements IApiRepository<IResource> {
|
||||||
public readonly entityType: EntityType = 'Resource';
|
public readonly entityType: EntityType = 'Resource';
|
||||||
private readonly dataUrl = 'data/mock-resources.json';
|
private readonly dataUrl = 'data/mock-resources.json';
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all resources from mock JSON file
|
|
||||||
*/
|
|
||||||
public async fetchAll(): Promise<IResource[]> {
|
public async fetchAll(): Promise<IResource[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.dataUrl);
|
const response = await fetch(this.dataUrl);
|
||||||
|
|
@ -41,7 +30,6 @@ export class MockResourceRepository implements IApiRepository<IResource> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData: RawResourceData[] = await response.json();
|
const rawData: RawResourceData[] = await response.json();
|
||||||
|
|
||||||
return this.processResourceData(rawData);
|
return this.processResourceData(rawData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load resource data:', error);
|
console.error('Failed to load resource data:', error);
|
||||||
|
|
@ -49,31 +37,29 @@ export class MockResourceRepository implements IApiRepository<IResource> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendCreate(_resource: IResource): Promise<IResource> {
|
||||||
* NOT SUPPORTED - MockResourceRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendCreate(resource: IResource): Promise<IResource> {
|
|
||||||
throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.');
|
throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendUpdate(_id: string, _updates: Partial<IResource>): Promise<IResource> {
|
||||||
* NOT SUPPORTED - MockResourceRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendUpdate(id: string, updates: Partial<IResource>): Promise<IResource> {
|
|
||||||
throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.');
|
throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async sendDelete(_id: string): Promise<void> {
|
||||||
* NOT SUPPORTED - MockResourceRepository is read-only
|
|
||||||
*/
|
|
||||||
public async sendDelete(id: string): Promise<void> {
|
|
||||||
throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
|
throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private processResourceData(data: RawResourceData[]): IResource[] {
|
private processResourceData(data: RawResourceData[]): IResource[] {
|
||||||
return data.map((resource): IResource => ({
|
return data.map((resource): IResource => ({
|
||||||
...resource,
|
id: resource.id,
|
||||||
|
name: resource.name,
|
||||||
|
displayName: resource.displayName,
|
||||||
type: resource.type as ResourceType,
|
type: resource.type as ResourceType,
|
||||||
|
avatarUrl: resource.avatarUrl,
|
||||||
|
color: resource.color,
|
||||||
|
isActive: resource.isActive,
|
||||||
|
defaultSchedule: resource.defaultSchedule,
|
||||||
|
metadata: resource.metadata,
|
||||||
syncStatus: 'synced' as const
|
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