Introduces detailed documentation for calendar library, covering: - Core features and architecture - Installation and setup guide - Event system reference - Types and configuration options - Extensibility and utilities Prepares for initial library release with thorough documentation
620 lines
18 KiB
Markdown
620 lines
18 KiB
Markdown
# Calendar
|
|
|
|
Professional TypeScript calendar component with offline-first architecture, drag-and-drop functionality, and real-time synchronization capabilities.
|
|
|
|
## Features
|
|
|
|
- **Multiple View Modes**: Date-based (day/week/month) and resource-based (people, rooms) views
|
|
- **Drag & Drop**: Smooth event dragging with snap-to-grid, cross-column movement, and timed/all-day conversion
|
|
- **Event Resizing**: Intuitive resize handles for adjusting event duration
|
|
- **Offline-First**: IndexedDB storage with automatic background sync
|
|
- **Event-Driven Architecture**: Decoupled components via centralized EventBus
|
|
- **Dependency Injection**: Built on NovaDI for clean, testable architecture
|
|
- **Extensions**: Modular extensions for teams, departments, bookings, customers, schedules, and audit logging
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
npm install calendar
|
|
```
|
|
|
|
## Quick Start (AI-Friendly Setup Guide)
|
|
|
|
### Step 1: Create HTML Structure
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="node_modules/calendar/dist/css/calendar.css">
|
|
</head>
|
|
<body>
|
|
<div class="calendar-wrapper">
|
|
<swp-calendar-container>
|
|
<swp-time-axis>
|
|
<swp-header-spacer></swp-header-spacer>
|
|
<swp-time-axis-content id="time-axis"></swp-time-axis-content>
|
|
</swp-time-axis>
|
|
<swp-grid-container>
|
|
<swp-header-viewport>
|
|
<swp-header-track>
|
|
<swp-calendar-header></swp-calendar-header>
|
|
</swp-header-track>
|
|
<swp-header-drawer></swp-header-drawer>
|
|
</swp-header-viewport>
|
|
<swp-content-viewport>
|
|
<swp-content-track>
|
|
<swp-scrollable-content>
|
|
<swp-time-grid>
|
|
<swp-grid-lines></swp-grid-lines>
|
|
<swp-day-columns></swp-day-columns>
|
|
</swp-time-grid>
|
|
</swp-scrollable-content>
|
|
</swp-content-track>
|
|
</swp-content-viewport>
|
|
</swp-grid-container>
|
|
</swp-calendar-container>
|
|
</div>
|
|
|
|
<script type="module" src="dist/bundle.js"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
### Step 2: Initialize Calendar
|
|
|
|
```typescript
|
|
import { Container } from '@novadi/core';
|
|
import {
|
|
registerCoreServices,
|
|
CalendarApp,
|
|
IndexedDBContext,
|
|
SettingsService,
|
|
ViewConfigService,
|
|
EventService,
|
|
EventBus,
|
|
CalendarEvents
|
|
} from 'calendar';
|
|
|
|
async function init() {
|
|
// 1. Create DI container and register services
|
|
const container = new Container();
|
|
const builder = container.builder();
|
|
registerCoreServices(builder, {
|
|
dbConfig: { dbName: 'MyCalendarDB', dbVersion: 1 }
|
|
});
|
|
const app = builder.build();
|
|
|
|
// 2. Initialize IndexedDB
|
|
const dbContext = app.resolveType<IndexedDBContext>();
|
|
await dbContext.initialize();
|
|
|
|
// 3. Seed required settings (first time only)
|
|
const settingsService = app.resolveType<SettingsService>();
|
|
const viewConfigService = app.resolveType<ViewConfigService>();
|
|
|
|
await settingsService.save({
|
|
id: 'grid',
|
|
dayStartHour: 8,
|
|
dayEndHour: 17,
|
|
workStartHour: 9,
|
|
workEndHour: 16,
|
|
hourHeight: 64,
|
|
snapInterval: 15,
|
|
syncStatus: 'synced'
|
|
});
|
|
|
|
await settingsService.save({
|
|
id: 'workweek',
|
|
presets: {
|
|
standard: { id: 'standard', label: 'Standard', workDays: [1, 2, 3, 4, 5], periodDays: 7 }
|
|
},
|
|
defaultPreset: 'standard',
|
|
firstDayOfWeek: 1,
|
|
syncStatus: 'synced'
|
|
});
|
|
|
|
await viewConfigService.save({
|
|
id: 'simple',
|
|
groupings: [{ type: 'date', values: [], idProperty: 'date', derivedFrom: 'start' }],
|
|
syncStatus: 'synced'
|
|
});
|
|
|
|
// 4. Initialize CalendarApp
|
|
const calendarApp = app.resolveType<CalendarApp>();
|
|
const containerEl = document.querySelector('swp-calendar-container') as HTMLElement;
|
|
await calendarApp.init(containerEl);
|
|
|
|
// 5. Render a view
|
|
const eventBus = app.resolveType<EventBus>();
|
|
eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' });
|
|
}
|
|
|
|
init().catch(console.error);
|
|
```
|
|
|
|
### Step 3: Add Events
|
|
|
|
```typescript
|
|
const eventService = app.resolveType<EventService>();
|
|
|
|
await eventService.save({
|
|
id: crypto.randomUUID(),
|
|
title: 'Meeting',
|
|
start: new Date('2024-01-15T09:00:00'),
|
|
end: new Date('2024-01-15T10:00:00'),
|
|
type: 'meeting',
|
|
allDay: false,
|
|
syncStatus: 'synced'
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
### Core Components
|
|
|
|
| Component | Description |
|
|
|-----------|-------------|
|
|
| `CalendarApp` | Main application entry point |
|
|
| `CalendarOrchestrator` | Coordinates rendering pipeline |
|
|
| `EventBus` | Central event dispatcher for all inter-component communication |
|
|
| `DateService` | Date calculations and formatting |
|
|
| `IndexedDBContext` | Offline storage infrastructure |
|
|
|
|
### Managers
|
|
|
|
| Manager | Description |
|
|
|---------|-------------|
|
|
| `DragDropManager` | Event drag-drop with smooth animations and snap-to-grid |
|
|
| `EdgeScrollManager` | Automatic scrolling at viewport edges during drag |
|
|
| `ResizeManager` | Event resizing with visual feedback |
|
|
| `ScrollManager` | Scroll behavior and position management |
|
|
| `HeaderDrawerManager` | All-day events drawer toggle |
|
|
| `EventPersistenceManager` | Saves drag/resize changes to storage |
|
|
|
|
### Renderers
|
|
|
|
| Renderer | Description |
|
|
|----------|-------------|
|
|
| `DateRenderer` | Renders date-based column groupings |
|
|
| `ResourceRenderer` | Renders resource-based column groupings |
|
|
| `EventRenderer` | Renders timed events in columns |
|
|
| `ScheduleRenderer` | Renders working hours backgrounds |
|
|
| `HeaderDrawerRenderer` | Renders all-day events in header |
|
|
| `TimeAxisRenderer` | Renders time labels on the left axis |
|
|
|
|
### Storage
|
|
|
|
| Service | Description |
|
|
|---------|-------------|
|
|
| `EventService` / `EventStore` | Calendar event CRUD |
|
|
| `ResourceService` / `ResourceStore` | Resource management |
|
|
| `SettingsService` / `SettingsStore` | Tenant settings |
|
|
| `ViewConfigService` / `ViewConfigStore` | View configurations |
|
|
|
|
---
|
|
|
|
## Events Reference
|
|
|
|
### Lifecycle Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `core:initialized` | `CoreEvents.INITIALIZED` | - | Calendar core initialized |
|
|
| `core:ready` | `CoreEvents.READY` | - | Calendar ready for interaction |
|
|
| `core:destroyed` | `CoreEvents.DESTROYED` | - | Calendar destroyed |
|
|
|
|
### View Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `view:changed` | `CoreEvents.VIEW_CHANGED` | `{ viewId: string }` | View type changed |
|
|
| `view:rendered` | `CoreEvents.VIEW_RENDERED` | - | View finished rendering |
|
|
|
|
### Navigation Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `nav:date-changed` | `CoreEvents.DATE_CHANGED` | `{ date: Date }` | Current date changed |
|
|
| `nav:navigation-completed` | `CoreEvents.NAVIGATION_COMPLETED` | - | Navigation animation completed |
|
|
|
|
### Data Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `data:loading` | `CoreEvents.DATA_LOADING` | - | Data loading started |
|
|
| `data:loaded` | `CoreEvents.DATA_LOADED` | - | Data loading completed |
|
|
| `data:error` | `CoreEvents.DATA_ERROR` | `{ error: Error }` | Data loading error |
|
|
|
|
### Grid Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `grid:rendered` | `CoreEvents.GRID_RENDERED` | - | Grid finished rendering |
|
|
| `grid:clicked` | `CoreEvents.GRID_CLICKED` | `{ time: Date, columnKey: string }` | Grid area clicked |
|
|
|
|
### Event Management
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `event:created` | `CoreEvents.EVENT_CREATED` | `ICalendarEvent` | Event created |
|
|
| `event:updated` | `CoreEvents.EVENT_UPDATED` | `IEventUpdatedPayload` | Event updated |
|
|
| `event:deleted` | `CoreEvents.EVENT_DELETED` | `{ eventId: string }` | Event deleted |
|
|
| `event:selected` | `CoreEvents.EVENT_SELECTED` | `{ eventId: string }` | Event selected |
|
|
|
|
### Drag-Drop Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `event:drag-start` | `CoreEvents.EVENT_DRAG_START` | `IDragStartPayload` | Drag started |
|
|
| `event:drag-move` | `CoreEvents.EVENT_DRAG_MOVE` | `IDragMovePayload` | Dragging (throttled) |
|
|
| `event:drag-end` | `CoreEvents.EVENT_DRAG_END` | `IDragEndPayload` | Drag completed |
|
|
| `event:drag-cancel` | `CoreEvents.EVENT_DRAG_CANCEL` | `IDragCancelPayload` | Drag cancelled |
|
|
| `event:drag-column-change` | `CoreEvents.EVENT_DRAG_COLUMN_CHANGE` | `IDragColumnChangePayload` | Moved to different column |
|
|
|
|
### Header Drag Events (Timed to All-Day Conversion)
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `event:drag-enter-header` | `CoreEvents.EVENT_DRAG_ENTER_HEADER` | `IDragEnterHeaderPayload` | Entered header area |
|
|
| `event:drag-move-header` | `CoreEvents.EVENT_DRAG_MOVE_HEADER` | `IDragMoveHeaderPayload` | Moving in header area |
|
|
| `event:drag-leave-header` | `CoreEvents.EVENT_DRAG_LEAVE_HEADER` | `IDragLeaveHeaderPayload` | Left header area |
|
|
|
|
### Resize Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `event:resize-start` | `CoreEvents.EVENT_RESIZE_START` | `IResizeStartPayload` | Resize started |
|
|
| `event:resize-end` | `CoreEvents.EVENT_RESIZE_END` | `IResizeEndPayload` | Resize completed |
|
|
|
|
### Edge Scroll Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `edge-scroll:tick` | `CoreEvents.EDGE_SCROLL_TICK` | `{ deltaY: number }` | Scroll tick during edge scroll |
|
|
| `edge-scroll:started` | `CoreEvents.EDGE_SCROLL_STARTED` | - | Edge scrolling started |
|
|
| `edge-scroll:stopped` | `CoreEvents.EDGE_SCROLL_STOPPED` | - | Edge scrolling stopped |
|
|
|
|
### Sync Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `sync:started` | `CoreEvents.SYNC_STARTED` | - | Background sync started |
|
|
| `sync:completed` | `CoreEvents.SYNC_COMPLETED` | - | Background sync completed |
|
|
| `sync:failed` | `CoreEvents.SYNC_FAILED` | `{ error: Error }` | Background sync failed |
|
|
|
|
### Entity Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `entity:saved` | `CoreEvents.ENTITY_SAVED` | `IEntitySavedPayload` | Entity saved to storage |
|
|
| `entity:deleted` | `CoreEvents.ENTITY_DELETED` | `IEntityDeletedPayload` | Entity deleted from storage |
|
|
|
|
### Audit Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `audit:logged` | `CoreEvents.AUDIT_LOGGED` | `IAuditLoggedPayload` | Audit entry logged |
|
|
|
|
### Rendering Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `events:rendered` | `CoreEvents.EVENTS_RENDERED` | - | Events finished rendering |
|
|
|
|
### System Events
|
|
|
|
| Event | Constant | Payload | Description |
|
|
|-------|----------|---------|-------------|
|
|
| `system:error` | `CoreEvents.ERROR` | `{ error: Error, context?: string }` | System error occurred |
|
|
|
|
---
|
|
|
|
## Command Events (Host to Calendar)
|
|
|
|
Use these to control the calendar from your application:
|
|
|
|
```typescript
|
|
import { EventBus, CalendarEvents } from 'calendar';
|
|
|
|
const eventBus = app.resolveType<EventBus>();
|
|
|
|
// Navigate
|
|
eventBus.emit(CalendarEvents.CMD_NAVIGATE_PREV);
|
|
eventBus.emit(CalendarEvents.CMD_NAVIGATE_NEXT);
|
|
|
|
// Render a view
|
|
eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' });
|
|
|
|
// Toggle header drawer
|
|
eventBus.emit(CalendarEvents.CMD_DRAWER_TOGGLE);
|
|
|
|
// Change workweek preset
|
|
eventBus.emit(CalendarEvents.CMD_WORKWEEK_CHANGE, { presetId: 'standard' });
|
|
|
|
// Update view grouping
|
|
eventBus.emit(CalendarEvents.CMD_VIEW_UPDATE, { type: 'resource', values: ['r1', 'r2'] });
|
|
```
|
|
|
|
| Command | Constant | Payload | Description |
|
|
|---------|----------|---------|-------------|
|
|
| Navigate Previous | `CalendarEvents.CMD_NAVIGATE_PREV` | - | Go to previous period |
|
|
| Navigate Next | `CalendarEvents.CMD_NAVIGATE_NEXT` | - | Go to next period |
|
|
| Render View | `CalendarEvents.CMD_RENDER` | `{ viewId: string }` | Render specified view |
|
|
| Toggle Drawer | `CalendarEvents.CMD_DRAWER_TOGGLE` | - | Toggle all-day drawer |
|
|
| Change Workweek | `CalendarEvents.CMD_WORKWEEK_CHANGE` | `{ presetId: string }` | Change workweek preset |
|
|
| Update View | `CalendarEvents.CMD_VIEW_UPDATE` | `{ type: string, values: string[] }` | Update grouping values |
|
|
|
|
---
|
|
|
|
## Types
|
|
|
|
### Core Types
|
|
|
|
```typescript
|
|
// Event types
|
|
type CalendarEventType = 'customer' | 'vacation' | 'break' | 'meeting' | 'blocked';
|
|
|
|
interface ICalendarEvent {
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
start: Date;
|
|
end: Date;
|
|
type: CalendarEventType;
|
|
allDay: boolean;
|
|
bookingId?: string;
|
|
resourceId?: string;
|
|
customerId?: string;
|
|
recurringId?: string;
|
|
syncStatus: SyncStatus;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
// Resource types
|
|
type ResourceType = 'person' | 'room' | 'equipment' | 'vehicle' | 'custom';
|
|
|
|
interface IResource {
|
|
id: string;
|
|
name: string;
|
|
displayName: string;
|
|
type: ResourceType;
|
|
avatarUrl?: string;
|
|
color?: string;
|
|
isActive?: boolean;
|
|
defaultSchedule?: IWeekSchedule;
|
|
syncStatus: SyncStatus;
|
|
}
|
|
|
|
// Sync status
|
|
type SyncStatus = 'synced' | 'pending' | 'error';
|
|
```
|
|
|
|
### Settings Types
|
|
|
|
```typescript
|
|
interface IGridSettings {
|
|
id: 'grid';
|
|
dayStartHour: number;
|
|
dayEndHour: number;
|
|
workStartHour: number;
|
|
workEndHour: number;
|
|
hourHeight: number;
|
|
snapInterval: number;
|
|
}
|
|
|
|
interface IWorkweekPreset {
|
|
id: string;
|
|
workDays: number[]; // ISO weekdays: 1=Monday, 7=Sunday
|
|
label: string;
|
|
periodDays: number; // Navigation step (1=day, 7=week)
|
|
}
|
|
|
|
interface IWeekSchedule {
|
|
[day: number]: ITimeSlot | null; // null = off that day
|
|
}
|
|
|
|
interface ITimeSlot {
|
|
start: string; // "HH:mm"
|
|
end: string; // "HH:mm"
|
|
}
|
|
```
|
|
|
|
### Drag-Drop Payloads
|
|
|
|
```typescript
|
|
interface IDragStartPayload {
|
|
eventId: string;
|
|
element: HTMLElement;
|
|
ghostElement: HTMLElement;
|
|
startY: number;
|
|
mouseOffset: { x: number; y: number };
|
|
columnElement: HTMLElement;
|
|
}
|
|
|
|
interface IDragEndPayload {
|
|
swpEvent: SwpEvent;
|
|
sourceColumnKey: string;
|
|
target: 'grid' | 'header';
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Extensions
|
|
|
|
Import extensions separately to keep bundle size minimal:
|
|
|
|
```typescript
|
|
// Teams extension
|
|
import { registerTeams, TeamService, TeamStore, TeamRenderer } from 'calendar/teams';
|
|
|
|
// Departments extension
|
|
import { registerDepartments, DepartmentService, DepartmentStore } from 'calendar/departments';
|
|
|
|
// Bookings extension
|
|
import { registerBookings, BookingService, BookingStore } from 'calendar/bookings';
|
|
|
|
// Customers extension
|
|
import { registerCustomers, CustomerService, CustomerStore } from 'calendar/customers';
|
|
|
|
// Schedules extension (working hours)
|
|
import { registerSchedules, ResourceScheduleService, ScheduleOverrideService } from 'calendar/schedules';
|
|
|
|
// Audit extension
|
|
import { registerAudit, AuditService, AuditStore } from 'calendar/audit';
|
|
|
|
// Register with container builder
|
|
const builder = container.builder();
|
|
registerCoreServices(builder);
|
|
registerTeams(builder);
|
|
registerSchedules(builder);
|
|
// ... etc
|
|
```
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
### Calendar Options
|
|
|
|
```typescript
|
|
interface ICalendarOptions {
|
|
timeConfig?: ITimeFormatConfig;
|
|
gridConfig?: IGridConfig;
|
|
dbConfig?: IDBConfig;
|
|
}
|
|
|
|
// Time format configuration
|
|
interface ITimeFormatConfig {
|
|
timezone: string; // e.g., 'Europe/Copenhagen'
|
|
use24HourFormat: boolean;
|
|
locale: string; // e.g., 'da-DK'
|
|
dateFormat: string;
|
|
showSeconds: boolean;
|
|
}
|
|
|
|
// Grid configuration
|
|
interface IGridConfig {
|
|
hourHeight: number; // Pixels per hour (default: 64)
|
|
dayStartHour: number; // Grid start hour (default: 6)
|
|
dayEndHour: number; // Grid end hour (default: 18)
|
|
snapInterval: number; // Minutes to snap to (default: 15)
|
|
gridStartThresholdMinutes: number;
|
|
}
|
|
|
|
// Database configuration
|
|
interface IDBConfig {
|
|
dbName: string; // IndexedDB database name
|
|
dbVersion: number; // Schema version
|
|
}
|
|
```
|
|
|
|
### Default Configuration
|
|
|
|
```typescript
|
|
import {
|
|
defaultTimeFormatConfig,
|
|
defaultGridConfig,
|
|
defaultDBConfig
|
|
} from 'calendar';
|
|
|
|
// Defaults:
|
|
// timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
// use24HourFormat: true
|
|
// locale: 'da-DK'
|
|
// hourHeight: 64
|
|
// dayStartHour: 6
|
|
// dayEndHour: 18
|
|
// snapInterval: 15
|
|
```
|
|
|
|
---
|
|
|
|
## Utilities
|
|
|
|
### Position Utilities
|
|
|
|
```typescript
|
|
import {
|
|
calculateEventPosition,
|
|
minutesToPixels,
|
|
pixelsToMinutes,
|
|
snapToGrid
|
|
} from 'calendar';
|
|
|
|
// Convert time to pixels
|
|
const pixels = minutesToPixels(120, 64); // 120 mins at 64px/hour = 128px
|
|
|
|
// Snap to 15-minute grid
|
|
const snapped = snapToGrid(new Date(), 15);
|
|
```
|
|
|
|
### Event Layout Engine
|
|
|
|
```typescript
|
|
import { eventsOverlap, calculateColumnLayout } from 'calendar';
|
|
|
|
// Check if two events overlap
|
|
const overlap = eventsOverlap(event1, event2);
|
|
|
|
// Calculate layout for overlapping events
|
|
const layout = calculateColumnLayout(events);
|
|
```
|
|
|
|
---
|
|
|
|
## Listening to Events
|
|
|
|
```typescript
|
|
import { EventBus, CoreEvents } from 'calendar';
|
|
|
|
const eventBus = app.resolveType<EventBus>();
|
|
|
|
// Subscribe to event updates
|
|
eventBus.on(CoreEvents.EVENT_UPDATED, (e: Event) => {
|
|
const { eventId, sourceColumnKey, targetColumnKey } = (e as CustomEvent).detail;
|
|
console.log(`Event ${eventId} moved from ${sourceColumnKey} to ${targetColumnKey}`);
|
|
});
|
|
|
|
// Subscribe to drag events
|
|
eventBus.on(CoreEvents.EVENT_DRAG_END, (e: Event) => {
|
|
const { swpEvent, target } = (e as CustomEvent).detail;
|
|
console.log(`Dropped on ${target}:`, swpEvent);
|
|
});
|
|
|
|
// One-time listener
|
|
eventBus.once(CoreEvents.READY, () => {
|
|
console.log('Calendar is ready!');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## CSS Customization
|
|
|
|
The calendar uses CSS custom properties for theming. Override these in your CSS:
|
|
|
|
```css
|
|
:root {
|
|
--calendar-hour-height: 64px;
|
|
--calendar-header-height: 48px;
|
|
--calendar-column-min-width: 120px;
|
|
--calendar-event-border-radius: 4px;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencies
|
|
|
|
- `@novadi/core` - Dependency injection framework (peer dependency)
|
|
- `dayjs` - Date manipulation and formatting
|
|
|
|
---
|
|
|
|
## License
|
|
|
|
Proprietary - SWP
|