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