From d24eed9cd1f16b26afd26bb9b1c19bca4021a861 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Thu, 29 Jan 2026 14:14:35 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=99=88(tools)=20remove=20openspec/=20from?= =?UTF-8?q?=20Git=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The openspec/ directory is now gitignored and removed from the repository index while kept locally. This prevents OpenSpec working artifacts from being pushed to GitHub. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + .../add-ical-subscription-export/design.md | 320 ------------------ .../add-ical-subscription-export/proposal.md | 62 ---- .../specs/ical-subscription-export/spec.md | 176 ---------- .../add-ical-subscription-export/tasks.md | 127 ------- .../.openspec.yaml | 2 - .../design.md | 78 ----- .../proposal.md | 34 -- .../specs/calendar-theme/spec.md | 113 ------- .../specs/scheduler-toolbar/spec.md | 115 ------- .../tasks.md | 62 ---- .../.openspec.yaml | 2 - .../fix-timezone-double-conversion/design.md | 100 ------ .../proposal.md | 29 -- .../specs/timezone-conversion/spec.md | 170 ---------- .../fix-timezone-double-conversion/tasks.md | 80 ----- openspec/project.md | 126 ------- openspec/specs/calendar-theme/spec.md | 113 ------- openspec/specs/scheduler-toolbar/spec.md | 115 ------- 19 files changed, 3 insertions(+), 1824 deletions(-) delete mode 100644 openspec/changes/add-ical-subscription-export/design.md delete mode 100644 openspec/changes/add-ical-subscription-export/proposal.md delete mode 100644 openspec/changes/add-ical-subscription-export/specs/ical-subscription-export/spec.md delete mode 100644 openspec/changes/add-ical-subscription-export/tasks.md delete mode 100644 openspec/changes/archive/2026-01-28-calendar-central-design/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-01-28-calendar-central-design/design.md delete mode 100644 openspec/changes/archive/2026-01-28-calendar-central-design/proposal.md delete mode 100644 openspec/changes/archive/2026-01-28-calendar-central-design/specs/calendar-theme/spec.md delete mode 100644 openspec/changes/archive/2026-01-28-calendar-central-design/specs/scheduler-toolbar/spec.md delete mode 100644 openspec/changes/archive/2026-01-28-calendar-central-design/tasks.md delete mode 100644 openspec/changes/fix-timezone-double-conversion/.openspec.yaml delete mode 100644 openspec/changes/fix-timezone-double-conversion/design.md delete mode 100644 openspec/changes/fix-timezone-double-conversion/proposal.md delete mode 100644 openspec/changes/fix-timezone-double-conversion/specs/timezone-conversion/spec.md delete mode 100644 openspec/changes/fix-timezone-double-conversion/tasks.md delete mode 100644 openspec/project.md delete mode 100644 openspec/specs/calendar-theme/spec.md delete mode 100644 openspec/specs/scheduler-toolbar/spec.md diff --git a/.gitignore b/.gitignore index 083bf55..fabfd62 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,6 @@ db.sqlite3 # Various .turbo + +# OpenSpec +openspec/ diff --git a/openspec/changes/add-ical-subscription-export/design.md b/openspec/changes/add-ical-subscription-export/design.md deleted file mode 100644 index 2ba78b5..0000000 --- a/openspec/changes/add-ical-subscription-export/design.md +++ /dev/null @@ -1,320 +0,0 @@ -# Design: iCal Subscription Export - -## Context - -La Suite Calendars uses SabreDAV as its CalDAV server, but the current -authentication model (API key + X-Forwarded-User headers) prevents direct -access from external calendar clients. Users need a way to subscribe to their -calendars from applications like Apple Calendar, Google Calendar, etc. - -SabreDAV provides an `ICSExportPlugin` that generates RFC 5545 compliant iCal -files. We want to leverage this plugin while providing a clean, unauthenticated -URL for external calendar applications. - -## Goals / Non-Goals - -**Goals:** -- Allow users to subscribe to their calendars from external applications -- Per-calendar subscription URLs with private tokens -- Clean URL format similar to Google Calendar / Outlook -- Ability to revoke/regenerate tokens -- Reuse SabreDAV's ICSExportPlugin for ICS generation -- **Standalone tokens that don't require synchronizing CalDAV calendars with Django** - -**Non-Goals:** -- Write access from external clients (read-only subscriptions) -- Full CalDAV protocol support for external clients -- Importing external calendars into La Suite Calendars (future feature) -- Real-time sync (clients poll at their own refresh rate) - -## Decisions - -### 1. URL Format - -**Decision:** Use a short, clean URL with token in the path: - -``` -https:///ical/.ics -``` - -**Examples from other services:** -- Google: `https://calendar.google.com/calendar/ical//public/basic.ics` -- Outlook: `https://outlook.office365.com/owa/calendar///calendar.ics` - -**Rationale:** -- Industry standard format -- No authentication prompt in calendar apps (token IS the auth) -- Easy to copy/paste -- Token not exposed in query strings (cleaner logs) - -### 2. Django Proxy to SabreDAV - -**Decision:** Django handles the public endpoint and proxies to SabreDAV. - -``` -Apple Calendar - │ - │ GET /ical/.ics (no auth headers) - ▼ - Django (public endpoint) - │ - │ 1. Extract token from URL - │ 2. Lookup CalendarSubscriptionToken in DB - │ 3. Get caldav_path and owner.email directly from token - ▼ - Django → SabreDAV (internal) - │ - │ GET /calendars//?export - │ Headers: X-Api-Key, X-Forwarded-User - ▼ - SabreDAV ICSExportPlugin - │ - │ Generates RFC 5545 ICS - ▼ - Django returns ICS to client -``` - -**Rationale:** -- No changes to SabreDAV authentication backend -- Clean separation: Django handles tokens, SabreDAV handles CalDAV -- Token validation logic stays in Python (easier to test/maintain) -- Reuses existing CalDAV proxy infrastructure - -### 3. Token Storage - Standalone Model - -**Decision:** Django model `CalendarSubscriptionToken` is **standalone** and stores the CalDAV path directly: - -```python -class CalendarSubscriptionToken(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - # Owner of the calendar (for permission verification) - owner = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="subscription_tokens", - ) - - # CalDAV path stored directly (e.g., /calendars/user@example.com/uuid/) - caldav_path = models.CharField(max_length=512) - - # Calendar display name (for UI and filename) - calendar_name = models.CharField(max_length=255, blank=True, default="") - - token = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) - is_active = models.BooleanField(default=True) - last_accessed_at = models.DateTimeField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["owner", "caldav_path"], - name="unique_token_per_owner_calendar", - ) - ] -``` - -**Rationale:** -- **No dependency on Django Calendar model** - tokens work directly with CalDAV paths -- No need to synchronize CalDAV calendars to Django before creating tokens -- Works for all calendars (not just those previously synced to Django) -- Avoids fragile name-matching when multiple calendars have the same name -- UUID provides 128 bits of entropy (secure) -- `is_active` allows soft-disable without deletion -- `last_accessed_at` for auditing -- Unique constraint ensures one token per user+calendar combination - -### 4. Token Scope: Per Calendar Path - -**Decision:** One token per user + CalDAV path combination. - -**Rationale:** -- Users can share specific calendars without exposing all calendars -- Revoking one calendar's access doesn't affect others -- Permission verification via path: user's email must be in the CalDAV path - -### 5. Permission Verification via CalDAV Path - -**Decision:** Verify ownership by checking the user's email is in the CalDAV path. - -```python -def _verify_caldav_access(self, user, caldav_path): - # Path format: /calendars/user@example.com/uuid/ - parts = caldav_path.strip("/").split("/") - if len(parts) >= 2 and parts[0] == "calendars": - path_email = unquote(parts[1]) - return path_email.lower() == user.email.lower() - return False -``` - -**Rationale:** -- CalDAV paths inherently contain the owner's email -- No need to query CalDAV server to check permissions -- Simple and fast verification - -### 6. ICS Generation via SabreDAV - -**Decision:** Use SabreDAV's `ICSExportPlugin` instead of generating ICS in -Django. - -**Rationale:** -- ICSExportPlugin is battle-tested and RFC 5545 compliant -- Handles recurring events, timezones, and edge cases correctly -- No code duplication -- SabreDAV already has the calendar data - -**Required change in `server.php`:** -```php -$server->addPlugin(new CalDAV\ICSExportPlugin()); -``` - -## API Design - -### Public Endpoint (no authentication) - -``` -GET /ical/.ics - → Validates token - → Proxies to SabreDAV using token.caldav_path and token.owner.email - → Returns ICS (Content-Type: text/calendar) - → 404 if token invalid/inactive -``` - -### Token Management (authenticated Django API) - -**New standalone endpoint:** - -``` -POST /api/v1.0/subscription-tokens/ - Body: { caldav_path, calendar_name (optional) } - → Creates token or returns existing (owner only) - → Verifies user's email is in caldav_path - → Returns: { token, url, caldav_path, calendar_name, created_at } - -GET /api/v1.0/subscription-tokens/by-path/?caldav_path=... - → Returns existing token or 404 - -DELETE /api/v1.0/subscription-tokens/by-path/?caldav_path=... - → Deletes token (revokes access) -``` - -### Frontend Flow - -1. User clicks "Get subscription URL" on a calendar -2. Frontend extracts CalDAV path from the calendar's URL -3. Frontend calls `POST /subscription-tokens/` with `{ caldav_path, calendar_name }` -4. Backend creates token (or returns existing) and returns subscription URL -5. Modal displays URL with copy button - -## Security Considerations - -### Token as Secret - -- Token is a UUID (128 bits of entropy) - infeasible to brute force -- Knowledge of token = read access to calendar -- URL should be treated as confidential - -### Mitigations - -- Clear UI warning about URL privacy -- Easy token regeneration (delete + create) -- `last_accessed_at` tracking for auditing -- Rate limiting on `/ical/` endpoint (future) - -### Attack Surface - -- Token in URL may appear in: - - Server access logs → configure log rotation, mask tokens - - Browser history (if opened in browser) → minor concern - - Referrer headers → set `Referrer-Policy: no-referrer` -- No CSRF risk (read-only, no state changes via GET) - -## Implementation Notes - -### Django View for /ical/.ics - -```python -class ICalExportView(View): - def get(self, request, token): - # 1. Lookup token - subscription = CalendarSubscriptionToken.objects.filter( - token=token, is_active=True - ).select_related('owner').first() - - if not subscription: - raise Http404 - - # 2. Update last_accessed_at - subscription.last_accessed_at = timezone.now() - subscription.save(update_fields=['last_accessed_at']) - - # 3. Proxy to SabreDAV using caldav_path and owner directly - caldav_path = subscription.caldav_path.lstrip("/") - caldav_url = f"{settings.CALDAV_URL}/api/v1.0/caldav/{caldav_path}?export" - - response = requests.get( - caldav_url, - headers={ - 'X-Api-Key': settings.CALDAV_OUTBOUND_API_KEY, - 'X-Forwarded-User': subscription.owner.email, - } - ) - - # 4. Return ICS - display_name = subscription.calendar_name or "calendar" - return HttpResponse( - response.content, - content_type='text/calendar', - headers={ - 'Content-Disposition': f'attachment; filename="{display_name}.ics"', - 'Cache-Control': 'no-store, private', - 'Referrer-Policy': 'no-referrer', - } - ) -``` - -### URL Configuration - -```python -# urls.py -urlpatterns = [ - path('ical/.ics', ICalExportView.as_view(), name='ical-export'), -] -``` - -## Risks / Trade-offs - -### Trade-off: Extra HTTP Hop - -Django proxies to SabreDAV (local network call). -- **Pro:** Clean architecture, no PHP changes -- **Con:** Slight latency (~1-5ms on localhost) -- **Verdict:** Acceptable for a polling use case (clients refresh hourly) - -### Risk: Token Leakage - -If URL is shared/leaked, anyone can read the calendar. -- **Mitigation:** Regenerate token feature, access logging, UI warnings - -### Risk: Large Calendar Performance - -Generating ICS for calendars with thousands of events. -- **Mitigation:** SabreDAV handles this efficiently -- **Future:** Add date range filtering (`?start=...&end=...`) - -## Migration Plan - -1. Add `CalendarSubscriptionToken` Django model with standalone fields -2. Create migration (adds owner, caldav_path, calendar_name fields) -3. Add `ICSExportPlugin` to SabreDAV `server.php` -4. Create Django `/ical/.ics` endpoint -5. Add standalone `SubscriptionTokenViewSet` API -6. Update frontend to use caldav_path instead of calendar ID -7. No data migration needed (new feature) - -## References - -- [SabreDAV ICSExportPlugin](https://sabre.io/dav/ics-export-plugin/) -- [Google Calendar public URL format](https://support.google.com/calendar/answer/37083) -- [Outlook calendar publishing](https://support.microsoft.com/en-us/office/introduction-to-publishing-internet-calendars-a25e68d6-695a-41c6-a701-103d44ba151d) diff --git a/openspec/changes/add-ical-subscription-export/proposal.md b/openspec/changes/add-ical-subscription-export/proposal.md deleted file mode 100644 index 0a47ab2..0000000 --- a/openspec/changes/add-ical-subscription-export/proposal.md +++ /dev/null @@ -1,62 +0,0 @@ -# Change: Add iCal Subscription Export - -## Why - -Users want to subscribe to their La Suite Calendars calendars from external -applications (Apple Calendar, Google Calendar, Thunderbird, Outlook, etc.) -using standard iCal URLs. Currently, external access is not supported due to -the API-key authentication model, preventing users from accessing their -calendars outside the web application. - -## What Changes - -- Add a standalone `CalendarSubscriptionToken` Django model that stores: - - Owner (user) reference - - CalDAV path directly (no FK to Calendar model) - - Calendar display name - - Token UUID and metadata -- Create a public Django endpoint `/ical/.ics` that: - - Validates the token (no user authentication required) - - Proxies the request to SabreDAV using the stored caldav_path and owner email - - Returns the ICS generated by SabreDAV's `ICSExportPlugin` -- Enable SabreDAV's `ICSExportPlugin` in `server.php` -- Add standalone Django REST API endpoint `/api/v1.0/subscription-tokens/`: - - POST to create token with `{ caldav_path, calendar_name }` - - GET/DELETE by-path to manage existing tokens - - Permission verification via caldav_path (user's email must be in path) -- Add UI in the calendar context menu to obtain and copy the subscription URL - - Frontend extracts CalDAV path from calendar URL - - Modal auto-creates token on open - -## Architecture Approach - -**URL format:** `https://calendars.example.com/ical/.ics` - -**Key design decision:** The token model is **standalone** and stores the CalDAV -path directly, avoiding the need to synchronize CalDAV calendars with Django. - -This follows the same pattern as Google Calendar and Outlook: -- Short, clean URL -- Token in URL path acts as authentication (no username/password) -- Reuses SabreDAV's ICSExportPlugin for RFC 5545 compliant ICS generation - -See `design.md` for detailed technical flow. - -## Impact - -- **Affected specs**: New capability `ical-subscription-export` -- **Affected code**: - - `docker/sabredav/server.php` - Add ICSExportPlugin - - `src/backend/core/models.py` - New CalendarSubscriptionToken model (standalone) - - `src/backend/core/api/viewsets.py` - New SubscriptionTokenViewSet - - `src/backend/core/api/viewsets_ical.py` - New ICalExportView - - `src/frontend/apps/calendars/` - UI for subscription URL with caldavPath -- **Security**: Tokens are random UUIDs; URLs should be treated as secrets -- **Database**: New Django table for subscription tokens -- **No breaking changes** to existing functionality - -## Out of Scope (Future Work) - -- Subscribing TO external calendars from within La Suite Calendars (import) -- CalDAV access with HTTP Basic authentication -- Public (unauthenticated) calendar sharing without token diff --git a/openspec/changes/add-ical-subscription-export/specs/ical-subscription-export/spec.md b/openspec/changes/add-ical-subscription-export/specs/ical-subscription-export/spec.md deleted file mode 100644 index 06295ac..0000000 --- a/openspec/changes/add-ical-subscription-export/specs/ical-subscription-export/spec.md +++ /dev/null @@ -1,176 +0,0 @@ -## ADDED Requirements - -### Requirement: Calendar Subscription Token Management - -The system SHALL allow calendar owners to generate a private subscription token -for their calendars using CalDAV paths directly, enabling read-only access via -iCal URL from external calendar applications without requiring Django Calendar -model synchronization. - -#### Scenario: Owner generates subscription token - -- **GIVEN** a user owns a calendar with CalDAV path `/calendars///` -- **WHEN** the user requests a subscription token with that CalDAV path -- **THEN** the system verifies the user's email matches the path -- **AND** generates a unique UUID token stored with the CalDAV path -- **AND** returns the subscription URL in format `/ical/.ics` - -#### Scenario: Owner retrieves existing subscription token - -- **GIVEN** a user owns a calendar with an existing subscription token -- **WHEN** the user requests the subscription token by CalDAV path -- **THEN** the system returns the existing token and URL - -#### Scenario: Owner regenerates subscription token - -- **GIVEN** a user owns a calendar with an existing subscription token -- **WHEN** the user deletes the token and creates a new one -- **THEN** the old token is invalidated -- **AND** a new unique token is generated -- **AND** the old subscription URL no longer works - -#### Scenario: Owner revokes subscription token - -- **GIVEN** a user owns a calendar with an existing subscription token -- **WHEN** the user requests to delete the token by CalDAV path -- **THEN** the system removes the token -- **AND** the subscription URL returns 404 - -#### Scenario: Non-owner cannot manage subscription token - -- **GIVEN** a user attempts to create a token for a CalDAV path not containing their email -- **WHEN** the user sends a request with that CalDAV path -- **THEN** the system rejects the request with a 403 permission error - -#### Scenario: One token per calendar path per owner - -- **GIVEN** a user already has a subscription token for a CalDAV path -- **WHEN** the user requests to create another token for the same path -- **THEN** the system returns the existing token instead of creating a duplicate - ---- - -### Requirement: Public iCal Export Endpoint - -The system SHALL provide a public endpoint that serves calendar data in iCal -format when accessed with a valid subscription token, without requiring user -authentication, using the CalDAV path stored directly in the token. - -#### Scenario: Valid token returns calendar data - -- **GIVEN** a valid and active subscription token exists with a CalDAV path -- **WHEN** an HTTP GET request is made to `/ical/.ics` -- **THEN** the system proxies to SabreDAV using the token's caldav_path and owner email -- **AND** returns the calendar events in iCal format -- **AND** the response Content-Type is `text/calendar` -- **AND** the response is RFC 5545 compliant -- **AND** no authentication headers are required - -#### Scenario: Invalid token returns 404 - -- **GIVEN** a token that does not exist in the system -- **WHEN** an HTTP GET request is made to `/ical/.ics` -- **THEN** the system returns HTTP 404 Not Found - -#### Scenario: Deleted token returns 404 - -- **GIVEN** a subscription token that has been deleted -- **WHEN** an HTTP GET request is made to `/ical/.ics` -- **THEN** the system returns HTTP 404 Not Found - -#### Scenario: Access tracking - -- **GIVEN** a valid subscription token -- **WHEN** the iCal endpoint is accessed successfully -- **THEN** the system updates the token's last accessed timestamp - -#### Scenario: Security headers are set - -- **GIVEN** a valid subscription URL -- **WHEN** the iCal endpoint returns a response -- **THEN** the response includes `Cache-Control: no-store, private` -- **AND** the response includes `Referrer-Policy: no-referrer` - -#### Scenario: Compatible with external calendar apps - -- **GIVEN** a valid subscription URL -- **WHEN** the URL is added to Apple Calendar as a subscription -- **THEN** Apple Calendar successfully subscribes and displays events -- **AND** events sync automatically on refresh - ---- - -### Requirement: Subscription URL User Interface - -The system SHALL provide a user interface for calendar owners to obtain and -manage subscription URLs using CalDAV paths extracted from calendar URLs. - -#### Scenario: Access subscription URL from calendar menu - -- **GIVEN** a user is viewing their calendars -- **WHEN** the user opens the context menu for a calendar they own -- **THEN** an option to get the subscription URL is available - -#### Scenario: Subscription option hidden for non-owned calendars - -- **GIVEN** a user has shared access to a calendar but is not the owner -- **WHEN** the user opens the context menu for that calendar -- **THEN** the subscription URL option is NOT displayed - -#### Scenario: Display subscription URL modal - -- **GIVEN** a user clicks the subscription URL option for their calendar -- **WHEN** the modal opens -- **THEN** the frontend extracts the CalDAV path from the calendar URL -- **AND** creates or retrieves the token using the CalDAV path -- **AND** the full subscription URL is displayed -- **AND** a "Copy to clipboard" button is available -- **AND** a warning about keeping the URL private is shown -- **AND** an option to regenerate the URL is available - -#### Scenario: Copy URL to clipboard - -- **GIVEN** the subscription URL modal is open -- **WHEN** the user clicks "Copy to clipboard" -- **THEN** the URL is copied to the system clipboard -- **AND** visual feedback confirms the copy was successful - -#### Scenario: Regenerate token from modal - -- **GIVEN** the subscription URL modal is open -- **WHEN** the user clicks to regenerate the URL -- **THEN** a confirmation dialog is shown -- **AND** upon confirmation, the old token is deleted -- **AND** a new token is generated -- **AND** the modal updates to show the new URL - -#### Scenario: Error handling in modal - -- **GIVEN** the subscription URL modal is open -- **WHEN** the initial token fetch returns 404 (no existing token) -- **THEN** the system automatically creates a new token -- **AND** no error message is displayed to the user -- **BUT** if token creation fails, an error message is displayed - ---- - -### Requirement: Standalone Token Storage - -The system SHALL store subscription tokens independently of the Django Calendar -model, using CalDAV paths directly to enable token management without requiring -CalDAV-to-Django synchronization. - -#### Scenario: Token stores CalDAV path directly - -- **GIVEN** a subscription token is created -- **THEN** the token record includes the full CalDAV path -- **AND** the token record includes the owner (user) reference -- **AND** the token record includes an optional calendar display name -- **AND** no foreign key to Django Calendar model is required - -#### Scenario: Permission verification via path - -- **GIVEN** a user requests a subscription token -- **WHEN** the system verifies permissions -- **THEN** it checks that the user's email appears in the CalDAV path -- **AND** does not require querying the CalDAV server diff --git a/openspec/changes/add-ical-subscription-export/tasks.md b/openspec/changes/add-ical-subscription-export/tasks.md deleted file mode 100644 index a2c3aa1..0000000 --- a/openspec/changes/add-ical-subscription-export/tasks.md +++ /dev/null @@ -1,127 +0,0 @@ -# Tasks: iCal Subscription Export - -## 1. Backend - Data Model - -- [x] 1.1 Create `CalendarSubscriptionToken` model in `core/models.py` - - ForeignKey to User (owner, on_delete=CASCADE) - - `caldav_path` field (CharField, max_length=512) - stores CalDAV path directly - - `calendar_name` field (CharField, max_length=255, optional) - for display - - `token` field (UUID, unique, indexed, default=uuid4) - - `is_active` boolean (default=True) - - `last_accessed_at` DateTimeField (nullable) - - UniqueConstraint on (owner, caldav_path) -- [x] 1.2 Create and run database migration -- [x] 1.3 Add model to Django admin for debugging - -## 2. SabreDAV - Enable ICSExportPlugin - -- [x] 2.1 Add `ICSExportPlugin` to `server.php`: - ```php - $server->addPlugin(new CalDAV\ICSExportPlugin()); - ``` -- [x] 2.2 Test that `?export` works via existing CalDAV proxy - -## 3. Backend - Public iCal Endpoint - -- [x] 3.1 Create `ICalExportView` in `core/api/viewsets_ical.py` - - No authentication required (public endpoint) - - Extract token from URL path - - Lookup `CalendarSubscriptionToken` by token - - Return 404 if token invalid/inactive - - Update `last_accessed_at` on access - - Proxy request to SabreDAV using `token.caldav_path` and `token.owner.email` - - Return ICS response with `Content-Type: text/calendar` - - Set security headers (Cache-Control, Referrer-Policy) -- [x] 3.2 Add URL route: `path('ical/.ics', ...)` -- [x] 3.3 Write tests for public endpoint (valid token, invalid token, inactive) - -## 4. Backend - Standalone Token Management API - -- [x] 4.1 Create serializers in `core/api/serializers.py` - - `CalendarSubscriptionTokenSerializer` - fields: token, url, caldav_path, calendar_name, etc. - - `CalendarSubscriptionTokenCreateSerializer` - for POST body validation -- [x] 4.2 Create standalone `SubscriptionTokenViewSet` in `core/api/viewsets.py`: - - `POST /subscription-tokens/` - create token with { caldav_path, calendar_name } - - `GET /subscription-tokens/by-path/?caldav_path=...` - get existing token - - `DELETE /subscription-tokens/by-path/?caldav_path=...` - revoke token - - Permission verification: user's email must be in caldav_path -- [x] 4.3 Register viewset in `core/urls.py` -- [x] 4.4 Write API tests for token management (create, get, delete, permissions) - -## 5. Frontend - API Integration - -- [x] 5.1 Add API functions in `features/calendar/api.ts`: - - `getSubscriptionToken(caldavPath)` - GET by-path - - `createSubscriptionToken({ caldavPath, calendarName })` - POST - - `deleteSubscriptionToken(caldavPath)` - DELETE by-path -- [x] 5.2 Update React Query hooks in `hooks/useCalendars.ts` - - Use caldavPath instead of calendarId - -## 6. Frontend - UI Components - -- [x] 6.1 Update `SubscriptionUrlModal` component - - Accept `caldavPath` prop instead of `calendarId` - - Extract caldavPath from calendar URL in parent component - - Display the subscription URL in a copyable field - - "Copy to clipboard" button with success feedback - - Warning text about URL being private - - "Regenerate URL" button with confirmation dialog - - Only show error alert for real errors (not for expected 404) -- [x] 6.2 Update `CalendarList.tsx` - - Extract CalDAV path from calendar URL - - Pass caldavPath to SubscriptionUrlModal -- [x] 6.3 Add translations (i18n) for new UI strings - -## 7. Cleanup - -- [x] 7.1 Remove old `subscription_token` action from CalendarViewSet -- [x] 7.2 Remove `sync-from-caldav` endpoint (no longer needed) -- [x] 7.3 Remove `syncFromCaldav` from frontend API - -## 8. Testing & Validation - -- [x] 8.1 Manual test: add URL to Apple Calendar -- [ ] 8.2 Manual test: add URL to Google Calendar -- [x] 8.3 Verify token regeneration invalidates old URL -- [ ] 8.4 E2E test for subscription workflow (optional) - -## Dependencies - -``` -1 (Django model) - ↓ -2 (ICSExportPlugin) ──────┐ - ↓ │ -3 (Public endpoint) ──────┤ can run in parallel after 1 - ↓ │ -4 (Token API) ────────────┘ - ↓ -5 (Frontend API) - ↓ -6 (Frontend UI) - ↓ -7 (Cleanup) - ↓ -8 (Testing) -``` - -## Key Files Modified - -### Backend -- `src/backend/core/models.py` - CalendarSubscriptionToken model (standalone) -- `src/backend/core/migrations/0002_calendarsubscriptiontoken.py` -- `src/backend/core/api/serializers.py` - Token serializers -- `src/backend/core/api/viewsets.py` - SubscriptionTokenViewSet -- `src/backend/core/api/viewsets_ical.py` - ICalExportView -- `src/backend/core/urls.py` - Route registration -- `src/backend/core/admin.py` - Admin configuration -- `src/backend/core/factories.py` - Test factory - -### Frontend -- `src/features/calendar/api.ts` - API functions with caldavPath -- `src/features/calendar/hooks/useCalendars.ts` - React Query hooks -- `src/features/calendar/components/calendar-list/CalendarList.tsx` -- `src/features/calendar/components/calendar-list/SubscriptionUrlModal.tsx` - -### SabreDAV -- `docker/sabredav/server.php` - ICSExportPlugin enabled diff --git a/openspec/changes/archive/2026-01-28-calendar-central-design/.openspec.yaml b/openspec/changes/archive/2026-01-28-calendar-central-design/.openspec.yaml deleted file mode 100644 index df18424..0000000 --- a/openspec/changes/archive/2026-01-28-calendar-central-design/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-01-28 diff --git a/openspec/changes/archive/2026-01-28-calendar-central-design/design.md b/openspec/changes/archive/2026-01-28-calendar-central-design/design.md deleted file mode 100644 index eaa2fbe..0000000 --- a/openspec/changes/archive/2026-01-28-calendar-central-design/design.md +++ /dev/null @@ -1,78 +0,0 @@ -## Context - -L'application utilise @event-calendar/core (vkurko/calendar), une bibliothèque Svelte qui expose des variables CSS `--ec-*` et des classes CSS `.ec-*` pour la personnalisation. Le design system Cunningham (@gouvfr-lasuite/cunningham-react) fournit les tokens visuels via des variables CSS `--c--globals--*`. - -État actuel : le calendrier utilise les styles par défaut d'EventCalendar qui ne s'intègrent pas visuellement avec La Suite. - -Contraintes : -- EventCalendar est une bibliothèque Svelte, pas React - on ne peut pas modifier ses composants internes -- La toolbar native ne supporte pas de dropdown React pour le sélecteur de vues -- Les variables CSS seules ne suffisent pas pour tous les aspects visuels - -## Goals / Non-Goals - -**Goals:** -- Aligner visuellement le calendrier central avec le design system Cunningham -- Créer une toolbar custom React avec navigation et sélecteur de vues -- Override les styles EventCalendar via variables CSS et classes CSS -- Supporter automatiquement le dark mode via les tokens Cunningham - -**Non-Goals:** -- Différenciation visuelle des weekends (reporté) -- Redesign du mini-calendrier ou de la sidebar gauche -- Modification des modals d'événements -- Color picker pour les calendriers (futur) - -## Decisions - -### 1. Toolbar custom React vs Override CSS de la toolbar native - -**Décision** : Toolbar custom React - -**Alternatives considérées** : -- Override CSS des boutons natifs : limité pour le dropdown, moins de contrôle -- Modification du DOM via JavaScript : fragile, maintenance difficile - -**Rationale** : -- Contrôle total sur le layout et les interactions -- Meilleure intégration avec le reste de l'app React -- Utilise les méthodes API d'EventCalendar (`prev()`, `next()`, `setOption()`, `getView()`) -- DropdownMenu custom (plutôt que Select Cunningham) pour une meilleure accessibilité clavier (Escape, Arrow, Enter) - -### 2. Fichier de thème séparé vs Inline dans Scheduler.scss - -**Décision** : Fichier `scheduler-theme.scss` séparé - -**Rationale** : -- Séparation des responsabilités (thème vs composant) -- Facilite la maintenance et l'évolution du thème -- Permet de trouver facilement tous les overrides EventCalendar - -### 3. Style "aujourd'hui" - Encadré vs Fond coloré - -**Décision** : Encadré fin autour du numéro de jour, pas de fond - -**Rationale** : -- Plus subtil et moderne -- Conforme à la maquette fournie -- Meilleure lisibilité (pas de conflit de couleurs) - -### 4. Now indicator - Style existant vs Custom - -**Décision** : Garder le style natif (ligne + point) avec couleur brand - -**Rationale** : -- EventCalendar inclut déjà un cercle via `::before` -- Correspond à l'Option B identifiée (Google Calendar style) -- Seule la couleur nécessite un override - -## Risks / Trade-offs - -**[Couplage avec la structure DOM d'EventCalendar]** -→ Les sélecteurs CSS comme `.ec-col-head.ec-today time` dépendent de la structure HTML interne. Une mise à jour de la lib pourrait casser les styles. Mitigation : épingler la version et tester lors des mises à jour. - -**[Synchronisation toolbar ↔ calendrier]** -→ La toolbar custom doit rester synchronisée avec l'état interne du calendrier (vue courante, dates). Mitigation : utiliser `getView()` et le callback `datesSet` pour maintenir la sync. - -**[Performance des overrides CSS]** -→ Les sélecteurs imbriqués peuvent avoir un léger impact. Mitigation : sélecteurs simples, éviter les sélecteurs universels. diff --git a/openspec/changes/archive/2026-01-28-calendar-central-design/proposal.md b/openspec/changes/archive/2026-01-28-calendar-central-design/proposal.md deleted file mode 100644 index 5913c32..0000000 --- a/openspec/changes/archive/2026-01-28-calendar-central-design/proposal.md +++ /dev/null @@ -1,34 +0,0 @@ -## Why - -L'application calendrier n'a actuellement aucun design établi. Le composant EventCalendar utilise les styles par défaut de la bibliothèque @event-calendar/core qui ne correspondent pas à l'identité visuelle de La Suite (design system Cunningham). Cette première itération vise à créer un design épuré et moderne pour le calendrier central. - -## What Changes - -- Création d'une toolbar custom React remplaçant la toolbar native d'EventCalendar -- Override des variables CSS `--ec-*` pour utiliser les tokens Cunningham -- Override des classes CSS pour personnaliser les événements, headers de jours, sidebar -- Style "aujourd'hui" avec encadré fin (sans fond coloré) -- Événements avec coins arrondis, sans ombre, titre en gras -- Now indicator avec couleur brand -- Sidebar heures avec style discret - -## Capabilities - -### New Capabilities - -- `calendar-theme`: Thème visuel du calendrier central aligné sur le design system Cunningham. Couvre les variables CSS, les overrides de classes, et les styles des événements. -- `scheduler-toolbar`: Toolbar custom React pour la navigation et le changement de vue du calendrier. Remplace la toolbar native d'EventCalendar. - -### Modified Capabilities - -_(Aucune capability existante modifiée)_ - -## Impact - -- **Frontend** : Nouveaux fichiers SCSS et composants React dans `src/frontend/apps/calendars/src/features/calendar/components/scheduler/` -- **Fichiers modifiés** : - - `useSchedulerInit.ts` : désactivation de la toolbar native (`headerToolbar: false`) - - `Scheduler.tsx` : intégration de la toolbar custom - - `globals.scss` : import du nouveau fichier de thème -- **Dépendances** : Utilise les composants Cunningham existants (Button, Select) -- **API EventCalendar** : Utilise les méthodes `prev()`, `next()`, `setOption()`, `getView()` diff --git a/openspec/changes/archive/2026-01-28-calendar-central-design/specs/calendar-theme/spec.md b/openspec/changes/archive/2026-01-28-calendar-central-design/specs/calendar-theme/spec.md deleted file mode 100644 index 90e0d6a..0000000 --- a/openspec/changes/archive/2026-01-28-calendar-central-design/specs/calendar-theme/spec.md +++ /dev/null @@ -1,113 +0,0 @@ -## ADDED Requirements - -### Requirement: Variables CSS Cunningham - -Le thème DOIT remapper les variables CSS d'EventCalendar (`--ec-*`) vers les tokens Cunningham (`--c--globals--*`). - -#### Scenario: Couleurs de base appliquées -- **WHEN** le calendrier est rendu -- **THEN** le fond utilise `--c--globals--colors--gray-000` -- **AND** les bordures utilisent `--c--globals--colors--gray-100` -- **AND** le texte utilise `--c--globals--colors--gray-800` - -#### Scenario: Dark mode automatique -- **WHEN** le système est en dark mode -- **THEN** les couleurs s'adaptent automatiquement via les tokens Cunningham - ---- - -### Requirement: Style du jour actuel - -Le jour actuel DOIT être affiché avec un encadré fin autour du numéro, sans fond coloré. - -#### Scenario: Header du jour actuel -- **WHEN** un jour est le jour actuel -- **THEN** le numéro du jour est entouré d'une bordure fine (1px) -- **AND** la bordure a un border-radius de 4px -- **AND** le fond de la colonne header reste transparent - ---- - -### Requirement: Style des événements - -Les événements DOIVENT avoir un style épuré avec coins arrondis. - -#### Scenario: Apparence d'un événement -- **WHEN** un événement est affiché dans la grille -- **THEN** il a un border-radius de 6px -- **AND** il n'a pas de box-shadow -- **AND** le titre est en font-weight 600 (semi-bold) -- **AND** l'horaire est en font-weight 400 avec légère opacité - -#### Scenario: Couleur d'un événement -- **WHEN** un événement appartient à un calendrier -- **THEN** il prend la couleur de ce calendrier en fond -- **AND** le texte est blanc - ---- - -### Requirement: Style du now indicator - -L'indicateur de l'heure actuelle DOIT utiliser la couleur brand. - -#### Scenario: Apparence du now indicator -- **WHEN** l'heure actuelle est visible dans la vue -- **THEN** une ligne horizontale avec un point est affichée -- **AND** la couleur est `--c--globals--colors--brand-500` - ---- - -### Requirement: Style de la sidebar heures - -La sidebar affichant les heures DOIT avoir un style discret. - -#### Scenario: Apparence des labels d'heure -- **WHEN** la sidebar des heures est affichée -- **THEN** la font-size est réduite (0.75rem) -- **AND** la couleur est `--c--globals--colors--gray-500` - ---- - -### Requirement: Toolbar native masquée - -La toolbar native d'EventCalendar DOIT être masquée. - -#### Scenario: Toolbar native invisible -- **WHEN** le calendrier est rendu -- **THEN** l'élément `.ec-toolbar` a `display: none` - ---- - -### Requirement: Lignes de grille simplifiées - -Les lignes de grille DOIVENT afficher uniquement les heures pleines. - -#### Scenario: Pas de lignes intermédiaires -- **WHEN** la vue semaine ou jour est affichée -- **THEN** seules les lignes horaires (chaque heure) sont visibles -- **AND** les lignes intermédiaires (30 min) sont masquées - ---- - -### Requirement: Header unifié - -Le header (en-têtes de colonnes + section all-day) DOIT avoir un aspect unifié. - -#### Scenario: Pas de bordures internes -- **WHEN** le header est affiché -- **THEN** les éléments `.ec-col-head` et `.ec-all-day` n'ont pas de bordures -- **AND** les `.ec-day` dans `.ec-all-day` n'ont pas de bordure droite - -#### Scenario: Sidebar header masquée -- **WHEN** le header est affiché -- **THEN** le texte du timezone dans `.ec-sidebar` du header est invisible - ---- - -### Requirement: Bordure de grille - -La grille du body DOIT avoir une bordure en bas et à droite. - -#### Scenario: Bordures de la grille -- **WHEN** la grille du calendrier est affichée -- **THEN** `.ec-grid` dans `.ec-body` a une bordure bottom et right diff --git a/openspec/changes/archive/2026-01-28-calendar-central-design/specs/scheduler-toolbar/spec.md b/openspec/changes/archive/2026-01-28-calendar-central-design/specs/scheduler-toolbar/spec.md deleted file mode 100644 index 3af9e7c..0000000 --- a/openspec/changes/archive/2026-01-28-calendar-central-design/specs/scheduler-toolbar/spec.md +++ /dev/null @@ -1,115 +0,0 @@ -## ADDED Requirements - -### Requirement: Layout de la toolbar - -La toolbar DOIT afficher les éléments de navigation et de sélection de vue. - -#### Scenario: Structure de la toolbar -- **WHEN** la toolbar est affichée -- **THEN** elle contient à gauche : bouton "Aujourd'hui", boutons de navigation (précédent/suivant) -- **AND** elle contient au centre : le titre de la période (ex: "janv. – févr. 2026") -- **AND** elle contient à droite : un dropdown pour sélectionner la vue - ---- - -### Requirement: Bouton Aujourd'hui - -Le bouton "Aujourd'hui" DOIT permettre de revenir à la date du jour. - -#### Scenario: Clic sur Aujourd'hui -- **WHEN** l'utilisateur clique sur "Aujourd'hui" -- **THEN** le calendrier navigue vers la date actuelle -- **AND** la vue reste inchangée - -#### Scenario: Style du bouton -- **WHEN** le bouton "Aujourd'hui" est affiché -- **THEN** il a un style "pill" (bordure arrondie) -- **AND** il utilise les styles Cunningham - ---- - -### Requirement: Navigation précédent/suivant - -Les boutons de navigation DOIVENT permettre de naviguer dans le temps. - -#### Scenario: Clic sur précédent -- **WHEN** l'utilisateur clique sur le bouton précédent (◀) -- **THEN** le calendrier navigue vers la période précédente - -#### Scenario: Clic sur suivant -- **WHEN** l'utilisateur clique sur le bouton suivant (▶) -- **THEN** le calendrier navigue vers la période suivante - -#### Scenario: Style des boutons de navigation -- **WHEN** les boutons de navigation sont affichés -- **THEN** ils sont des IconButtons avec flèches -- **AND** ils utilisent les styles Cunningham - ---- - -### Requirement: Titre de la période - -Le titre DOIT afficher la période actuellement visible. - -#### Scenario: Affichage du titre -- **WHEN** la vue est en mode semaine ou jour -- **THEN** le titre affiche le mois et l'année (ex: "janv. – févr. 2026") - -#### Scenario: Mise à jour du titre -- **WHEN** l'utilisateur navigue vers une autre période -- **THEN** le titre se met à jour pour refléter la nouvelle période - ---- - -### Requirement: Sélecteur de vue - -Le dropdown DOIT permettre de changer de vue via un menu déroulant custom. - -#### Scenario: Options disponibles -- **WHEN** l'utilisateur clique sur le bouton trigger -- **THEN** un menu déroulant s'ouvre avec les options : Jour, Semaine, Mois, Liste -- **AND** l'option sélectionnée est mise en évidence avec un checkmark - -#### Scenario: Changement de vue -- **WHEN** l'utilisateur sélectionne une vue différente -- **THEN** le calendrier change pour afficher cette vue -- **AND** le menu se ferme -- **AND** le bouton trigger affiche la vue sélectionnée - -#### Scenario: Vue par défaut -- **WHEN** le calendrier est chargé -- **THEN** la vue "Semaine" est sélectionnée par défaut - -#### Scenario: Fermeture du menu -- **WHEN** l'utilisateur clique en dehors du menu -- **THEN** le menu se ferme - ---- - -### Requirement: Accessibilité clavier - -Le sélecteur de vue DOIT être accessible au clavier. - -#### Scenario: Navigation clavier -- **WHEN** le menu est ouvert -- **THEN** les touches ArrowUp/ArrowDown permettent de naviguer entre les options -- **AND** la touche Enter sélectionne l'option focalisée -- **AND** la touche Escape ferme le menu - -#### Scenario: Focus visible -- **WHEN** une option est focalisée par le clavier -- **THEN** elle a un style visuel distinct (outline) - ---- - -### Requirement: Synchronisation avec le calendrier - -La toolbar DOIT rester synchronisée avec l'état du calendrier. - -#### Scenario: Sync après navigation -- **WHEN** le calendrier change de période (via drag ou autre) -- **THEN** le titre de la toolbar se met à jour - -#### Scenario: Sync après changement de vue externe -- **WHEN** la vue du calendrier change par un autre moyen -- **THEN** le dropdown affiche la vue correcte diff --git a/openspec/changes/archive/2026-01-28-calendar-central-design/tasks.md b/openspec/changes/archive/2026-01-28-calendar-central-design/tasks.md deleted file mode 100644 index e14c865..0000000 --- a/openspec/changes/archive/2026-01-28-calendar-central-design/tasks.md +++ /dev/null @@ -1,62 +0,0 @@ -## 1. Setup fichiers de thème - -- [x] 1.1 Créer le fichier `scheduler-theme.scss` dans `src/frontend/apps/calendars/src/features/calendar/components/scheduler/` -- [x] 1.2 Ajouter l'import de `scheduler-theme.scss` dans `globals.scss` - -## 2. Variables CSS et overrides de base - -- [x] 2.1 Définir les variables CSS `--ec-*` mappées vers les tokens Cunningham -- [x] 2.2 Ajouter le style `.ec-toolbar { display: none }` pour masquer la toolbar native - -## 3. Style du jour actuel - -- [x] 3.1 Override `.ec-col-head.ec-today` pour fond transparent -- [x] 3.2 Ajouter le style encadré sur `.ec-col-head.ec-today time` - -## 4. Style des événements - -- [x] 4.1 Override `.ec-event` : border-radius 6px, box-shadow none, padding ajusté -- [x] 4.2 Override `.ec-event-title` : font-weight 600 -- [x] 4.3 Override `.ec-event-time` : font-weight 400, opacity 0.95 - -## 5. Style du now indicator et sidebar - -- [x] 5.1 Override couleur du now indicator vers brand-500 -- [x] 5.2 Override `.ec-sidebar` : font-size 0.75rem, couleur gray-500 - -## 6. Composant SchedulerToolbar - -- [x] 6.1 Créer le fichier `SchedulerToolbar.tsx` -- [x] 6.2 Créer le fichier `SchedulerToolbar.scss` -- [x] 6.3 Implémenter le bouton "Aujourd'hui" avec appel à `setOption('date', new Date())` -- [x] 6.4 Implémenter les boutons de navigation avec appels à `prev()` et `next()` -- [x] 6.5 Implémenter le titre dynamique de la période avec `getView()` -- [x] 6.6 Implémenter le dropdown des vues avec `setOption('view', ...)` - -## 7. Intégration - -- [x] 7.1 Modifier `useSchedulerInit.ts` : ajouter `headerToolbar: false` -- [x] 7.2 Modifier `Scheduler.tsx` : intégrer `` au-dessus du container -- [x] 7.3 Passer la ref du calendrier à la toolbar pour accéder aux méthodes API - -## 8. Synchronisation toolbar ↔ calendrier - -- [x] 8.1 Utiliser le callback `datesSet` pour mettre à jour le titre de la toolbar -- [x] 8.2 Synchroniser le dropdown avec la vue courante - -## 9. Améliorations post-implémentation - -- [x] 9.1 Supprimer les lignes intermédiaires (30 min) de la grille -- [x] 9.2 Unifier le header (ec-col-head + ec-all-day) sans bordures internes -- [x] 9.3 Masquer le texte timezone dans ec-sidebar du header -- [x] 9.4 Ajouter bordure bottom/right à ec-grid -- [x] 9.5 Remplacer Select par DropdownMenu custom pour le sélecteur de vue -- [x] 9.6 Ajouter navigation clavier au dropdown (Escape, Arrow, Enter) -- [x] 9.7 Ajouter les traductions calendar.navigation.previous/next (EN, FR, NL) -- [x] 9.8 Corriger le type CalendarApi dans CalendarContext -- [x] 9.9 Mémoiser les handlers avec useCallback - -## 10. Vérification - -- [x] 10.1 Vérifier le rendu visuel sur les 4 vues (Jour, Semaine, Mois, Liste) -- [x] 10.2 Vérifier la navigation et le changement de vue diff --git a/openspec/changes/fix-timezone-double-conversion/.openspec.yaml b/openspec/changes/fix-timezone-double-conversion/.openspec.yaml deleted file mode 100644 index e85ca8e..0000000 --- a/openspec/changes/fix-timezone-double-conversion/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-01-29 diff --git a/openspec/changes/fix-timezone-double-conversion/design.md b/openspec/changes/fix-timezone-double-conversion/design.md deleted file mode 100644 index 519c0ff..0000000 --- a/openspec/changes/fix-timezone-double-conversion/design.md +++ /dev/null @@ -1,100 +0,0 @@ -## Context - -L'application utilise un pattern "fake UTC" pour communiquer avec la librairie `ts-ics`. Ce pattern consiste à créer des objets `Date` JavaScript dont les composants UTC (`getUTCHours()`, etc.) représentent l'heure locale voulue, car `ts-ics` utilise `getUTCHours()` pour générer les chaînes ICS. - -Le problème : ce pattern fuit dans le chemin d'affichage. La méthode `icsDateToJsDate()` retourne `icsDate.local.date` (un fake UTC) et `dateToLocalISOString()` appelle `getHours()` dessus. Le navigateur ajoute alors son propre offset timezone, ce qui double la conversion. - -``` -ts-ics parse: DTSTART;TZID=Europe/Paris:20260129T150000 - - date = Date(UTC 14:00) ← vrai UTC (15h Paris - 1h offset) - local.date = Date(UTC 15:00) ← fake UTC (getUTCHours=15) - -Actuel (bugué): - icsDateToJsDate() → local.date → Date(UTC 15:00) - dateToLocalISOString() → getHours() → 16h (navigateur ajoute +1h) - Affichage: 16:00 ❌ - -Corrigé: - icsDateToJsDate() → date → Date(UTC 14:00) - dateToLocalISOString() → getHours() → 15h (navigateur convertit correctement) - Affichage: 15:00 ✅ -``` - -## Goals / Non-Goals - -**Goals:** - -- Corriger l'affichage des événements dans le scheduler (suppression du décalage timezone) -- Gérer correctement les événements cross-timezone (event créé à New York, vu depuis Paris) -- Gérer correctement les transitions DST (heure d'été/hiver) -- Confiner le pattern fake UTC au strict minimum : le point d'entrée vers `ts-ics` - -**Non-Goals:** - -- Remplacer `ts-ics` par une autre librairie -- Brancher le champ `timezone` du profil utilisateur (prévu mais hors scope) -- Ajouter un sélecteur de timezone dans l'UI -- Modifier le backend Django ou le CalDAV server SabreDAV -- Modifier l'EventModal ou les dateFormatters (ils fonctionnent correctement) - -## Decisions - -### Decision 1 : Retourner le vrai UTC dans `icsDateToJsDate()` - -**Choix** : Retourner `icsDate.date` (vrai UTC) au lieu de `icsDate.local.date` (fake UTC). - -**Rationale** : `dateToLocalISOString()` utilise `getHours()` qui applique automatiquement l'offset du navigateur. Avec un vrai UTC en entrée, la conversion navigateur donne directement l'heure locale correcte. Plus besoin de flag `isFakeUtc` dans le chemin d'affichage. - -**Alternative rejetée** : Modifier `dateToLocalISOString()` pour utiliser `getUTCHours()` quand la date est fake UTC. Rejeté car cela propagerait le concept de fake UTC plus loin dans le code au lieu de le contenir. - -### Decision 2 : Utiliser `Intl.DateTimeFormat` pour la conversion timezone dans `jsDateToIcsDate()` - -**Choix** : Ajouter un helper `getDateComponentsInTimezone(date, timezone)` qui utilise `Intl.DateTimeFormat` avec le paramètre `timeZone` pour extraire les composants (year, month, day, hours, minutes, seconds) dans le timezone cible. Utiliser ces composants pour créer le fake UTC destiné à `ts-ics`. - -**Rationale** : C'est la seule approche qui gère correctement : -1. **Même timezone** : event créé et vu en France → `Intl(tz=Europe/Paris)` donne l'heure locale française -2. **Cross-timezone** : event créé à NY, vu en France → `Intl(tz=America/New_York)` donne l'heure new-yorkaise -3. **DST** : transitions automatiquement gérées par le moteur `Intl` du navigateur - -**Alternative rejetée** : Calculer l'offset manuellement avec `getTimezoneOffset()`. Rejeté car ne gère pas les transitions DST correctement pour les timezones arbitraires. - -### Decision 3 : Conserver le fake UTC au point d'entrée ts-ics - -**Choix** : Le fake UTC reste dans `jsDateToIcsDate()` (adapter) et `handleSave()` (EventModal) — les deux seuls endroits qui produisent des `IcsEvent` pour `ts-ics`. - -**Rationale** : `ts-ics` utilise `date.getUTCHours()` pour générer les chaînes ICS (`DTSTART;TZID=Europe/Paris:20260129T150000`). C'est une contrainte de la librairie qui ne peut pas être contournée sans la forker. Le fake UTC est le pattern correct pour cette interface — le problème n'était pas le pattern lui-même, mais sa fuite dans le chemin d'affichage. - -``` - Frontière fake UTC - │ - Affichage (vrai UTC) │ ts-ics (fake UTC) - ─────────────────────────┼─────────────────── - icsDateToJsDate() │ jsDateToIcsDate() - dateToLocalISOString() │ handleSave() - EventCalendar UI │ generateIcsCalendar() - │ - getHours() → local OK │ getUTCHours() → local OK -``` - -### Decision 4 : Supprimer le paramètre `isFakeUtc` de `jsDateToIcsDate()` - -**Choix** : Le paramètre `isFakeUtc` est remplacé par la conversion explicite via `Intl.DateTimeFormat`. La méthode reçoit toujours un vrai UTC (ou une Date en heure locale du navigateur — c'est le même objet JS, seule l'interprétation change) et le convertit dans le timezone cible. - -**Rationale** : L'ancien code avait deux chemins : -- `isFakeUtc = true` → passe la date telle quelle (suppose que les composants UTC sont déjà corrects) -- `isFakeUtc = false` → copie les composants locaux (`getHours()`) dans un nouveau `Date.UTC()` - -Le nouveau code n'a qu'un seul chemin : extraire les composants dans le timezone cible via `Intl`, puis créer le fake UTC. Cela élimine une catégorie entière de bugs (mauvaise valeur de `isFakeUtc`). - -## Risks / Trade-offs - -**[Régression EventModal]** → Le modal reçoit des IcsEvent avec des dates fake UTC (produites par `jsDateToIcsDate`). Il utilise `isFakeUtc` + `getUTCHours()` pour les lire. Ce chemin n'est pas modifié et reste correct. Vérifié : le `isFakeUtc` dans le modal détecte `event.start.local?.timezone`, qui est toujours présent sur les dates fake UTC produites par l'adapter. - -**[Performance Intl.DateTimeFormat]** → `Intl.DateTimeFormat` crée un formateur à chaque appel. Mitigation : impact négligeable car appelé uniquement à la sauvegarde (pas à chaque rendu). Si nécessaire, on peut cacher les formateurs par timezone. - -**[Navigateurs anciens]** → `Intl.DateTimeFormat` avec `formatToParts()` est supporté depuis Chrome 56, Firefox 51, Safari 11. Tous les navigateurs cibles de l'app le supportent. Déjà utilisé dans `getTimezoneOffset()`. - -**[Events sans TZID (UTC pur)]** → `DTSTART:20260129T150000Z` → ts-ics produit `date = Date(UTC 15:00)` sans propriété `local`. `icsDateToJsDate()` retourne déjà `date` dans ce cas. Aucun changement de comportement. - -**[Events all-day]** → `DTSTART;VALUE=DATE:20260129` → ts-ics produit `date = Date.UTC(2026, 0, 29)` sans `local`. `icsDateToJsDate()` retourne déjà `date`. `dateToDateOnlyString()` utilise `getUTCFullYear/Month/Date()`. Aucun changement de comportement. diff --git a/openspec/changes/fix-timezone-double-conversion/proposal.md b/openspec/changes/fix-timezone-double-conversion/proposal.md deleted file mode 100644 index 7c356c9..0000000 --- a/openspec/changes/fix-timezone-double-conversion/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -## Why - -Le scheduler affiche les événements avec un décalage d'une heure par rapport à l'heure réelle. La cause : une double application de l'offset timezone lors de la conversion des dates ICS vers l'affichage. Le pattern "fake UTC" utilisé pour communiquer avec ts-ics fuit dans le chemin d'affichage, où `local.date` (fake UTC) est lu avec `getHours()` (qui ajoute l'offset navigateur), créant un décalage de ±N heures selon la timezone. Ce bug corrompt aussi les données lors de drag & drop, car l'utilisateur corrige visuellement des positions déjà décalées. - -## What Changes - -- Corriger `icsDateToJsDate()` pour retourner le vrai UTC (`icsDate.date`) au lieu du fake UTC (`icsDate.local.date`), corrigeant l'affichage scheduler -- Ajouter un helper `getDateComponentsInTimezone(date, timezone)` utilisant `Intl.DateTimeFormat` pour extraire les composants date/heure dans n'importe quel timezone cible -- Refactorer `jsDateToIcsDate()` pour supprimer le paramètre `isFakeUtc` et utiliser le nouveau helper — le fake UTC n'est plus créé par copie aveugle mais par conversion explicite via Intl API -- Nettoyer `toIcsEvent()` pour supprimer la variable `isFakeUtc` et la logique conditionnelle associée -- Conserver le pattern fake UTC **uniquement** au point d'entrée vers ts-ics (`jsDateToIcsDate` et `EventModal.handleSave`), car ts-ics utilise `getUTCHours()` pour générer l'ICS — c'est une contrainte de la librairie, pas un choix d'architecture - -## Capabilities - -### New Capabilities - -- `timezone-conversion`: Gestion correcte des conversions de dates entre timezones dans l'adapter CalDAV ↔ EventCalendar, incluant le support cross-timezone et DST via Intl API - -### Modified Capabilities - -_(aucune capability existante modifiée au niveau des specs)_ - -## Impact - -- **Code frontend** : `EventCalendarAdapter.ts` (3 méthodes modifiées, 1 ajoutée), `toIcsEvent` simplifié -- **Pas de changement** : `EventModal.tsx`, `dateFormatters.ts`, `useSchedulerHandlers.ts`, backend Django, CalDAV server -- **Pas de changement d'API** : le format ICS envoyé au CalDAV server reste identique (DTSTART avec TZID) -- **Dépendances** : aucune nouvelle dépendance (utilise `Intl.DateTimeFormat` natif du navigateur) -- **Risque** : faible — le changement est isolé dans l'adapter, les tests existants couvrent le round-trip diff --git a/openspec/changes/fix-timezone-double-conversion/specs/timezone-conversion/spec.md b/openspec/changes/fix-timezone-double-conversion/specs/timezone-conversion/spec.md deleted file mode 100644 index e0de7f2..0000000 --- a/openspec/changes/fix-timezone-double-conversion/specs/timezone-conversion/spec.md +++ /dev/null @@ -1,170 +0,0 @@ -## ADDED Requirements - -### Requirement: Affichage correct des événements timezone-aware dans le scheduler - -Le système SHALL afficher les événements avec TZID à l'heure locale correcte du navigateur dans le scheduler. La méthode `icsDateToJsDate()` MUST retourner `icsDate.date` (vrai UTC) pour que `dateToLocalISOString()` produise l'heure locale correcte via `getHours()`. - -#### Scenario: Événement Europe/Paris vu depuis la France - -- **WHEN** un événement `DTSTART;TZID=Europe/Paris:20260129T150000` est affiché dans un navigateur en France (UTC+1 hiver) -- **THEN** le scheduler affiche l'événement à 15:00 - -#### Scenario: Événement UTC pur vu depuis la France - -- **WHEN** un événement `DTSTART:20260129T140000Z` est affiché dans un navigateur en France (UTC+1 hiver) -- **THEN** le scheduler affiche l'événement à 15:00 - -#### Scenario: Événement all-day - -- **WHEN** un événement `DTSTART;VALUE=DATE:20260129` est affiché -- **THEN** le scheduler affiche l'événement le 29 janvier sans décalage de jour - -### Requirement: Conversion cross-timezone correcte à l'écriture - -Le système SHALL convertir les dates de l'heure locale du navigateur vers le timezone cible de l'événement lors de la sauvegarde. La conversion MUST utiliser `Intl.DateTimeFormat` avec le paramètre `timeZone` pour extraire les composants date/heure dans le timezone cible. - -#### Scenario: Sauvegarde d'un événement local (même timezone) - -- **WHEN** un utilisateur en France crée un événement à 15:00 avec timezone `Europe/Paris` -- **THEN** le système génère `DTSTART;TZID=Europe/Paris:20260129T150000` - -#### Scenario: Sauvegarde d'un événement cross-timezone sans modification - -- **WHEN** un événement `DTSTART;TZID=America/New_York:20260129T100000` est ouvert et sauvegardé sans modification depuis un navigateur en France -- **THEN** le système génère `DTSTART;TZID=America/New_York:20260129T100000` (heure NY préservée) - -#### Scenario: Drag & drop d'un événement cross-timezone - -- **WHEN** un événement `DTSTART;TZID=America/New_York:20260129T100000` affiché à 16:00 heure de Paris est déplacé à 17:00 sur le scheduler -- **THEN** le système génère `DTSTART;TZID=America/New_York:20260129T110000` (déplacement de +1h dans le timezone NY) - -### Requirement: Gestion correcte des transitions DST - -Le système SHALL gérer correctement les événements qui traversent une transition d'heure d'été/hiver. La conversion MUST utiliser `Intl.DateTimeFormat` qui résout automatiquement l'offset DST pour la date spécifique. - -#### Scenario: Événement en été vu depuis l'hiver - -- **WHEN** un événement `DTSTART;TZID=Europe/Paris:20260715T150000` (CEST, UTC+2) est affiché dans un navigateur en France en janvier (CET, UTC+1) -- **THEN** le scheduler affiche l'événement à 15:00 (l'heure Paris est préservée indépendamment du DST du navigateur) - -#### Scenario: Round-trip d'un événement été - -- **WHEN** un événement `DTSTART;TZID=Europe/Paris:20260715T150000` est ouvert et sauvegardé sans modification -- **THEN** le système génère `DTSTART;TZID=Europe/Paris:20260715T150000` (offset CEST correctement calculé par Intl) - -### Requirement: Helper getDateComponentsInTimezone - -Le système SHALL fournir une méthode `getDateComponentsInTimezone(date: Date, timezone: string)` qui retourne les composants (year, month, day, hours, minutes, seconds) d'un instant UTC dans le timezone cible. Cette méthode MUST utiliser `Intl.DateTimeFormat` avec `formatToParts()`. - -#### Scenario: Extraction des composants Europe/Paris en hiver - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T14:00:00Z"), "Europe/Paris")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 0, seconds: 0 }` - -#### Scenario: Extraction des composants America/New_York en hiver - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T15:00:00Z"), "America/New_York")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 10, minutes: 0, seconds: 0 }` - -#### Scenario: Extraction des composants Europe/Paris en été (CEST) - -- **WHEN** `getDateComponentsInTimezone(Date("2026-07-15T13:00:00Z"), "Europe/Paris")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 7, day: 15, hours: 15, minutes: 0, seconds: 0 }` (UTC+2 en été) - -#### Scenario: Extraction des composants Asia/Tokyo (pas de DST) - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T06:00:00Z"), "Asia/Tokyo")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 0, seconds: 0 }` (UTC+9, jamais de DST) - -#### Scenario: Changement de jour par conversion timezone (UTC tard → jour suivant en avance) - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T23:00:00Z"), "Asia/Tokyo")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 30, hours: 8, minutes: 0, seconds: 0 }` (23h UTC + 9h = 8h le lendemain) - -#### Scenario: Changement de jour par conversion timezone (UTC tôt → jour précédent en retard) - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T03:00:00Z"), "America/New_York")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 28, hours: 22, minutes: 0, seconds: 0 }` (3h UTC - 5h = 22h la veille) - -#### Scenario: Changement d'année par conversion timezone - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-01T00:30:00Z"), "America/Los_Angeles")` est appelé -- **THEN** le résultat contient `{ year: 2025, month: 12, day: 31, hours: 16, minutes: 30, seconds: 0 }` (UTC-8 en hiver) - -#### Scenario: UTC comme timezone cible - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T15:30:45Z"), "UTC")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 30, seconds: 45 }` - -#### Scenario: Minutes et secondes non-zéro - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T14:37:42Z"), "Europe/Paris")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 37, seconds: 42 }` - -#### Scenario: Offset demi-heure (Inde) - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T10:00:00Z"), "Asia/Kolkata")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 30, seconds: 0 }` (UTC+5:30) - -#### Scenario: Offset 45 minutes (Népal) - -- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T10:00:00Z"), "Asia/Kathmandu")` est appelé -- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 45, seconds: 0 }` (UTC+5:45) - -#### Scenario: Proche de la transition DST Europe/Paris (dernier dimanche de mars) - -- **WHEN** `getDateComponentsInTimezone(Date("2026-03-29T00:30:00Z"), "Europe/Paris")` est appelé (avant transition, encore CET) -- **THEN** le résultat contient `{ hours: 1, minutes: 30 }` (UTC+1) - -#### Scenario: Après la transition DST Europe/Paris - -- **WHEN** `getDateComponentsInTimezone(Date("2026-03-29T02:00:00Z"), "Europe/Paris")` est appelé (après transition, CEST) -- **THEN** le résultat contient `{ hours: 4, minutes: 0 }` (UTC+2) - -### Requirement: Confinement du fake UTC à l'interface ts-ics - -Le pattern fake UTC (objets Date dont les composants UTC représentent l'heure locale) MUST être confiné à deux endroits uniquement : `jsDateToIcsDate()` dans l'adapter et `handleSave()` dans l'EventModal. Aucun autre code NE DOIT créer ou consommer de dates fake UTC pour l'affichage. - -#### Scenario: Le chemin d'affichage n'utilise pas de fake UTC - -- **WHEN** `icsDateToJsDate()` est appelé avec un `IcsDateObject` ayant une propriété `local` -- **THEN** la méthode retourne `icsDate.date` (vrai UTC), PAS `icsDate.local.date` (fake UTC) - -#### Scenario: jsDateToIcsDate produit du fake UTC pour ts-ics - -- **WHEN** `jsDateToIcsDate()` reçoit `Date("2026-01-29T14:00:00Z")` avec timezone `"Europe/Paris"` -- **THEN** l'objet retourné contient `date` avec `getUTCHours() === 15` (fake UTC pour ts-ics) - -### Requirement: Tests unitaires exhaustifs des utilitaires de conversion timezone - -Le changement MUST inclure un fichier de tests dédié (`__tests__/timezone-conversion.test.ts`) couvrant tous les scénarios de conversion. Les tests MUST utiliser des dates UTC explicites pour être déterministes indépendamment de la timezone de la machine de CI. - -#### Scenario: Tests de getDateComponentsInTimezone couvrent toutes les catégories - -- **WHEN** la suite de tests est exécutée -- **THEN** les tests couvrent : timezones positives (Europe/Paris, Asia/Tokyo), timezones négatives (America/New_York, America/Los_Angeles), timezone UTC, offsets demi-heure (Asia/Kolkata UTC+5:30), offsets 45min (Asia/Kathmandu UTC+5:45), changements de jour par conversion, changement d'année par conversion, transitions DST (CET→CEST mars, CEST→CET octobre), minutes et secondes non-zéro - -#### Scenario: Tests de icsDateToJsDate couvrent la correction du bug - -- **WHEN** la suite de tests est exécutée -- **THEN** les tests vérifient que `icsDateToJsDate()` retourne `icsDate.date` quand `local` est présent, retourne `icsDate.date` quand `local` est absent, retourne `icsDate.date` pour les DATE type (all-day) - -#### Scenario: Tests de jsDateToIcsDate couvrent la conversion timezone correcte - -- **WHEN** la suite de tests est exécutée -- **THEN** les tests vérifient : all-day event produit DATE type, timed event produit DATE-TIME avec timezone, fake UTC a les bons composants UTC pour Europe/Paris hiver, fake UTC a les bons composants UTC pour America/New_York hiver, fake UTC a les bons composants UTC pour Asia/Tokyo, fake UTC a les bons composants UTC pour Europe/Paris été (DST), fake UTC préserve minutes et secondes - -#### Scenario: Tests de round-trip (parse → adapter → display → adapter → generate) - -- **WHEN** la suite de tests est exécutée -- **THEN** les tests vérifient le round-trip complet pour : événement Europe/Paris hiver, événement Europe/Paris été, événement America/New_York, événement Asia/Tokyo, événement UTC pur, événement all-day, événement cross-timezone (NY vu depuis Paris) - -#### Scenario: Tests de getTimezoneOffset couvrent les cas limites - -- **WHEN** la suite de tests est exécutée -- **THEN** les tests vérifient : offset positif (Europe/Paris → "+0100" hiver, "+0200" été), offset négatif (America/New_York → "-0500" hiver, "-0400" été), offset zéro (UTC → "+0000"), offset demi-heure (Asia/Kolkata → "+0530"), timezone invalide retourne "+0000" - -#### Scenario: Les tests sont déterministes en CI - -- **WHEN** les tests sont exécutés sur une machine de CI (timezone potentiellement différente) -- **THEN** tous les tests passent car ils utilisent uniquement des dates UTC explicites (`new Date("...Z")`) et des assertions sur `getUTCHours()` / `getUTCMinutes()` pour les fake UTC, jamais `getHours()` qui dépend de la timezone locale diff --git a/openspec/changes/fix-timezone-double-conversion/tasks.md b/openspec/changes/fix-timezone-double-conversion/tasks.md deleted file mode 100644 index b07a2d8..0000000 --- a/openspec/changes/fix-timezone-double-conversion/tasks.md +++ /dev/null @@ -1,80 +0,0 @@ -## 1. Helper de conversion timezone - -- [x] 1.1 Ajouter le type `DateComponents` (interface avec year, month, day, hours, minutes, seconds) dans `EventCalendarAdapter.ts` -- [x] 1.2 Ajouter la méthode `getDateComponentsInTimezone(date: Date, timezone: string)` dans `EventCalendarAdapter.ts` — utilise `Intl.DateTimeFormat` avec `formatToParts()` pour extraire les composants dans le timezone cible - -## 2. Fix du chemin de lecture (affichage) - -- [x] 2.1 Modifier `icsDateToJsDate()` dans `EventCalendarAdapter.ts` pour retourner `icsDate.date` au lieu de `icsDate.local?.date` — c'est le fix principal de l'affichage scheduler - -## 3. Fix du chemin d'écriture (sauvegarde) - -- [x] 3.1 Refactorer `jsDateToIcsDate()` dans `EventCalendarAdapter.ts` : supprimer le paramètre `isFakeUtc`, utiliser `getDateComponentsInTimezone()` pour obtenir les composants dans le timezone cible, créer le fake UTC avec `Date.UTC()` à partir de ces composants -- [x] 3.2 Modifier `toIcsEvent()` dans `EventCalendarAdapter.ts` : supprimer la variable `isFakeUtc` (ligne 347) et ne plus passer ce paramètre à `jsDateToIcsDate()` - -## 4. Tests unitaires — getDateComponentsInTimezone - -Créer `src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/timezone-conversion.test.ts` - -- [x] 4.1 Test Europe/Paris hiver : `Date("2026-01-29T14:00:00Z")` → `{ hours: 15 }` (CET, UTC+1) -- [x] 4.2 Test Europe/Paris été : `Date("2026-07-15T13:00:00Z")` → `{ hours: 15 }` (CEST, UTC+2) -- [x] 4.3 Test America/New_York hiver : `Date("2026-01-29T15:00:00Z")` → `{ hours: 10 }` (EST, UTC-5) -- [x] 4.4 Test America/New_York été : `Date("2026-07-15T14:00:00Z")` → `{ hours: 10 }` (EDT, UTC-4) -- [x] 4.5 Test Asia/Tokyo (pas de DST) : `Date("2026-01-29T06:00:00Z")` → `{ hours: 15 }` (JST, UTC+9) -- [x] 4.6 Test UTC : `Date("2026-01-29T15:30:45Z")` → `{ hours: 15, minutes: 30, seconds: 45 }` -- [x] 4.7 Test changement de jour (UTC tard → lendemain en avance) : `Date("2026-01-29T23:00:00Z")` + Asia/Tokyo → `{ day: 30, hours: 8 }` -- [x] 4.8 Test changement de jour (UTC tôt → veille en retard) : `Date("2026-01-29T03:00:00Z")` + America/New_York → `{ day: 28, hours: 22 }` -- [x] 4.9 Test changement d'année : `Date("2026-01-01T00:30:00Z")` + America/Los_Angeles → `{ year: 2025, month: 12, day: 31 }` -- [x] 4.10 Test offset demi-heure (Inde) : `Date("2026-01-29T10:00:00Z")` + Asia/Kolkata → `{ hours: 15, minutes: 30 }` (UTC+5:30) -- [x] 4.11 Test offset 45min (Népal) : `Date("2026-01-29T10:00:00Z")` + Asia/Kathmandu → `{ hours: 15, minutes: 45 }` (UTC+5:45) -- [x] 4.12 Test transition DST CET→CEST (mars) : vérifier avant et après la transition du dernier dimanche de mars -- [x] 4.13 Test transition DST CEST→CET (octobre) : vérifier avant et après la transition du dernier dimanche d'octobre -- [x] 4.14 Test minutes et secondes non-zéro : `Date("2026-01-29T14:37:42Z")` + Europe/Paris → `{ hours: 15, minutes: 37, seconds: 42 }` - -## 5. Tests unitaires — icsDateToJsDate (fix du bug) - -- [x] 5.1 Test : retourne `icsDate.date` (vrai UTC) quand `local` est présent — vérifie que c'est bien `date` et PAS `local.date` -- [x] 5.2 Test : retourne `icsDate.date` quand `local` est absent (événements UTC purs) -- [x] 5.3 Test : retourne `icsDate.date` pour les événements all-day (type DATE) - -## 6. Tests unitaires — jsDateToIcsDate (conversion timezone) - -- [x] 6.1 Test all-day : produit un `IcsDateObject` de type `DATE` sans timezone -- [x] 6.2 Test Europe/Paris hiver : `Date(UTC 14:00)` + tz Paris → fake UTC avec `getUTCHours() === 15` -- [x] 6.3 Test America/New_York hiver : `Date(UTC 15:00)` + tz NY → fake UTC avec `getUTCHours() === 10` -- [x] 6.4 Test Asia/Tokyo : `Date(UTC 06:00)` + tz Tokyo → fake UTC avec `getUTCHours() === 15` -- [x] 6.5 Test Europe/Paris été (DST) : `Date(UTC 13:00)` + tz Paris → fake UTC avec `getUTCHours() === 15` (CEST, UTC+2) -- [x] 6.6 Test préservation minutes/secondes : `Date(UTC 14:37:42)` + tz Paris → fake UTC avec `getUTCMinutes() === 37`, `getUTCSeconds() === 42` -- [x] 6.7 Test changement de jour : `Date(UTC 23:00)` + tz Tokyo → fake UTC avec `getUTCDate()` = jour suivant -- [x] 6.8 Test que `local.timezone` est correctement défini dans l'objet retourné -- [x] 6.9 Test que `local.tzoffset` est correctement calculé (format "+HHMM" / "-HHMM") - -## 7. Tests unitaires — getTimezoneOffset - -- [x] 7.1 Test offset positif hiver : Europe/Paris → "+0100" -- [x] 7.2 Test offset positif été : Europe/Paris → "+0200" -- [x] 7.3 Test offset négatif hiver : America/New_York → "-0500" -- [x] 7.4 Test offset négatif été : America/New_York → "-0400" -- [x] 7.5 Test offset zéro : UTC → "+0000" -- [x] 7.6 Test offset demi-heure : Asia/Kolkata → "+0530" -- [x] 7.7 Test timezone invalide : retourne "+0000" (fallback gracieux) - -## 8. Tests unitaires — Round-trip complet (parse ICS → adapter → display string → adapter → ICS) - -- [x] 8.1 Round-trip Europe/Paris hiver : parse `DTSTART;TZID=Europe/Paris:20260129T150000` → icsDateToJsDate → dateToLocalISOString → parse string → jsDateToIcsDate → vérifier `getUTCHours() === 15` -- [x] 8.2 Round-trip Europe/Paris été : idem avec `20260715T150000` (CEST) -- [x] 8.3 Round-trip America/New_York : parse `DTSTART;TZID=America/New_York:20260129T100000` → round-trip → vérifier `getUTCHours() === 10` -- [x] 8.4 Round-trip Asia/Tokyo : parse `DTSTART;TZID=Asia/Tokyo:20260129T150000` → round-trip → vérifier `getUTCHours() === 15` -- [x] 8.5 Round-trip UTC pur : parse `DTSTART:20260129T140000Z` → round-trip (pas de TZID, utilise browser tz) -- [x] 8.6 Round-trip all-day : parse `DTSTART;VALUE=DATE:20260129` → round-trip → vérifier `getUTCDate() === 29` -- [x] 8.7 Round-trip cross-timezone (NY créé, Paris affiché) : vérifier que l'heure NY est préservée après un round-trip depuis un browser Paris - -## 9. Mise à jour des tests existants - -- [x] 9.1 Mettre à jour le test `icsDateToJsDate` dans `event-calendar-helper.test.ts` (ligne 514-533) : le test "returns local date when present" doit maintenant vérifier que c'est `icsDate.date` (vrai UTC) qui est retourné, pas `local.date` - -## 10. Vérification finale - -- [x] 10.1 Vérifier que le TypeScript compile sans erreurs (`yarn tsc --noEmit`) -- [x] 10.2 Vérifier que le linter passe (`yarn lint`) -- [x] 10.3 Vérifier que tous les tests passent (`yarn test`) diff --git a/openspec/project.md b/openspec/project.md deleted file mode 100644 index 2408a53..0000000 --- a/openspec/project.md +++ /dev/null @@ -1,126 +0,0 @@ -## Project Overview - -La Suite Calendars is a modern calendar application for managing events and schedules. It's a full-stack application with: -- **Backend**: Django 5 REST API with PostgreSQL -- **Frontend**: Next.js 15 with React 19 -- **CalDAV Server**: SabreDAV (PHP-based) for calendar protocol support : https://sabre.io/dav/ -- **Authentication**: Keycloak OIDC provider - -In this project, you can create events, invite people to events, create calendars, and invite others to share and manage those calendars, allowing them to add and manage events as well. Every invitation sends an email with an ICS file attached; this also happens for event updates and cancellations. - -## Common Commands - -### Development Setup -```bash -make bootstrap # Initial setup: builds containers, runs migrations, starts services -make run # Start all services (backend + frontend containers) -make run-backend # Start backend services only (for local frontend development) -make stop # Stop all containers -make down # Stop and remove containers, networks, volumes -``` - -### Backend Development -```bash -make test-back -- path/to/test.py::TestClass::test_method # Run specific test -make test-back-parallel # Run all tests in parallel -make lint # Run ruff + pylint -make migrate # Run Django migrations -make makemigrations # Create new migrations -make shell # Django shell -make dbshell # PostgreSQL shell -``` - -### Frontend Development -```bash -make frontend-development-install # Install frontend dependencies locally -make run-frontend-development # Run frontend locally (after run-backend) -make frontend-lint # Run ESLint on frontend -cd src/frontend/apps/calendars && yarn test # Run frontend tests -cd src/frontend/apps/calendars && yarn test:watch # Watch mode -``` - -### E2E Tests -```bash -make run-tests-e2e # Run all e2e tests -make run-tests-e2e -- --project chromium --headed # Run with specific browser -``` - -### Internationalization -```bash -make i18n-generate # Generate translation files -make i18n-compile # Compile translations -make crowdin-upload # Upload sources to Crowdin -make crowdin-download # Download translations from Crowdin -``` - -## Architecture - -### Backend Structure (`src/backend/`) -- `calendars/` - Django project configuration, settings, Celery app -- `core/` - Main application code: - - `api/` - DRF viewsets and serializers - - `models.py` - Database models - - `services/` - Business logic - - `authentication/` - OIDC authentication - - `tests/` - pytest test files - -### Frontend Structure (`src/frontend/`) -Yarn workspaces monorepo: -- `apps/calendars/` - Main Next.js application - - `src/features/` - Feature modules (calendar, auth, api, i18n, etc.) - - `src/pages/` - Next.js pages - - `src/hooks/` - Custom React hooks -- `apps/e2e/` - Playwright end-to-end tests - -### CalDAV Server (`docker/sabredav/`) -PHP SabreDAV server providing CalDAV protocol support, running against the shared PostgreSQL database. - -### Service Ports (Development) -- Frontend: http://localhost:8920 -- Backend API: http://localhost:8921 -- CalDAV: http://localhost:8922 -- Keycloak: http://localhost:8925 -- PostgreSQL: 8912 -- Mailcatcher: http://localhost:1081 - -## Key Technologies - -### Backend -- Django 5 with Django REST Framework -- Celery with Redis for background tasks -- pytest for testing (use `bin/pytest` wrapper) -- Ruff for linting/formatting (100 char line length for pylint compatibility) - -### Frontend -- Next.js 15 with React 19 -- @tanstack/react-query for data fetching -- tsdav/ical.js/tsics for CalDAV client integration : https://tsdav.vercel.app/docs/intro / https://github.com/Neuvernetzung/ts-ics -- @gouvfr-lasuite/cunningham-react for UI components : https://github.com/suitenumerique/cunningham -- Jest for unit tests -- Playwright for e2e tests - -## Code Style - -### Python -- Follow PEP 8 with 100 character line limit -- Use Django REST Framework viewsets for APIs -- Business logic in models and services, keep views thin -- Use `select_related`/`prefetch_related` for query optimization - -### TypeScript/React -- Feature-based folder structure under `src/features/` -- Use React Query for server state management as possible, if it is not possible, don't worry. -- Use the vercel-react-best-practices skill when you write a react code -- Please, make many tiny files and separate components in differentes files -- Check for Lint and TypeScript errors before telling me that you have finished - -### Git - -- Maximum line length: 80 characters. -- Each commit must have a title and a description. -- The commit title should start with a Gitmoji, then the area in parentheses - (e.g. back, front, docs), then your chosen title. - -# Workflow -- Be sure to typecheck when you're done making a series of code changes -- Prefer running single tests, and not the whole test suite, for performance \ No newline at end of file diff --git a/openspec/specs/calendar-theme/spec.md b/openspec/specs/calendar-theme/spec.md deleted file mode 100644 index 90e0d6a..0000000 --- a/openspec/specs/calendar-theme/spec.md +++ /dev/null @@ -1,113 +0,0 @@ -## ADDED Requirements - -### Requirement: Variables CSS Cunningham - -Le thème DOIT remapper les variables CSS d'EventCalendar (`--ec-*`) vers les tokens Cunningham (`--c--globals--*`). - -#### Scenario: Couleurs de base appliquées -- **WHEN** le calendrier est rendu -- **THEN** le fond utilise `--c--globals--colors--gray-000` -- **AND** les bordures utilisent `--c--globals--colors--gray-100` -- **AND** le texte utilise `--c--globals--colors--gray-800` - -#### Scenario: Dark mode automatique -- **WHEN** le système est en dark mode -- **THEN** les couleurs s'adaptent automatiquement via les tokens Cunningham - ---- - -### Requirement: Style du jour actuel - -Le jour actuel DOIT être affiché avec un encadré fin autour du numéro, sans fond coloré. - -#### Scenario: Header du jour actuel -- **WHEN** un jour est le jour actuel -- **THEN** le numéro du jour est entouré d'une bordure fine (1px) -- **AND** la bordure a un border-radius de 4px -- **AND** le fond de la colonne header reste transparent - ---- - -### Requirement: Style des événements - -Les événements DOIVENT avoir un style épuré avec coins arrondis. - -#### Scenario: Apparence d'un événement -- **WHEN** un événement est affiché dans la grille -- **THEN** il a un border-radius de 6px -- **AND** il n'a pas de box-shadow -- **AND** le titre est en font-weight 600 (semi-bold) -- **AND** l'horaire est en font-weight 400 avec légère opacité - -#### Scenario: Couleur d'un événement -- **WHEN** un événement appartient à un calendrier -- **THEN** il prend la couleur de ce calendrier en fond -- **AND** le texte est blanc - ---- - -### Requirement: Style du now indicator - -L'indicateur de l'heure actuelle DOIT utiliser la couleur brand. - -#### Scenario: Apparence du now indicator -- **WHEN** l'heure actuelle est visible dans la vue -- **THEN** une ligne horizontale avec un point est affichée -- **AND** la couleur est `--c--globals--colors--brand-500` - ---- - -### Requirement: Style de la sidebar heures - -La sidebar affichant les heures DOIT avoir un style discret. - -#### Scenario: Apparence des labels d'heure -- **WHEN** la sidebar des heures est affichée -- **THEN** la font-size est réduite (0.75rem) -- **AND** la couleur est `--c--globals--colors--gray-500` - ---- - -### Requirement: Toolbar native masquée - -La toolbar native d'EventCalendar DOIT être masquée. - -#### Scenario: Toolbar native invisible -- **WHEN** le calendrier est rendu -- **THEN** l'élément `.ec-toolbar` a `display: none` - ---- - -### Requirement: Lignes de grille simplifiées - -Les lignes de grille DOIVENT afficher uniquement les heures pleines. - -#### Scenario: Pas de lignes intermédiaires -- **WHEN** la vue semaine ou jour est affichée -- **THEN** seules les lignes horaires (chaque heure) sont visibles -- **AND** les lignes intermédiaires (30 min) sont masquées - ---- - -### Requirement: Header unifié - -Le header (en-têtes de colonnes + section all-day) DOIT avoir un aspect unifié. - -#### Scenario: Pas de bordures internes -- **WHEN** le header est affiché -- **THEN** les éléments `.ec-col-head` et `.ec-all-day` n'ont pas de bordures -- **AND** les `.ec-day` dans `.ec-all-day` n'ont pas de bordure droite - -#### Scenario: Sidebar header masquée -- **WHEN** le header est affiché -- **THEN** le texte du timezone dans `.ec-sidebar` du header est invisible - ---- - -### Requirement: Bordure de grille - -La grille du body DOIT avoir une bordure en bas et à droite. - -#### Scenario: Bordures de la grille -- **WHEN** la grille du calendrier est affichée -- **THEN** `.ec-grid` dans `.ec-body` a une bordure bottom et right diff --git a/openspec/specs/scheduler-toolbar/spec.md b/openspec/specs/scheduler-toolbar/spec.md deleted file mode 100644 index 3af9e7c..0000000 --- a/openspec/specs/scheduler-toolbar/spec.md +++ /dev/null @@ -1,115 +0,0 @@ -## ADDED Requirements - -### Requirement: Layout de la toolbar - -La toolbar DOIT afficher les éléments de navigation et de sélection de vue. - -#### Scenario: Structure de la toolbar -- **WHEN** la toolbar est affichée -- **THEN** elle contient à gauche : bouton "Aujourd'hui", boutons de navigation (précédent/suivant) -- **AND** elle contient au centre : le titre de la période (ex: "janv. – févr. 2026") -- **AND** elle contient à droite : un dropdown pour sélectionner la vue - ---- - -### Requirement: Bouton Aujourd'hui - -Le bouton "Aujourd'hui" DOIT permettre de revenir à la date du jour. - -#### Scenario: Clic sur Aujourd'hui -- **WHEN** l'utilisateur clique sur "Aujourd'hui" -- **THEN** le calendrier navigue vers la date actuelle -- **AND** la vue reste inchangée - -#### Scenario: Style du bouton -- **WHEN** le bouton "Aujourd'hui" est affiché -- **THEN** il a un style "pill" (bordure arrondie) -- **AND** il utilise les styles Cunningham - ---- - -### Requirement: Navigation précédent/suivant - -Les boutons de navigation DOIVENT permettre de naviguer dans le temps. - -#### Scenario: Clic sur précédent -- **WHEN** l'utilisateur clique sur le bouton précédent (◀) -- **THEN** le calendrier navigue vers la période précédente - -#### Scenario: Clic sur suivant -- **WHEN** l'utilisateur clique sur le bouton suivant (▶) -- **THEN** le calendrier navigue vers la période suivante - -#### Scenario: Style des boutons de navigation -- **WHEN** les boutons de navigation sont affichés -- **THEN** ils sont des IconButtons avec flèches -- **AND** ils utilisent les styles Cunningham - ---- - -### Requirement: Titre de la période - -Le titre DOIT afficher la période actuellement visible. - -#### Scenario: Affichage du titre -- **WHEN** la vue est en mode semaine ou jour -- **THEN** le titre affiche le mois et l'année (ex: "janv. – févr. 2026") - -#### Scenario: Mise à jour du titre -- **WHEN** l'utilisateur navigue vers une autre période -- **THEN** le titre se met à jour pour refléter la nouvelle période - ---- - -### Requirement: Sélecteur de vue - -Le dropdown DOIT permettre de changer de vue via un menu déroulant custom. - -#### Scenario: Options disponibles -- **WHEN** l'utilisateur clique sur le bouton trigger -- **THEN** un menu déroulant s'ouvre avec les options : Jour, Semaine, Mois, Liste -- **AND** l'option sélectionnée est mise en évidence avec un checkmark - -#### Scenario: Changement de vue -- **WHEN** l'utilisateur sélectionne une vue différente -- **THEN** le calendrier change pour afficher cette vue -- **AND** le menu se ferme -- **AND** le bouton trigger affiche la vue sélectionnée - -#### Scenario: Vue par défaut -- **WHEN** le calendrier est chargé -- **THEN** la vue "Semaine" est sélectionnée par défaut - -#### Scenario: Fermeture du menu -- **WHEN** l'utilisateur clique en dehors du menu -- **THEN** le menu se ferme - ---- - -### Requirement: Accessibilité clavier - -Le sélecteur de vue DOIT être accessible au clavier. - -#### Scenario: Navigation clavier -- **WHEN** le menu est ouvert -- **THEN** les touches ArrowUp/ArrowDown permettent de naviguer entre les options -- **AND** la touche Enter sélectionne l'option focalisée -- **AND** la touche Escape ferme le menu - -#### Scenario: Focus visible -- **WHEN** une option est focalisée par le clavier -- **THEN** elle a un style visuel distinct (outline) - ---- - -### Requirement: Synchronisation avec le calendrier - -La toolbar DOIT rester synchronisée avec l'état du calendrier. - -#### Scenario: Sync après navigation -- **WHEN** le calendrier change de période (via drag ou autre) -- **THEN** le titre de la toolbar se met à jour - -#### Scenario: Sync après changement de vue externe -- **WHEN** la vue du calendrier change par un autre moyen -- **THEN** le dropdown affiche la vue correcte