From c72f17eb93a30446d77020552704214ab7e628a1 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 18:59:02 +0100 Subject: [PATCH] docs : add time tracking design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-10-time-tracking-design.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-10-time-tracking-design.md diff --git a/docs/superpowers/specs/2026-03-10-time-tracking-design.md b/docs/superpowers/specs/2026-03-10-time-tracking-design.md new file mode 100644 index 0000000..aa62482 --- /dev/null +++ b/docs/superpowers/specs/2026-03-10-time-tracking-design.md @@ -0,0 +1,197 @@ +# Time Tracking (Toggl-style Timer) + +## Résumé + +Système de suivi de temps type Toggl intégré à Lesstime. Permet de démarrer des timers depuis les tickets (TaskCard) ou à vide depuis la sidebar, visualiser les temps sur un calendrier semaine/jour, et gérer les entrées de temps (drag, resize, copier-coller). + +## Modèle de données + +### Entité `TimeEntry` + +| Champ | Type | Contraintes | +|-------|------|-------------| +| `id` | integer | PK, auto-increment | +| `title` | string(255) | nullable | +| `description` | text | nullable | +| `startedAt` | datetimetz_immutable | requis (stocké en UTC) | +| `stoppedAt` | datetimetz_immutable | nullable (null = timer actif, stocké en UTC) | +| `user` | ManyToOne → User | requis, CASCADE on delete | +| `project` | ManyToOne → Project | nullable, SET NULL on delete | +| `task` | ManyToOne → Task | nullable, SET NULL on delete | +| `types` | ManyToMany → TaskType | join table `time_entry_task_type` | + +### Règles métier + +- Un seul timer actif (`stoppedAt = null`) par user à la fois +- `stoppedAt` > `startedAt` si renseigné +- Les entrées de temps peuvent se chevaucher +- Démarrage depuis un ticket : copie `title`, `project`, `task`, `types` depuis la Task. Le `user` est toujours le user connecté (pas l'assignee du ticket) +- Démarrage à vide : seuls `startedAt` et `user` (connecté) sont renseignés, le reste peut être complété après +- Unicité timer actif : index partiel unique sur `(user_id) WHERE stopped_at IS NULL` +- Entrées traversant minuit : tronquées visuellement à la fin du jour, la suite s'affiche dans la colonne du jour suivant +- Toutes les dates sont stockées et échangées en UTC. Le frontend convertit en heure locale pour l'affichage + +## API Endpoints + +Préfixe `/api`. + +### Sécurité / Autorisations + +- Tout user authentifié peut lire les entrées de tous les users (filtrage par user côté frontend) +- Un user peut créer/modifier/supprimer ses propres entrées +- Un ROLE_ADMIN peut créer/modifier/supprimer les entrées de n'importe qui +- Assigner un temps à un autre user (`user` ≠ soi-même) requiert ROLE_ADMIN + +| Méthode | Route | Description | +|---------|-------|-------------| +| `GET` | `/api/time_entries` | Liste avec filtres : `user`, `project`, `startedAt[after]`, `startedAt[before]`, `types` | +| `POST` | `/api/time_entries` | Créer une entrée ou démarrer un timer | +| `PATCH` | `/api/time_entries/{id}` | Modifier (stopper, compléter, redimensionner, déplacer) | +| `DELETE` | `/api/time_entries/{id}` | Supprimer | +| `GET` | `/api/time_entries/active` | Timer actif du user connecté (custom Provider, `uriTemplate` avec priorité > item route) | + +## Frontend + +### Store Pinia `useTimerStore` + +```typescript +state: { + activeEntry: TimeEntry | null +} + +getters: { + isRunning: boolean // activeEntry !== null + elapsed: number // calculé via setInterval: now - activeEntry.startedAt +} + +actions: { + fetchActive() // GET /api/time_entries/active — appelé au chargement app + start() // POST à vide (startedAt: now, user: currentUser) + startFromTask(task: Task) // Stoppe le timer actif si existant, puis POST avec données du ticket (user = connecté, pas assignee) + stop() // PATCH stoppedAt: now +} +``` + +Le temps est fiable même si le navigateur est fermé : `startedAt` est en base, le compteur affiche toujours `now - startedAt` au rechargement. + +### Timer dans la sidebar (bas à gauche) + +- **Inactif** : affiche `00:00:00` + bouton play (démarrage à vide) +- **Actif** : compteur temps réel + bouton stop +- Toujours visible, dans le layout `default.vue` + +### Bouton play sur TaskCard + +- Bouton play existant sur les cartes du kanban +- Clic → `timerStore.startFromTask(task)` +- Si un timer est déjà actif : stop automatique de l'ancien, puis démarrage du nouveau + +### Page "Suivi des temps" + +**Route** : `/time-tracking` +**Lien sidebar** : "Suivi de temps" (icône horloge) + +#### Header + +- Titre "Suivi des temps" +- Mois/année en orange +- Toggle vue : **Semaine** / **Jour** avec flèches `< >` +- Filtres : **User** (select, défaut = user connecté), **Type** (select TaskType) +- Bouton **"+ Ajouter une Activité"** + +#### Grille calendrier + +- **Axe Y** : 00:00 → 23:59 (minuit à minuit) +- **Axe X** : 7 colonnes (semaine, Lun→Dim) ou 1 colonne (jour) +- Chaque colonne : jour + date + total heures sous la date + +#### Blocs de temps + +- **Couleur** = couleur du projet +- **Contenu** : titre, nom du projet (petit), badge type coloré, durée +- Les blocs peuvent se chevaucher + +#### Interactions + +| Action | Comportement | +|--------|-------------| +| **Clic sur un bloc** | Ouvre le drawer en mode édition | +| **Drag & drop d'un bloc** | Déplacer vers un autre créneau ou autre jour | +| **Resize (bord bas)** | Redimensionner la durée (modifie `stoppedAt`) | +| **Clic sur créneau vide** | Ouvre le drawer en mode création avec heure début pré-remplie | +| **Clic droit sur un bloc** | Menu contextuel : Copier, Supprimer | +| **Clic droit sur créneau vide** | Menu contextuel : Coller (si un bloc copié) | +| **Bouton "+ Ajouter une Activité"** | Ouvre le drawer en mode création | + +### Drawer "Ajouter/Modifier un temps" + +Utilise le composant `AppDrawer` existant. + +**Champs** : +- Titre (input text) +- Description (textarea) +- Heure début (datetime picker) +- Heure fin (datetime picker) +- User (select, défaut = user connecté, peut assigner à un autre) +- Projet (select) +- Type (select TaskType) +- Bouton Enregistrer + +En mode édition : champs pré-remplis avec les données du TimeEntry. + +## Service frontend + +### `useTimeEntryService()` + +```typescript +getByDateRange(params: { after: string, before: string, user?: number, types?: number[] }): Promise +getActive(): Promise +create(payload: TimeEntryWrite): Promise +update(id: number, payload: Partial): Promise +remove(id: number): Promise +``` + +### DTO `TimeEntry` + +```typescript +type TimeEntry = { + id: number + '@id'?: string + title: string | null + description: string | null + startedAt: string // ISO datetime + stoppedAt: string | null // null = timer actif + user: UserData + project: Project | null + task: Task | null + types: TaskType[] +} + +type TimeEntryWrite = { + title?: string | null + description?: string | null + startedAt: string + stoppedAt?: string | null + user: string // IRI + project?: string | null // IRI + task?: string | null // IRI + types?: string[] // IRIs +} +``` + +## Modifications sur l'existant + +- **DTO `Task`** : ajouter le champ `project: Project` (nécessaire pour `startFromTask`) +- **`TaskCard.vue`** : connecter le bouton play existant à `timerStore.startFromTask(task)` +- **`default.vue`** : intégrer `SidebarTimer.vue` en bas de la sidebar (au-dessus du bouton collapse). En mode collapsed : afficher uniquement le bouton play/stop sans le compteur texte +- **Sidebar links** : ajouter le lien "Suivi de temps" vers `/time-tracking` + +## Composants frontend + +| Composant | Rôle | +|-----------|------| +| `TimeTrackingCalendar.vue` | Grille calendrier (semaine/jour) avec blocs | +| `TimeEntryBlock.vue` | Bloc de temps individuel (drag, resize) | +| `TimeEntryDrawer.vue` | Drawer ajout/modification | +| `TimeEntryContextMenu.vue` | Menu contextuel (copier, coller, supprimer) | +| `SidebarTimer.vue` | Widget timer dans la sidebar |