# 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 |