commit
8a0cc97018
3 changed files with 657 additions and 15 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "simpl-liste",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simpl-liste",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.1",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo/ngrok": "^4.1.3",
|
||||
|
|
|
|||
603
spec-simpl-liste-web.md
Normal file
603
spec-simpl-liste-web.md
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
# Spec — Simpl-Liste Web
|
||||
|
||||
> Date: 2026-04-06
|
||||
> Projet: simpl-liste
|
||||
> Statut: Draft
|
||||
> Dependance: Logto IdP (deploye et operationnel)
|
||||
|
||||
## Contexte
|
||||
|
||||
Simpl-Liste est actuellement une app mobile React Native/Expo avec stockage SQLite local (offline-first, sans compte). L'objectif est de rendre l'application disponible via le web, integree au systeme d'authentification Compte Maximus (Logto), avec synchronisation bidirectionnelle entre le mobile et le web.
|
||||
|
||||
L'app mobile continue de fonctionner sans compte. Le login debloque la sync ; il reste optionnel.
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre aux utilisateurs connectes avec un Compte Maximus de gerer leurs listes et taches depuis `liste.lacompagniemaximus.com`, avec sync hybride : temps reel sur le web (WebSocket), polling sur le mobile. Parite fonctionnelle complete avec le mobile (hors widgets, notifications push, et sync calendrier).
|
||||
|
||||
## Scope
|
||||
|
||||
### IN
|
||||
- Frontend web Next.js App Router dans `simpl-liste/web/`
|
||||
- API REST backend (route handlers Next.js) pour CRUD listes, taches, tags, sous-taches
|
||||
- WebSocket server pour notifications temps reel sur le web
|
||||
- Schema PostgreSQL (`sl_` prefix) avec Drizzle ORM (`drizzle-orm/pg-core`)
|
||||
- Auth via Logto (Compte Maximus) — SDK `@logto/next`
|
||||
- Sync hybride : WebSocket web + polling mobile
|
||||
- Login optionnel dans l'app mobile avec choix de migration des donnees locales
|
||||
- Types TypeScript partages entre mobile et web (`src/shared/`)
|
||||
- i18n FR/EN (francais par defaut)
|
||||
- Dark mode (light/dark/system)
|
||||
- Responsive design (utilisable sur mobile)
|
||||
- Deploiement sur Coolify (`liste.lacompagniemaximus.com`)
|
||||
|
||||
### OUT (explicitement exclu)
|
||||
- Widgets Android (specifique mobile)
|
||||
- Notifications push (specifique mobile)
|
||||
- Sync calendrier systeme (specifique mobile via expo-calendar)
|
||||
- Export ICS depuis le web (future phase)
|
||||
- Mode offline pour le web (connexion requise)
|
||||
- Partage de listes entre utilisateurs (future phase)
|
||||
- App iOS (future phase)
|
||||
|
||||
## Design
|
||||
|
||||
### Architecture globale
|
||||
|
||||
```
|
||||
simpl-liste/
|
||||
├── app/ # App mobile React Native (existant)
|
||||
├── src/ # Code mobile (existant)
|
||||
│ ├── shared/ # NOUVEAU — types, constantes, logique partagee
|
||||
│ │ ├── types.ts # Task, List, Tag, TaskFilters, etc.
|
||||
│ │ ├── colors.ts # Palette (extrait de theme/colors.ts)
|
||||
│ │ ├── priority.ts # Helpers priorite (extrait de lib/priority.ts)
|
||||
│ │ └── recurrence.ts # Calcul recurrence (extrait de lib/recurrence.ts)
|
||||
│ └── ...
|
||||
└── web/ # NOUVEAU — App Next.js
|
||||
├── package.json
|
||||
├── next.config.ts
|
||||
├── server.ts # Custom server (Next.js + WebSocket)
|
||||
├── Dockerfile
|
||||
├── src/db/
|
||||
│ ├── schema.ts # Schema PostgreSQL Drizzle (sl_ tables)
|
||||
│ ├── client.ts # Connexion pg + drizzle instance
|
||||
│ └── migrations/ # Migrations SQL (drizzle-kit generate)
|
||||
├── src/
|
||||
│ ├── app/ # App Router (pages, layouts, API routes)
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── page.tsx # Redirect vers /lists ou /auth
|
||||
│ │ ├── auth/ # Login via Logto
|
||||
│ │ ├── api/ # Route handlers REST
|
||||
│ │ │ ├── lists/
|
||||
│ │ │ ├── tasks/
|
||||
│ │ │ ├── tags/
|
||||
│ │ │ └── sync/
|
||||
│ │ └── (app)/ # Pages authentifiees
|
||||
│ │ ├── layout.tsx # Sidebar listes + main area
|
||||
│ │ ├── inbox/
|
||||
│ │ └── lists/[id]/
|
||||
│ ├── lib/
|
||||
│ │ ├── auth.ts # Logto middleware + session
|
||||
│ │ ├── db.ts # Re-export drizzle client
|
||||
│ │ └── ws.ts # WebSocket server + broadcast
|
||||
│ ├── components/ # Composants React web
|
||||
│ └── i18n/ # next-intl ou i18next
|
||||
└── tailwind.config.ts # Palette partagee depuis src/shared/colors.ts
|
||||
```
|
||||
|
||||
### ORM unifie — Drizzle partout
|
||||
|
||||
Le projet utilise **Drizzle ORM** sur les deux plateformes :
|
||||
- **Mobile** : `drizzle-orm/sqlite-core` + `expo-sqlite` (existant)
|
||||
- **Web/serveur** : `drizzle-orm/pg-core` + `pg` (nouveau)
|
||||
|
||||
Un seul ORM a maitriser, un seul outil de migration (`drizzle-kit`), pas de codegen (contrairement a Prisma). Les schemas SQLite et PostgreSQL partagent la meme structure de colonnes — les types inferes par Drizzle (`$inferSelect`) sont directement compatibles.
|
||||
|
||||
Le dossier `src/shared/types.ts` exporte les types metier communs (filtres, tri, recurrence, priorites). Les types des entites (Task, List, Tag) sont inferes depuis les schemas Drizzle respectifs — pas besoin d'une couche de mapping manuelle.
|
||||
|
||||
```typescript
|
||||
// src/shared/types.ts — types partages (filtres, enums, helpers)
|
||||
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
export type Priority = 0 | 1 | 2 | 3;
|
||||
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
export type FilterCompleted = 'all' | 'active' | 'completed';
|
||||
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
|
||||
export interface TaskFilters { sortBy: SortBy; sortOrder: SortOrder; /* ... */ }
|
||||
|
||||
// Les types des entites sont inferes depuis les schemas Drizzle de chaque plateforme :
|
||||
// Mobile : typeof tasks.$inferSelect (depuis src/db/schema.ts, sqlite-core)
|
||||
// Web : typeof slTasks.$inferSelect (depuis web/src/db/schema.ts, pg-core)
|
||||
```
|
||||
|
||||
### Identite utilisateur — Compte Maximus
|
||||
|
||||
Le `userId` stocke dans les tables PostgreSQL est le **`sub` claim du JWT Logto**. Ce choix est delibere : Logto est le systeme d'identite centralise de La Compagnie Maximus. Toutes les apps actuelles et futures (la-suite-booking, famille-website, etc.) migreront vers Logto comme IdP unique. Utiliser le `sub` comme identifiant commun garantit la compatibilite cross-app et permettra un futur dashboard Compte Maximus unifie.
|
||||
|
||||
### Schema PostgreSQL (Drizzle pg-core)
|
||||
|
||||
Base sur le schema SQLite existant (`src/db/schema.ts`), avec ajout de `userId` (= `sub` Logto) pour l'isolation multi-utilisateur et `deletedAt` pour le soft-delete (sync). Structure quasi identique au schema mobile — meme ORM, memes conventions.
|
||||
|
||||
```typescript
|
||||
// web/src/db/schema.ts
|
||||
import { pgTable, uuid, text, integer, boolean, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const slLists = pgTable('sl_lists', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
color: text('color'),
|
||||
icon: text('icon'),
|
||||
position: integer('position').notNull().default(0),
|
||||
isInbox: boolean('is_inbox').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
index('idx_sl_lists_user').on(table.userId),
|
||||
]);
|
||||
|
||||
export const slTasks = pgTable('sl_tasks', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
notes: text('notes'),
|
||||
completed: boolean('completed').notNull().default(false),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
priority: integer('priority').notNull().default(0), // 0=none, 1=low, 2=medium, 3=high
|
||||
dueDate: timestamp('due_date', { withTimezone: true }),
|
||||
listId: uuid('list_id').notNull().references(() => slLists.id, { onDelete: 'cascade' }),
|
||||
parentId: uuid('parent_id').references((): any => slTasks.id, { onDelete: 'cascade' }),
|
||||
position: integer('position').notNull().default(0),
|
||||
recurrence: text('recurrence'), // 'daily'|'weekly'|'monthly'|'yearly'
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
index('idx_sl_tasks_user').on(table.userId),
|
||||
index('idx_sl_tasks_list').on(table.listId),
|
||||
index('idx_sl_tasks_parent').on(table.parentId),
|
||||
]);
|
||||
|
||||
export const slTags = pgTable('sl_tags', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
color: text('color').notNull().default('#4A90A4'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
index('idx_sl_tags_user').on(table.userId),
|
||||
]);
|
||||
|
||||
export const slTaskTags = pgTable('sl_task_tags', {
|
||||
taskId: uuid('task_id').notNull().references(() => slTasks.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id').notNull().references(() => slTags.id, { onDelete: 'cascade' }),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.taskId, table.tagId] }),
|
||||
]);
|
||||
```
|
||||
|
||||
Notes :
|
||||
- `deletedAt` (soft-delete) sur lists, tasks, tags — necessaire pour la sync (les suppressions doivent etre propagees aux clients)
|
||||
- `calendar_event_id` omis (specifique mobile)
|
||||
- `updatedAt` sert de vecteur de version pour le last-write-wins
|
||||
- La table `sl_tags` n'a pas de `updatedAt` dans le schema SQLite mobile — a ajouter cote mobile pour la sync
|
||||
- Le schema PostgreSQL est structurellement identique au schema SQLite mobile, avec `userId` et `deletedAt` en plus — memes noms de colonnes, memes types logiques
|
||||
|
||||
### API REST
|
||||
|
||||
Base URL: `https://liste.lacompagniemaximus.com/api`
|
||||
Auth: Session Logto (cookie) pour le web, Bearer JWT pour le mobile
|
||||
|
||||
#### Listes
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/lists` | Toutes les listes de l'utilisateur |
|
||||
| POST | `/api/lists` | Creer une liste |
|
||||
| PUT | `/api/lists/:id` | Modifier une liste |
|
||||
| DELETE | `/api/lists/:id` | Soft-delete (cascade taches) |
|
||||
| PUT | `/api/lists/reorder` | Reordonner (batch positions) |
|
||||
|
||||
#### Taches
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/lists/:listId/tasks` | Taches d'une liste (filtres: completed, priority, dueDate, tags) |
|
||||
| GET | `/api/tasks/:id/subtasks` | Sous-taches |
|
||||
| POST | `/api/tasks` | Creer une tache |
|
||||
| PUT | `/api/tasks/:id` | Modifier (titre, notes, completed, priorite, dueDate, listId, position, recurrence) |
|
||||
| DELETE | `/api/tasks/:id` | Soft-delete (cascade sous-taches) |
|
||||
| PUT | `/api/tasks/reorder` | Reordonner dans une liste |
|
||||
|
||||
#### Tags
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/tags` | Tous les tags de l'utilisateur |
|
||||
| POST | `/api/tags` | Creer un tag |
|
||||
| PUT | `/api/tags/:id` | Modifier un tag |
|
||||
| DELETE | `/api/tags/:id` | Soft-delete |
|
||||
| POST | `/api/tasks/:id/tags` | Assigner des tags a une tache |
|
||||
| DELETE | `/api/tasks/:taskId/tags/:tagId` | Retirer un tag |
|
||||
|
||||
#### Sync (mobile)
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/sync?since=<ISO timestamp>` | Changements serveur depuis timestamp (inclut soft-deletes) |
|
||||
| POST | `/api/sync` | Push batch de changements locaux (avec idempotency keys) |
|
||||
|
||||
#### WebSocket (web)
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| POST | `/api/ws-ticket` | Generer un ticket ephemere (nonce, TTL 30s) pour le handshake WS |
|
||||
| WS | `/ws?ticket=<nonce>` | Connexion WebSocket (ticket a usage unique) |
|
||||
|
||||
### Sync Strategy — Hybride
|
||||
|
||||
#### Web : WebSocket temps reel
|
||||
- Custom server Next.js (`server.ts`) qui attache un serveur `ws` sur le meme port HTTP (path `/ws`)
|
||||
- A chaque mutation via l'API, le serveur broadcast un message aux WebSocket connectes du meme `userId`
|
||||
- Le client web ecoute et rafraichit les donnees affectees (pas de re-fetch complet, notification granulaire par entity type + id)
|
||||
- Reconnexion automatique avec backoff exponentiel cote client
|
||||
- Heartbeat toutes les 30s pour detecter les connexions mortes
|
||||
|
||||
```typescript
|
||||
// Message WebSocket — notification seulement, pas de payload (securite)
|
||||
type WsMessage =
|
||||
| { type: 'sync'; entity: 'list' | 'task' | 'tag'; action: 'create' | 'update' | 'delete'; id: string }
|
||||
| { type: 'auth_expired' } // session expiree, le client doit se reconnecter
|
||||
```
|
||||
|
||||
#### Mobile : polling periodique
|
||||
- Sync au lancement de l'app (si connecte)
|
||||
- Sync toutes les 2 minutes en foreground
|
||||
- Sync au retour du background (app state change)
|
||||
- Indicateur visuel de statut sync (icone dans le header)
|
||||
|
||||
#### Resolution de conflits
|
||||
- **Last-write-wins** base sur `updatedAt` — le timestamp le plus recent gagne
|
||||
- En cas d'egalite exacte (improbable), le serveur gagne
|
||||
- Les suppressions (soft-delete) sont prioritaires : si un client supprime et l'autre modifie, la suppression gagne
|
||||
|
||||
#### Flux de sync mobile
|
||||
1. Le mobile envoie `POST /api/sync` avec tous les changements locaux depuis `lastSyncAt`
|
||||
2. Le serveur applique les changements (LWW), retourne les conflits resolus
|
||||
3. Le mobile fait `GET /api/sync?since=lastSyncAt` pour recuperer les changements serveur
|
||||
4. Le mobile applique les changements serveur dans SQLite
|
||||
5. `lastSyncAt` est mis a jour dans AsyncStorage
|
||||
|
||||
#### Premiere connexion — Migration des donnees locales
|
||||
Au premier login dans l'app mobile, une alerte propose :
|
||||
1. **"Fusionner mes taches"** — Upload toutes les donnees locales vers le serveur, puis sync bidirectionnel
|
||||
2. **"Repartir du serveur"** — Ecrase les donnees locales avec celles du serveur (si le compte a deja des donnees web)
|
||||
|
||||
**Reconciliation de l'Inbox** : L'Inbox mobile a un ID hardcode (`00000000-0000-0000-0000-000000000001`). Le serveur cree une Inbox avec un UUID genere. Lors du merge :
|
||||
1. Le serveur cree l'Inbox du user si elle n'existe pas
|
||||
2. Le mobile remap l'ID hardcode vers l'ID Inbox serveur dans toutes les taches locales
|
||||
3. Push les taches avec le nouvel ID
|
||||
4. Mettre a jour l'ID Inbox local pour matcher le serveur
|
||||
|
||||
#### Outbox pattern — file d'attente locale
|
||||
Les changements locaux sont d'abord ecrits dans une table SQLite `sync_outbox` avant d'etre envoyes au serveur :
|
||||
|
||||
```sql
|
||||
CREATE TABLE sync_outbox (
|
||||
id TEXT PRIMARY KEY, -- UUID (= idempotency key)
|
||||
entity_type TEXT NOT NULL, -- 'list' | 'task' | 'tag' | 'task_tag'
|
||||
entity_id TEXT NOT NULL, -- ID de l'entite modifiee
|
||||
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
|
||||
payload TEXT NOT NULL, -- JSON de l'operation
|
||||
created_at TEXT NOT NULL, -- ISO 8601
|
||||
synced_at TEXT -- NULL tant que pas sync, ISO 8601 apres
|
||||
);
|
||||
```
|
||||
|
||||
Flux :
|
||||
1. Chaque mutation locale ecrit dans la table metier ET dans `sync_outbox`
|
||||
2. Le sync service lit les entries `WHERE synced_at IS NULL`, les envoie en batch via `POST /api/sync`
|
||||
3. Les entries synced sont marquees (`synced_at = now()`)
|
||||
4. Les entries de plus de 7 jours sont purgees
|
||||
5. Si le serveur est inaccessible, l'outbox s'accumule — les changements ne sont jamais perdus
|
||||
|
||||
### Auth
|
||||
|
||||
#### Web (Next.js)
|
||||
- SDK `@logto/next` pour l'auth server-side
|
||||
- Middleware Next.js qui protege toutes les routes sous `/(app)/`
|
||||
- Session cookie securisee (HttpOnly, SameSite=Strict)
|
||||
- Extraction du `userId` depuis les claims JWT Logto
|
||||
- Verification optionnelle du claim `apps.simpl-liste` pour l'acces
|
||||
|
||||
#### Mobile (React Native)
|
||||
- SDK `@logto/rn` pour le flow OAuth2/OIDC
|
||||
- Bouton "Se connecter" dans Settings > Compte
|
||||
- Token JWT stocke securise (expo-secure-store)
|
||||
- Le token est envoye en header `Authorization: Bearer <jwt>` pour les appels API
|
||||
- Mode deconnecte : l'app fonctionne normalement sans token, la sync est desactivee
|
||||
|
||||
### Frontend Web — UX
|
||||
|
||||
#### Layout principal
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Simpl-Liste [FR] [🌙] [👤] │
|
||||
├──────────┬──────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📥 Inbox │ ☐ Acheter du lait ⚡ Haute │
|
||||
│ 📋 Courses│ ☐ Appeler le dentiste 📅 Lun │
|
||||
│ 🏠 Maison │ ☑ Faire le menage ✓ Fait │
|
||||
│ + Liste │ ☐ Reparer le robinet │
|
||||
│ │ │
|
||||
│ Tags │ [+ Nouvelle tache] │
|
||||
│ 🔵 Urgent│ │
|
||||
│ 🟢 Perso │ Filtres: [Actives ▾] [Priorite ▾] │
|
||||
│ │ Tri: [Position ▾] │
|
||||
├──────────┴──────────────────────────────────────┤
|
||||
│ ● Connecte — Sync OK │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar gauche** : listes (avec couleur/icone), section tags, bouton + pour creer
|
||||
- **Zone principale** : taches de la liste selectionnee, avec filtres et tri en haut
|
||||
- **Barre d'etat** : statut de connexion et derniere sync
|
||||
- Edition inline des taches (titre, completion, priorite) — modal pour les details complets (notes, date, tags, sous-taches, recurrence)
|
||||
- Responsive : sidebar se replie en hamburger menu sur mobile web
|
||||
|
||||
### Securite
|
||||
|
||||
#### Transport et authentification
|
||||
- TLS obligatoire (HTTPS/WSS via Caddy) — jamais de connexion non chiffree
|
||||
- Toutes les requetes API authentifiees (session cookie web / JWT Bearer mobile)
|
||||
- Session cookie web : `HttpOnly`, `Secure`, `SameSite=Strict`
|
||||
- JWT mobile : access token court (15 min) + refresh token avec rotation (chaque refresh invalide l'ancien)
|
||||
- Validation JWT systematique sur chaque requete : signature, `exp`, `aud`, `iss`
|
||||
|
||||
#### Stockage des tokens mobile
|
||||
- Adaptateur de stockage custom pour `@logto/rn` basé sur `expo-secure-store` (Keychain iOS / Keystore Android)
|
||||
- Ne JAMAIS stocker de tokens dans AsyncStorage (non chiffre, lisible sur appareil roote)
|
||||
- Ne pas inclure de donnees sensibles dans le payload JWT (le payload est encode en base64, pas chiffre)
|
||||
|
||||
#### WebSocket — Anti-CSWSH (Cross-Site WebSocket Hijacking)
|
||||
- Authentification par **ticket ephemere** (pas par cookie) :
|
||||
1. Le client appelle `POST /api/ws-ticket` (authentifie par session)
|
||||
2. Le serveur genere un nonce a usage unique (TTL 30s), stocke en memoire
|
||||
3. Le client se connecte a `wss://host/ws?ticket=<nonce>`
|
||||
4. Le serveur valide et invalide le ticket au handshake (usage unique)
|
||||
- Validation du header `Origin` contre une allowlist (`liste.lacompagniemaximus.com`)
|
||||
- Re-validation de la session toutes les 15 minutes — fermer la connexion si expiree, envoyer `{ type: 'auth_expired' }` au client
|
||||
- Ne pas logger les query params sur la route `/ws` (eviter fuite du ticket dans les access logs)
|
||||
|
||||
#### Isolation des donnees et autorisation
|
||||
- Isolation par `userId` sur chaque requete (`WHERE userId = ?`)
|
||||
- **BOLA prevention sur le sync batch** : pour chaque operation dans `POST /api/sync`, verifier que l'entite appartient au `userId` (`WHERE id = ? AND userId = ?`). Rejeter le batch entier si une seule verification echoue.
|
||||
- Schemas Zod **strict** sur chaque endpoint — whitelist explicite des champs modifiables
|
||||
- Ne jamais permettre au client de modifier `userId`, `createdAt`, `deletedAt` via l'API
|
||||
|
||||
#### Anti-replay sur les endpoints sync
|
||||
- **Idempotency keys** : chaque operation dans le batch porte un UUID genere par le client. Le serveur stocke les IDs traites (TTL 24h) et retourne le meme resultat si resoumis.
|
||||
- **Fenetre temporelle** : rejeter les requetes dont le timestamp depasse +/- 5 minutes
|
||||
|
||||
#### Rate limiting granulaire
|
||||
|
||||
| Endpoint | Limite par user | Raison |
|
||||
|----------|----------------|--------|
|
||||
| `POST /api/sync` | 10/min | Operation lourde (batch) |
|
||||
| `POST /api/ws-ticket` | 10/min | Prevenir brute-force de tickets |
|
||||
| `POST /api/lists`, `POST /api/tasks`, `POST /api/tags` | 30/min | Creation |
|
||||
| `GET /api/*` | 200/min | Lecture |
|
||||
| Connexions WebSocket | 5/min | Prevenir flood de reconnexions |
|
||||
|
||||
#### Messages WebSocket
|
||||
- Envoyer seulement le type d'entite + id dans les messages WS (pas le contenu des taches)
|
||||
- Le client re-fetch les donnees modifiees via l'API REST (securisee)
|
||||
- Evite la fuite de donnees si la connexion WS est compromise
|
||||
|
||||
#### Soft-delete et retention
|
||||
- Purge automatique des enregistrements soft-deleted apres 30 jours (cron job)
|
||||
- Les clients qui n'ont pas sync depuis 30+ jours effectuent un full sync au lieu d'un delta
|
||||
- Politique de retention documentee dans les conditions d'utilisation
|
||||
|
||||
#### Input validation
|
||||
- Validation/sanitization (zod) sur tous les champs texte
|
||||
- Protection XSS : echapper le contenu utilisateur a l'affichage (React le fait par defaut)
|
||||
- CSRF protection sur les mutations (cookie `SameSite=Strict`)
|
||||
|
||||
## Plan de travail
|
||||
|
||||
### Issue 1 — Types partages et refactoring mobile [type:task]
|
||||
Dependances : aucune
|
||||
- [ ] Creer `src/shared/types.ts` — exporter Task, List, Tag, TaskFilters, SortBy, etc.
|
||||
- [ ] Creer `src/shared/colors.ts` — extraire la palette de `theme/colors.ts`
|
||||
- [ ] Creer `src/shared/priority.ts` — extraire de `lib/priority.ts`
|
||||
- [ ] Creer `src/shared/recurrence.ts` — extraire de `lib/recurrence.ts`
|
||||
- [ ] Refactorer le code mobile pour importer depuis `shared/`
|
||||
- [ ] Ajouter `updatedAt` au schema `tags` dans le mobile (migration Drizzle)
|
||||
|
||||
### Issue 2 — Setup projet web + schema PostgreSQL [type:task]
|
||||
Dependances : aucune (parallele avec Issue 1)
|
||||
- [ ] Init Next.js App Router dans `web/` avec TypeScript, Tailwind, Drizzle ORM (`drizzle-orm/pg-core` + `pg`)
|
||||
- [ ] Schema Drizzle PostgreSQL (sl_lists, sl_tasks, sl_tags, sl_task_tags) — userId = `sub` Logto
|
||||
- [ ] Migration initiale + seed (inbox par defaut par user)
|
||||
- [ ] Endpoint `/api/health` (status API, latence DB, connexions WS actives)
|
||||
- [ ] Dockerfile + config Coolify pour `liste.lacompagniemaximus.com` (verifier support WS dans Caddy)
|
||||
- [ ] Configurer Logto app pour le web (redirect URIs, etc.)
|
||||
|
||||
### Issue 3 — Auth web + middleware [type:feature]
|
||||
Dependances : Issue 2
|
||||
- [ ] Integrer `@logto/next` (sign-in, sign-out, callback)
|
||||
- [ ] Middleware Next.js : proteger `/(app)/`, extraire userId
|
||||
- [ ] Page de login (`/auth`) avec redirect vers Logto
|
||||
- [ ] Gestion de session (cookie securise)
|
||||
|
||||
### Issue 4 — API REST backend [type:feature]
|
||||
Dependances : Issue 2, Issue 3
|
||||
- [ ] Middleware auth sur `/api/*` (session cookie + Bearer JWT avec validation signature/exp/aud/iss)
|
||||
- [ ] Endpoints CRUD listes (avec soft-delete)
|
||||
- [ ] Endpoints CRUD taches (avec filtres, tri, sous-taches, soft-delete)
|
||||
- [ ] Endpoints CRUD tags + assignation (avec soft-delete)
|
||||
- [ ] Endpoints sync (GET since + POST batch avec idempotency keys)
|
||||
- [ ] Verification BOLA par entite sur le batch sync (`WHERE id = ? AND userId = ?`)
|
||||
- [ ] Schemas Zod strict sur chaque endpoint (whitelist de champs, rejeter champs inconnus)
|
||||
- [ ] Rate limiting granulaire (sync 10/min, creation 30/min, lecture 200/min)
|
||||
- [ ] Endpoint `POST /api/ws-ticket` (nonce ephemere TTL 30s, usage unique)
|
||||
- [ ] Tests API (optionnel mais recommande)
|
||||
|
||||
### Issue 5 — WebSocket server [type:feature]
|
||||
Dependances : Issue 4
|
||||
- [ ] Custom server (`server.ts`) : Next.js + `ws` sur le meme port
|
||||
- [ ] Auth WebSocket par ticket ephemere (valider et invalider le nonce au handshake)
|
||||
- [ ] Validation du header `Origin` contre l'allowlist
|
||||
- [ ] Broadcast par userId sur chaque mutation API (type + id seulement, pas de payload)
|
||||
- [ ] Re-validation de session toutes les 15 min (fermer si expiree + message `auth_expired`)
|
||||
- [ ] Heartbeat 30s + reconnexion auto cote client avec backoff exponentiel
|
||||
- [ ] Ne pas logger les query params sur la route `/ws`
|
||||
- [ ] Mettre a jour le Dockerfile pour utiliser le custom server
|
||||
|
||||
### Issue 6 — Frontend web [type:feature]
|
||||
Dependances : Issue 4, Issue 5
|
||||
- [ ] Layout principal (sidebar listes + zone taches)
|
||||
- [ ] CRUD listes (creer, renommer, supprimer, couleur/icone, reordonner)
|
||||
- [ ] CRUD taches (creer, editer inline, completer, supprimer, priorite, date, notes)
|
||||
- [ ] Sous-taches (affichage imbrique, creer/editer)
|
||||
- [ ] Tags (creer, assigner, filtrer)
|
||||
- [ ] Filtres et tri
|
||||
- [ ] Recurrence (affichage, creation, auto-recurrence au complete)
|
||||
- [ ] Integration WebSocket (mise a jour temps reel)
|
||||
- [ ] Dark mode (light/dark/system)
|
||||
- [ ] i18n FR/EN
|
||||
- [ ] Responsive design
|
||||
- [ ] Barre de statut sync
|
||||
|
||||
### Issue 7 — Sync mobile [type:feature]
|
||||
Dependances : Issue 1, Issue 4
|
||||
- [ ] Integrer `@logto/rn` avec adaptateur `expo-secure-store` (ne pas utiliser AsyncStorage pour les tokens)
|
||||
- [ ] Bouton login dans Settings > Compte
|
||||
- [ ] Access token court (15 min) + refresh token avec rotation automatique
|
||||
- [ ] Intercepteur HTTP pour refresh transparent (capturer 401, rafraichir, relancer)
|
||||
- [ ] Table `sync_outbox` dans SQLite (migration Drizzle) — outbox pattern
|
||||
- [ ] Client sync : push outbox entries + pull changes avec idempotency keys
|
||||
- [ ] Reconciliation Inbox au premier merge (remap ID hardcode → ID serveur)
|
||||
- [ ] Polling periodique (2 min) + sync au lancement + sync au retour foreground
|
||||
- [ ] Gestion des conflits LWW
|
||||
- [ ] Ecran de premiere connexion : choix merge local ou reset serveur
|
||||
- [ ] Indicateur visuel de statut sync (header)
|
||||
- [ ] Mode degrade si serveur inaccessible (outbox s'accumule, sync au retour)
|
||||
|
||||
### Ordre d'execution
|
||||
|
||||
```
|
||||
Issue 1 (Types partages) ──┐
|
||||
├── Issue 4 (API REST) ── Issue 5 (WebSocket) ─┐
|
||||
Issue 2 (Setup web + DB) ──┤ ├── Issue 6 (Frontend)
|
||||
└── Issue 3 (Auth web) ─┘ │
|
||||
Issue 1 ── Issue 4 ── Issue 7 (Sync mobile) │
|
||||
```
|
||||
|
||||
## Fichiers concernes
|
||||
|
||||
| Fichier | Action | Raison |
|
||||
|---------|--------|--------|
|
||||
| `web/` | Creer | Nouveau projet Next.js complet |
|
||||
| `src/shared/types.ts` | Creer | Types TypeScript partages mobile/web |
|
||||
| `src/shared/colors.ts` | Creer | Palette centralisee |
|
||||
| `src/shared/priority.ts` | Creer | Helpers priorite partages |
|
||||
| `src/shared/recurrence.ts` | Creer | Logique recurrence partagee |
|
||||
| `src/theme/colors.ts` | Modifier | Importer depuis shared/ |
|
||||
| `src/lib/priority.ts` | Modifier | Importer depuis shared/ |
|
||||
| `src/lib/recurrence.ts` | Modifier | Importer depuis shared/ |
|
||||
| `src/db/schema.ts` | Modifier | Ajouter updatedAt aux tags |
|
||||
| `src/db/schema.ts` | Modifier | Ajouter table `sync_outbox` |
|
||||
| `src/db/repository/tasks.ts` | Modifier | Ecrire dans l'outbox a chaque mutation (si connecte) |
|
||||
| `src/services/syncClient.ts` | Creer | Client sync (push outbox, pull changes, reconciliation Inbox) |
|
||||
| `src/stores/useSettingsStore.ts` | Modifier | Ajouter syncEnabled, lastSyncAt, userId |
|
||||
| `app/(tabs)/settings.tsx` | Modifier | Ajouter section Compte (login/logout/sync) |
|
||||
| `app/_layout.tsx` | Modifier | Init sync polling si connecte |
|
||||
| `package.json` | Modifier | Ajouter deps auth mobile (@logto/rn, expo-secure-store) |
|
||||
|
||||
## Criteres d'acceptation
|
||||
|
||||
- [ ] Un utilisateur connecte peut acceder a ses listes depuis liste.lacompagniemaximus.com
|
||||
- [ ] Les operations CRUD fonctionnent pour les listes, taches, tags et sous-taches sur le web
|
||||
- [ ] Les modifications sur le web apparaissent en temps reel si deux onglets sont ouverts (WebSocket)
|
||||
- [ ] Les modifications sur le web apparaissent sur le mobile apres sync polling (et vice versa)
|
||||
- [ ] L'app mobile continue de fonctionner sans compte (offline-first preserve)
|
||||
- [ ] Au premier login mobile, l'utilisateur choisit de fusionner ou remplacer ses donnees locales
|
||||
- [ ] Le site fonctionne en FR et EN, en light et dark mode
|
||||
- [ ] Le site est responsive (utilisable sur mobile web)
|
||||
- [ ] Les suppressions se propagent correctement (soft-delete + sync)
|
||||
- [ ] Le WebSocket se reconnecte automatiquement apres une deconnexion
|
||||
- [ ] Les tokens mobile sont stockes dans expo-secure-store (pas AsyncStorage)
|
||||
- [ ] Le handshake WebSocket utilise un ticket ephemere (pas de cookie seul)
|
||||
- [ ] Les endpoints API rejettent les champs non autorises (userId, createdAt, deletedAt)
|
||||
- [ ] Le batch sync verifie l'ownership de chaque entite individuellement
|
||||
- [ ] Les soft-deletes sont purges automatiquement apres 30 jours
|
||||
|
||||
## Edge cases et risques
|
||||
|
||||
| Cas | Mitigation |
|
||||
|-----|------------|
|
||||
| Conflit de modification simultanee (web + mobile) | Last-write-wins sur `updatedAt`. Acceptable pour usage personnel. |
|
||||
| Suppression sur un appareil, modification sur l'autre | La suppression gagne (soft-delete prioritaire) |
|
||||
| Serveur inaccessible depuis le mobile | Mode degrade : l'app fonctionne en local, les changements sont queued et sync au retour |
|
||||
| WebSocket deconnecte | Reconnexion auto avec backoff exponentiel. Fallback : refresh manuel de la page |
|
||||
| Premiere sync avec beaucoup de donnees locales | Batch l'upload en chunks de 50 entites pour eviter les timeouts |
|
||||
| Token JWT expire pendant une session mobile | Refresh token avec rotation via Logto SDK. Si echec, re-login requis |
|
||||
| Deux listes "Inbox" apres merge (locale + serveur) | Remap ID hardcode mobile vers ID Inbox serveur dans toutes les taches locales avant push |
|
||||
| Utilisateur supprime son compte | Cascade delete de toutes les donnees sl_* du user dans PostgreSQL |
|
||||
| Custom server Next.js + Coolify | S'assurer que le Dockerfile utilise `node server.ts` et non `next start`. Verifier le support WebSocket dans Caddy. |
|
||||
| Changements offline perdus (crash app) | Outbox pattern : les mutations sont persistees dans `sync_outbox` SQLite avant envoi |
|
||||
| Broadcast WS multi-instance | Architecture single-instance pour la v1. Si scaling necessaire, ajouter Redis pub/sub |
|
||||
| VPS memoire insuffisante | Surveiller via `/api/health`. Prevoir upgrade 8 Go si migration famille-website en parallele |
|
||||
| CSWSH (Cross-Site WebSocket Hijacking) | Ticket ephemere a usage unique + validation Origin + cookies SameSite=Strict |
|
||||
| BOLA sur le batch sync | Verification d'ownership par entite dans chaque operation du batch |
|
||||
| Replay attack sur POST /api/sync | Idempotency keys par operation + fenetre temporelle +/- 5 min |
|
||||
| Token mobile sur appareil roote | Stockage dans expo-secure-store (Keychain/Keystore), jamais AsyncStorage |
|
||||
| Session WS survit a l'expiration auth | Re-validation serveur toutes les 15 min, fermeture si expiree |
|
||||
| Fuite de donnees via messages WS | Messages WS ne contiennent que type + id, pas le contenu des taches |
|
||||
| Soft-deletes accumules indefiniment | Purge automatique apres 30 jours, full sync pour clients inactifs |
|
||||
| Mass assignment via l'API | Schemas Zod strict, whitelist de champs modifiables, `userId`/`createdAt`/`deletedAt` non modifiables |
|
||||
|
||||
## Decisions prises
|
||||
|
||||
| Question | Decision | Raison |
|
||||
|----------|----------|--------|
|
||||
| Stack web | Next.js App Router + Drizzle + PostgreSQL | Fullstack dans un seul projet. Drizzle unifie mobile et serveur. |
|
||||
| Emplacement du code | `simpl-liste/web/` dans le repo existant | Partage des types via `src/shared/`. Un seul repo a maintenir. |
|
||||
| ORM serveur | Drizzle (`pg-core`) | Meme ORM que le mobile (`sqlite-core`). Un seul outil a maitriser, schemas compatibles, pas de codegen. |
|
||||
| Auth | Logto (deploye) | Systeme d'auth centralise La Compagnie Maximus. |
|
||||
| Sync web | WebSocket (via `ws` + custom server) | Temps reel pour le web. Le VPS a largement les ressources (4 cores, 4 Go libres). |
|
||||
| Sync mobile | Polling periodique (2 min) | React Native ne maintient pas de WebSocket en background. Polling simple et fiable. |
|
||||
| Conflits | Last-write-wins sur updatedAt | Simple, suffisant pour un usage personnel avec peu d'appareils. |
|
||||
| Suppressions | Soft-delete (deletedAt) | Necessaire pour propager les suppressions aux autres clients via sync. |
|
||||
| Compte requis | Non, optionnel | Preserver l'experience offline-first sans friction. Le login debloque la sync. |
|
||||
| Migration donnees locales | Choix utilisateur (merge ou reset) | Eviter la perte de donnees et laisser le controle a l'utilisateur. |
|
||||
| Identite utilisateur | `sub` Logto comme `userId` | Compte Maximus unifie cross-app. Toutes les apps actuelles et futures utilisent le meme identifiant. |
|
||||
| ORM unifie | Drizzle partout (sqlite-core mobile + pg-core serveur) | Un seul ORM, schemas compatibles, types inferes directement, pas de couche de mapping. |
|
||||
| Reverse proxy | Caddy (pas Traefik) | Caddy est le reverse proxy en place sur le VPS. Support natif WebSocket. |
|
||||
| Outbox pattern | Table `sync_outbox` dans SQLite mobile | Les changements offline survivent aux crashes. Idempotency keys integrees. |
|
||||
| Scaling WS | Single-instance, pas de Redis | Suffisant pour l'usage actuel. Redis pub/sub a ajouter si multi-instance futur. |
|
||||
| Monitoring | Endpoint `/api/health` | Metriques applicatives (DB, WS, API) en complement de vps-health-api (hardware). |
|
||||
|
||||
## References
|
||||
|
||||
| Source | Pertinence |
|
||||
|--------|------------|
|
||||
| [Socket.IO + Next.js guide](https://socket.io/how-to/use-with-nextjs) | Pattern custom server pour WebSocket avec Next.js. On utilise `ws` directement (plus leger). |
|
||||
| [Next.js WebSocket discussion #58698](https://github.com/vercel/next.js/discussions/58698) | Confirme que les route handlers App Router ne supportent pas les WS — custom server necessaire. |
|
||||
| [PowerSync React Native SDK](https://docs.powersync.com/client-sdks/reference/react-native-and-expo) | Alternative evaluee pour la sync. Trop lourd pour notre cas (single user). DIY LWW + tombstones suffit. |
|
||||
| [Expo local-first guide](https://docs.expo.dev/guides/local-first/) | Patterns de sync offline-first avec expo-sqlite. Valide notre approche outbox + polling. |
|
||||
| [OWASP WebSocket Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) | CSWSH, auth WS, validation Origin — base de notre strategie de ticket ephemere. |
|
||||
| [OWASP API Security Top 10](https://owasp.org/www-project-api-security/) | BOLA (#1), Mass Assignment (#3) — guides les controles d'acces sur le batch sync. |
|
||||
| [CSWSH Exploitation in 2025 — Include Security](https://blog.includesecurity.com/2025/04/cross-site-websocket-hijacking-exploitation-in-2025/) | Confirme que SameSite=Lax ne suffit pas si un sous-domaine est compromis. |
|
||||
| [Logto Security docs](https://docs.logto.io/security) | Rotation des cles, logout centralise, stockage securise des tokens. |
|
||||
| [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/) | API pour le stockage chiffre (Keychain iOS / Keystore Android) — adaptateur pour @logto/rn. |
|
||||
|
||||
## Estimation
|
||||
|
||||
7-10 sessions de travail, decoupees ainsi :
|
||||
- Issues 1-2 (setup) : 1-2 sessions
|
||||
- Issue 3 (auth) : 1 session
|
||||
- Issue 4 (API) : 2 sessions
|
||||
- Issue 5 (WebSocket) : 1 session
|
||||
- Issue 6 (frontend) : 2-3 sessions
|
||||
- Issue 7 (sync mobile) : 1-2 sessions
|
||||
|
|
@ -4,9 +4,21 @@ import { TaskListWidget } from './TaskListWidget';
|
|||
import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetSync';
|
||||
import { isValidUUID } from '../lib/validation';
|
||||
|
||||
const EXPAND_DEBOUNCE_MS = 2000;
|
||||
const EXPAND_DEBOUNCE_MS = 600;
|
||||
const lastExpandTimes = new Map<string, number>();
|
||||
|
||||
// Dev-only timing helper. Output goes to logcat:
|
||||
// adb logcat -s ReactNativeJS | grep '\[widget\]'
|
||||
async function timed<T>(label: string, fn: () => Promise<T> | T): Promise<T> {
|
||||
if (!__DEV__) return await fn();
|
||||
const start = Date.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log(`[widget] ${label}: ${Date.now() - start}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWithState(
|
||||
renderWidget: WidgetTaskHandlerProps['renderWidget'],
|
||||
widgetInfo: WidgetTaskHandlerProps['widgetInfo'],
|
||||
|
|
@ -44,17 +56,30 @@ async function forceWidgetRefresh(
|
|||
}
|
||||
}
|
||||
|
||||
// Best-effort persist: failure leaves AsyncStorage stale, but the next
|
||||
// handler call's getWidgetState() returns the prior state and re-renders
|
||||
// from it, so the UI self-heals on the next interaction.
|
||||
async function persistState(state: Awaited<ReturnType<typeof getWidgetState>>): Promise<void> {
|
||||
try {
|
||||
await setWidgetState(state);
|
||||
} catch {
|
||||
if (__DEV__) console.log('[widget] setWidgetState failed (state will resync on next sync push)');
|
||||
}
|
||||
}
|
||||
|
||||
export async function widgetTaskHandler(
|
||||
props: WidgetTaskHandlerProps
|
||||
): Promise<void> {
|
||||
const { widgetAction, widgetInfo, renderWidget } = props;
|
||||
const handlerStart = __DEV__ ? Date.now() : 0;
|
||||
|
||||
switch (widgetAction) {
|
||||
case 'WIDGET_ADDED':
|
||||
case 'WIDGET_UPDATE':
|
||||
case 'WIDGET_RESIZED': {
|
||||
const state = await getWidgetState();
|
||||
const state = await timed(`${widgetAction} getState`, getWidgetState);
|
||||
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
|
||||
if (__DEV__) console.log(`[widget] ${widgetAction} total: ${Date.now() - handlerStart}ms`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -66,31 +91,36 @@ export async function widgetTaskHandler(
|
|||
const taskId = props.clickActionData?.taskId;
|
||||
if (!isValidUUID(taskId)) break;
|
||||
|
||||
const state = await getWidgetState();
|
||||
const state = await timed('TOGGLE_COMPLETE getState', getWidgetState);
|
||||
state.tasks = state.tasks.filter((t) => t.id !== taskId);
|
||||
await setWidgetState(state);
|
||||
|
||||
// Render first so the user sees the row disappear immediately,
|
||||
// then persist + run the DB write.
|
||||
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
|
||||
await timed('TOGGLE_COMPLETE setState', () => persistState(state));
|
||||
|
||||
try {
|
||||
const { toggleComplete } = await import('../db/repository/tasks');
|
||||
await toggleComplete(taskId);
|
||||
await timed('TOGGLE_COMPLETE db', () => toggleComplete(taskId));
|
||||
} catch {
|
||||
// DB might not be available in headless mode
|
||||
}
|
||||
|
||||
if (__DEV__) console.log(`[widget] TOGGLE_COMPLETE total: ${Date.now() - handlerStart}ms`);
|
||||
}
|
||||
|
||||
if (props.clickAction === 'TOGGLE_EXPAND') {
|
||||
const taskId = props.clickActionData?.taskId as string | undefined;
|
||||
if (!taskId) break;
|
||||
|
||||
// Debounce: ignore rapid double-taps on the same task
|
||||
// Anti-double-tap. Short enough to not feel laggy when the user
|
||||
// genuinely wants to expand-then-collapse.
|
||||
const now = Date.now();
|
||||
const lastTime = lastExpandTimes.get(taskId) ?? 0;
|
||||
if (now - lastTime < EXPAND_DEBOUNCE_MS) break;
|
||||
lastExpandTimes.set(taskId, now);
|
||||
|
||||
const state = await getWidgetState();
|
||||
const state = await timed('TOGGLE_EXPAND getState', getWidgetState);
|
||||
const expandedSet = new Set(state.expandedTaskIds);
|
||||
|
||||
if (expandedSet.has(taskId)) {
|
||||
|
|
@ -99,9 +129,11 @@ export async function widgetTaskHandler(
|
|||
expandedSet.add(taskId);
|
||||
}
|
||||
state.expandedTaskIds = [...expandedSet];
|
||||
await setWidgetState(state);
|
||||
|
||||
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
|
||||
await timed('TOGGLE_EXPAND setState', () => persistState(state));
|
||||
|
||||
if (__DEV__) console.log(`[widget] TOGGLE_EXPAND total: ${Date.now() - handlerStart}ms`);
|
||||
}
|
||||
|
||||
if (props.clickAction === 'TOGGLE_SUBTASK') {
|
||||
|
|
@ -109,9 +141,8 @@ export async function widgetTaskHandler(
|
|||
const parentId = props.clickActionData?.parentId as string | undefined;
|
||||
if (!isValidUUID(subtaskId) || !parentId) break;
|
||||
|
||||
const state = await getWidgetState();
|
||||
const state = await timed('TOGGLE_SUBTASK getState', getWidgetState);
|
||||
|
||||
// Update subtask state in cached data
|
||||
const parent = state.tasks.find((t) => t.id === parentId);
|
||||
if (parent) {
|
||||
const sub = parent.subtasks?.find((s) => s.id === subtaskId);
|
||||
|
|
@ -120,16 +151,24 @@ export async function widgetTaskHandler(
|
|||
parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length;
|
||||
}
|
||||
}
|
||||
await setWidgetState(state);
|
||||
|
||||
await forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds);
|
||||
// forceWidgetRefresh fans out to all 3 widget sizes (state changed
|
||||
// affects every widget on the home screen). Run before persist for
|
||||
// immediate visual feedback; the data passed in is the in-memory
|
||||
// mutated state, not re-read from AsyncStorage.
|
||||
await timed('TOGGLE_SUBTASK render', () =>
|
||||
forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds)
|
||||
);
|
||||
await timed('TOGGLE_SUBTASK setState', () => persistState(state));
|
||||
|
||||
try {
|
||||
const { toggleComplete } = await import('../db/repository/tasks');
|
||||
await toggleComplete(subtaskId);
|
||||
await timed('TOGGLE_SUBTASK db', () => toggleComplete(subtaskId));
|
||||
} catch {
|
||||
// DB might not be available in headless mode
|
||||
}
|
||||
|
||||
if (__DEV__) console.log(`[widget] TOGGLE_SUBTASK total: ${Date.now() - handlerStart}ms`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue