456 lines
11 KiB
Markdown
456 lines
11 KiB
Markdown
|
|
# Month View Refactoring Plan
|
||
|
|
**Purpose:** Enable month view with minimal refactoring
|
||
|
|
**Timeline:** 3 days (6 hours of focused work)
|
||
|
|
**Priority:** High - Blocks new feature development
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This plan addresses only the critical architectural issues that prevent month view implementation. By focusing on the minimal necessary changes, we can add month view in ~500 lines instead of ~2000 lines.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Current Blockers for Month View
|
||
|
|
|
||
|
|
### 🚫 Why Month View Can't Be Added Now
|
||
|
|
|
||
|
|
1. **GridManager is hardcoded for time-based views**
|
||
|
|
- Assumes everything is hours and columns
|
||
|
|
- Time axis doesn't make sense for months
|
||
|
|
- Hour-based scrolling irrelevant
|
||
|
|
|
||
|
|
2. **No strategy pattern for different view types**
|
||
|
|
- Would need entirely new managers
|
||
|
|
- Massive code duplication
|
||
|
|
- Inconsistent behavior
|
||
|
|
|
||
|
|
3. **Config assumes time-based views**
|
||
|
|
```typescript
|
||
|
|
hourHeight: 60,
|
||
|
|
dayStartHour: 0,
|
||
|
|
snapInterval: 15
|
||
|
|
// These are meaningless for month view!
|
||
|
|
```
|
||
|
|
|
||
|
|
4. **Event rendering tied to time positions**
|
||
|
|
- Events positioned by minutes
|
||
|
|
- No concept of day cells
|
||
|
|
- Can't handle multi-day spans properly
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 1: View Strategy Pattern (2 hours)
|
||
|
|
|
||
|
|
### 1.1 Create ViewStrategy Interface
|
||
|
|
**New file:** `src/strategies/ViewStrategy.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export interface ViewStrategy {
|
||
|
|
// Core rendering methods
|
||
|
|
renderGrid(container: HTMLElement, context: ViewContext): void;
|
||
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement): void;
|
||
|
|
|
||
|
|
// Configuration
|
||
|
|
getLayoutConfig(): ViewLayoutConfig;
|
||
|
|
getRequiredConfig(): string[]; // Which config keys this view needs
|
||
|
|
|
||
|
|
// Navigation
|
||
|
|
getNextPeriod(currentDate: Date): Date;
|
||
|
|
getPreviousPeriod(currentDate: Date): Date;
|
||
|
|
getPeriodLabel(date: Date): string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ViewContext {
|
||
|
|
currentDate: Date;
|
||
|
|
config: CalendarConfig;
|
||
|
|
events: CalendarEvent[];
|
||
|
|
container: HTMLElement;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 1.2 Extract WeekViewStrategy
|
||
|
|
**New file:** `src/strategies/WeekViewStrategy.ts`
|
||
|
|
|
||
|
|
- Move existing logic from GridRenderer
|
||
|
|
- Keep all time-based rendering
|
||
|
|
- Minimal changes to existing code
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class WeekViewStrategy implements ViewStrategy {
|
||
|
|
renderGrid(container: HTMLElement, context: ViewContext): void {
|
||
|
|
// Move existing GridRenderer.renderGrid() here
|
||
|
|
this.createTimeAxis(container);
|
||
|
|
this.createDayColumns(container, context);
|
||
|
|
this.createTimeSlots(container);
|
||
|
|
}
|
||
|
|
|
||
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement): void {
|
||
|
|
// Move existing EventRenderer logic
|
||
|
|
// Position by time as before
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 1.3 Create MonthViewStrategy
|
||
|
|
**New file:** `src/strategies/MonthViewStrategy.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class MonthViewStrategy implements ViewStrategy {
|
||
|
|
renderGrid(container: HTMLElement, context: ViewContext): void {
|
||
|
|
// Create 7x6 grid
|
||
|
|
this.createMonthHeader(container); // Mon-Sun
|
||
|
|
this.createWeekRows(container, context);
|
||
|
|
}
|
||
|
|
|
||
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement): void {
|
||
|
|
// Render as small blocks in day cells
|
||
|
|
// Handle multi-day spanning
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 1.4 Update GridManager
|
||
|
|
**Modify:** `src/managers/GridManager.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class GridManager {
|
||
|
|
private strategy: ViewStrategy;
|
||
|
|
|
||
|
|
setViewStrategy(strategy: ViewStrategy): void {
|
||
|
|
this.strategy = strategy;
|
||
|
|
}
|
||
|
|
|
||
|
|
render(): void {
|
||
|
|
// Delegate to strategy
|
||
|
|
this.strategy.renderGrid(this.grid, {
|
||
|
|
currentDate: this.currentWeek,
|
||
|
|
config: this.config,
|
||
|
|
events: this.events,
|
||
|
|
container: this.grid
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 2: Configuration Split (1 hour)
|
||
|
|
|
||
|
|
### 2.1 View-Specific Configs
|
||
|
|
**New file:** `src/core/ViewConfigs.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Shared by all views
|
||
|
|
export interface BaseViewConfig {
|
||
|
|
locale: string;
|
||
|
|
firstDayOfWeek: number;
|
||
|
|
dateFormat: string;
|
||
|
|
eventColors: Record<string, string>;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Week/Day views only
|
||
|
|
export interface TimeViewConfig extends BaseViewConfig {
|
||
|
|
hourHeight: number;
|
||
|
|
dayStartHour: number;
|
||
|
|
dayEndHour: number;
|
||
|
|
snapInterval: number;
|
||
|
|
showCurrentTime: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Month view only
|
||
|
|
export interface MonthViewConfig extends BaseViewConfig {
|
||
|
|
weeksToShow: number; // Usually 6
|
||
|
|
showWeekNumbers: boolean;
|
||
|
|
compactMode: boolean;
|
||
|
|
eventLimit: number; // Max events shown per day
|
||
|
|
showMoreText: string; // "+2 more"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.2 Update CalendarConfig
|
||
|
|
**Modify:** `src/core/CalendarConfig.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class CalendarConfig {
|
||
|
|
private viewConfigs: Map<string, BaseViewConfig> = new Map();
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
// Set defaults for each view
|
||
|
|
this.viewConfigs.set('week', defaultWeekConfig);
|
||
|
|
this.viewConfigs.set('month', defaultMonthConfig);
|
||
|
|
}
|
||
|
|
|
||
|
|
getViewConfig<T extends BaseViewConfig>(view: string): T {
|
||
|
|
return this.viewConfigs.get(view) as T;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 3: Event Consolidation (1 hour)
|
||
|
|
|
||
|
|
### 3.1 Core Events Only
|
||
|
|
**New file:** `src/constants/CoreEvents.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export const CoreEvents = {
|
||
|
|
// View lifecycle (5 events)
|
||
|
|
VIEW_CHANGED: 'view:changed',
|
||
|
|
VIEW_RENDERED: 'view:rendered',
|
||
|
|
|
||
|
|
// Navigation (3 events)
|
||
|
|
DATE_CHANGED: 'date:changed',
|
||
|
|
PERIOD_CHANGED: 'period:changed',
|
||
|
|
|
||
|
|
// Data (4 events)
|
||
|
|
EVENTS_LOADING: 'events:loading',
|
||
|
|
EVENTS_LOADED: 'events:loaded',
|
||
|
|
EVENT_CLICKED: 'event:clicked',
|
||
|
|
EVENT_UPDATED: 'event:updated',
|
||
|
|
|
||
|
|
// UI State (3 events)
|
||
|
|
LOADING_START: 'ui:loading:start',
|
||
|
|
LOADING_END: 'ui:loading:end',
|
||
|
|
ERROR: 'ui:error',
|
||
|
|
|
||
|
|
// Grid (3 events)
|
||
|
|
GRID_RENDERED: 'grid:rendered',
|
||
|
|
GRID_CLICKED: 'grid:clicked',
|
||
|
|
CELL_CLICKED: 'cell:clicked'
|
||
|
|
};
|
||
|
|
// Total: ~18 events instead of 102!
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.2 Migration Map
|
||
|
|
**Modify:** `src/constants/EventTypes.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Keep old events but map to new ones
|
||
|
|
export const EventTypes = {
|
||
|
|
VIEW_CHANGED: CoreEvents.VIEW_CHANGED, // Direct mapping
|
||
|
|
WEEK_CHANGED: CoreEvents.PERIOD_CHANGED, // Renamed
|
||
|
|
// ... etc
|
||
|
|
} as const;
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 4: Month-Specific Renderers (2 hours)
|
||
|
|
|
||
|
|
### 4.1 MonthGridRenderer
|
||
|
|
**New file:** `src/renderers/MonthGridRenderer.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class MonthGridRenderer {
|
||
|
|
render(container: HTMLElement, date: Date): void {
|
||
|
|
const grid = this.createGrid();
|
||
|
|
|
||
|
|
// Add day headers
|
||
|
|
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(day => {
|
||
|
|
grid.appendChild(this.createDayHeader(day));
|
||
|
|
});
|
||
|
|
|
||
|
|
// Add 6 weeks of days
|
||
|
|
const dates = this.getMonthDates(date);
|
||
|
|
dates.forEach(weekDates => {
|
||
|
|
weekDates.forEach(date => {
|
||
|
|
grid.appendChild(this.createDayCell(date));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
container.appendChild(grid);
|
||
|
|
}
|
||
|
|
|
||
|
|
private createGrid(): HTMLElement {
|
||
|
|
const grid = document.createElement('div');
|
||
|
|
grid.className = 'month-grid';
|
||
|
|
grid.style.display = 'grid';
|
||
|
|
grid.style.gridTemplateColumns = 'repeat(7, 1fr)';
|
||
|
|
return grid;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.2 MonthEventRenderer
|
||
|
|
**New file:** `src/renderers/MonthEventRenderer.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class MonthEventRenderer {
|
||
|
|
render(events: CalendarEvent[], container: HTMLElement): void {
|
||
|
|
const dayMap = this.groupEventsByDay(events);
|
||
|
|
|
||
|
|
dayMap.forEach((dayEvents, dateStr) => {
|
||
|
|
const dayCell = container.querySelector(`[data-date="${dateStr}"]`);
|
||
|
|
if (!dayCell) return;
|
||
|
|
|
||
|
|
const limited = dayEvents.slice(0, 3); // Show max 3
|
||
|
|
limited.forEach(event => {
|
||
|
|
dayCell.appendChild(this.createEventBlock(event));
|
||
|
|
});
|
||
|
|
|
||
|
|
if (dayEvents.length > 3) {
|
||
|
|
dayCell.appendChild(this.createMoreIndicator(dayEvents.length - 3));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 5: Integration (1 hour)
|
||
|
|
|
||
|
|
### 5.1 Wire ViewManager
|
||
|
|
**Modify:** `src/managers/ViewManager.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
private changeView(newView: CalendarView): void {
|
||
|
|
// Create appropriate strategy
|
||
|
|
let strategy: ViewStrategy;
|
||
|
|
|
||
|
|
switch(newView) {
|
||
|
|
case 'week':
|
||
|
|
case 'day':
|
||
|
|
strategy = new WeekViewStrategy();
|
||
|
|
break;
|
||
|
|
case 'month':
|
||
|
|
strategy = new MonthViewStrategy();
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update GridManager
|
||
|
|
this.gridManager.setViewStrategy(strategy);
|
||
|
|
|
||
|
|
// Trigger re-render
|
||
|
|
this.eventBus.emit(CoreEvents.VIEW_CHANGED, { view: newView });
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.2 Enable Month Button
|
||
|
|
**Modify:** `wwwroot/index.html`
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!-- Remove disabled attribute -->
|
||
|
|
<swp-view-button data-view="month">Month</swp-view-button>
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.3 Add Month Styles
|
||
|
|
**New file:** `wwwroot/css/calendar-month-css.css`
|
||
|
|
|
||
|
|
```css
|
||
|
|
.month-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(7, 1fr);
|
||
|
|
gap: 1px;
|
||
|
|
background: var(--color-border);
|
||
|
|
}
|
||
|
|
|
||
|
|
.month-day-cell {
|
||
|
|
background: white;
|
||
|
|
min-height: 100px;
|
||
|
|
padding: 4px;
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
|
||
|
|
.month-day-number {
|
||
|
|
font-weight: bold;
|
||
|
|
margin-bottom: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.month-event {
|
||
|
|
font-size: 0.75rem;
|
||
|
|
padding: 2px 4px;
|
||
|
|
margin: 1px 0;
|
||
|
|
border-radius: 2px;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.month-more-indicator {
|
||
|
|
font-size: 0.7rem;
|
||
|
|
color: var(--color-text-secondary);
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Implementation Timeline
|
||
|
|
|
||
|
|
### Day 1 (Monday)
|
||
|
|
**Morning (2 hours)**
|
||
|
|
- [ ] Implement ViewStrategy interface
|
||
|
|
- [ ] Extract WeekViewStrategy
|
||
|
|
- [ ] Create MonthViewStrategy skeleton
|
||
|
|
|
||
|
|
**Afternoon (1 hour)**
|
||
|
|
- [ ] Split configuration
|
||
|
|
- [ ] Update CalendarConfig
|
||
|
|
|
||
|
|
### Day 2 (Tuesday)
|
||
|
|
**Morning (2 hours)**
|
||
|
|
- [ ] Consolidate events to CoreEvents
|
||
|
|
- [ ] Create migration mappings
|
||
|
|
- [ ] Update critical event listeners
|
||
|
|
|
||
|
|
**Afternoon (2 hours)**
|
||
|
|
- [ ] Implement MonthGridRenderer
|
||
|
|
- [ ] Implement MonthEventRenderer
|
||
|
|
|
||
|
|
### Day 3 (Wednesday)
|
||
|
|
**Morning (2 hours)**
|
||
|
|
- [ ] Wire everything in ViewManager
|
||
|
|
- [ ] Update HTML and CSS
|
||
|
|
- [ ] Test month view
|
||
|
|
- [ ] Fix edge cases
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Success Metrics
|
||
|
|
|
||
|
|
### ✅ Definition of Done
|
||
|
|
- [ ] Month view displays 6 weeks correctly
|
||
|
|
- [ ] Events show in day cells (max 3 + "more")
|
||
|
|
- [ ] Navigation works (prev/next month)
|
||
|
|
- [ ] Switching between week/month works
|
||
|
|
- [ ] No regression in week view
|
||
|
|
- [ ] Under 750 lines of new code
|
||
|
|
|
||
|
|
### 📊 Expected Impact
|
||
|
|
- **New code:** ~500-750 lines (vs 2000 without refactoring)
|
||
|
|
- **Reusability:** 80% of components shared
|
||
|
|
- **Future views:** Day view = 100 lines, Year view = 200 lines
|
||
|
|
- **Test coverage:** Easy to test strategies independently
|
||
|
|
- **Performance:** No impact on existing views
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Risk Mitigation
|
||
|
|
|
||
|
|
### Potential Issues & Solutions
|
||
|
|
|
||
|
|
1. **CSS conflicts between views**
|
||
|
|
- Solution: Namespace all month CSS with `.month-view`
|
||
|
|
|
||
|
|
2. **Event overlap in month cells**
|
||
|
|
- Solution: Implement "more" indicator after 3 events
|
||
|
|
|
||
|
|
3. **Performance with many events**
|
||
|
|
- Solution: Only render visible month
|
||
|
|
|
||
|
|
4. **Browser compatibility**
|
||
|
|
- Solution: Use CSS Grid with flexbox fallback
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Next Steps After Month View
|
||
|
|
|
||
|
|
Once this refactoring is complete, adding new views becomes trivial:
|
||
|
|
|
||
|
|
- **Day View:** ~100 lines (reuse WeekViewStrategy with 1 column)
|
||
|
|
- **Year View:** ~200 lines (12 small month grids)
|
||
|
|
- **Agenda View:** ~150 lines (list layout)
|
||
|
|
- **Timeline View:** ~300 lines (horizontal time axis)
|
||
|
|
|
||
|
|
The strategy pattern makes the calendar truly extensible!
|