docs : add time tracking design spec
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
197
docs/superpowers/specs/2026-03-10-time-tracking-design.md
Normal file
197
docs/superpowers/specs/2026-03-10-time-tracking-design.md
Normal file
@@ -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<TimeEntry[]>
|
||||
getActive(): Promise<TimeEntry | null>
|
||||
create(payload: TimeEntryWrite): Promise<TimeEntry>
|
||||
update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry>
|
||||
remove(id: number): Promise<void>
|
||||
```
|
||||
|
||||
### 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 |
|
||||
Reference in New Issue
Block a user