From 2299d66a9fa5c9c13ff0727a0e46014946de800f Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 10 Mar 2026 22:05:46 +0100 Subject: [PATCH] docs : add time tracking implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-10-time-tracking.md | 1829 +++++++++++++++++ 1 file changed, 1829 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-10-time-tracking.md diff --git a/docs/superpowers/plans/2026-03-10-time-tracking.md b/docs/superpowers/plans/2026-03-10-time-tracking.md new file mode 100644 index 0000000..9c90039 --- /dev/null +++ b/docs/superpowers/plans/2026-03-10-time-tracking.md @@ -0,0 +1,1829 @@ +# Time Tracking Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Toggl-style time tracking system with timer, calendar view (week/day), drag/resize/copy-paste of time blocks. + +**Architecture:** New `TimeEntry` entity with API Platform, custom provider for active timer. Frontend: Pinia timer store, calendar page with interactive grid, sidebar timer widget. + +**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM, Nuxt 4 / Vue 3 / Pinia / Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-03-10-time-tracking-design.md` + +--- + +## Chunk 1: Backend — Entity, Migration, API + +### Task 1: Create TimeEntry Entity + +**Files:** +- Create: `src/Entity/TimeEntry.php` +- Create: `src/Repository/TimeEntryRepository.php` + +- [ ] **Step 1: Create the Repository** + +```php + + */ +class TimeEntryRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TimeEntry::class); + } +} +``` + +- [ ] **Step 2: Create the TimeEntry Entity** + +```php + 'Get the active timer for the current user'], + ), + new Post(security: "is_granted('ROLE_USER')"), + new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"), + new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"), + ], + normalizationContext: ['groups' => ['time_entry:read']], + denormalizationContext: ['groups' => ['time_entry:write']], + order: ['startedAt' => 'DESC'], +)] +#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'types' => 'exact'])] +#[ApiFilter(DateFilter::class, properties: ['startedAt'])] +#[ORM\Entity(repositoryClass: TimeEntryRepository::class)] +#[ORM\UniqueConstraint(name: 'uniq_active_timer', columns: ['user_id'], options: ['where' => '(stopped_at IS NULL)'])] +class TimeEntry +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['time_entry:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['time_entry:read', 'time_entry:write'])] + private ?string $title = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['time_entry:read', 'time_entry:write'])] + private ?string $description = null; + + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)] + #[Groups(['time_entry:read', 'time_entry:write'])] + private ?\DateTimeImmutable $startedAt = null; + + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)] + #[Groups(['time_entry:read', 'time_entry:write'])] + private ?\DateTimeImmutable $stoppedAt = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['time_entry:read', 'time_entry:write'])] + private ?User $user = null; + + #[ORM\ManyToOne(targetEntity: Project::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['time_entry:read', 'time_entry:write'])] + private ?Project $project = null; + + #[ORM\ManyToOne(targetEntity: Task::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['time_entry:read', 'time_entry:write'])] + private ?Task $task = null; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: TaskType::class)] + #[ORM\JoinTable(name: 'time_entry_task_type')] + #[Groups(['time_entry:read', 'time_entry:write'])] + private Collection $types; + + public function __construct() + { + $this->types = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(?string $title): static + { + $this->title = $title; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getStartedAt(): ?\DateTimeImmutable + { + return $this->startedAt; + } + + public function setStartedAt(\DateTimeImmutable $startedAt): static + { + $this->startedAt = $startedAt; + + return $this; + } + + public function getStoppedAt(): ?\DateTimeImmutable + { + return $this->stoppedAt; + } + + public function setStoppedAt(?\DateTimeImmutable $stoppedAt): static + { + $this->stoppedAt = $stoppedAt; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getProject(): ?Project + { + return $this->project; + } + + public function setProject(?Project $project): static + { + $this->project = $project; + + return $this; + } + + public function getTask(): ?Task + { + return $this->task; + } + + public function setTask(?Task $task): static + { + $this->task = $task; + + return $this; + } + + /** @return Collection */ + public function getTypes(): Collection + { + return $this->types; + } + + public function addType(TaskType $type): static + { + if (!$this->types->contains($type)) { + $this->types->add($type); + } + + return $this; + } + + public function removeType(TaskType $type): static + { + $this->types->removeElement($type); + + return $this; + } +} +``` + +- [ ] **Step 3: Generate and run migration** + +```bash +make shell +# Inside container: +php bin/console doctrine:migrations:diff +exit +make migration-migrate +``` + +Verify: the migration creates `time_entry` table, `time_entry_task_type` join table, and all FK constraints. + +- [ ] **Step 4: Commit** + +```bash +git add src/Entity/TimeEntry.php src/Repository/TimeEntryRepository.php migrations/ +git commit -m "feat(time-tracking) : add TimeEntry entity and migration" +``` + +--- + +### Task 2: Create ActiveTimeEntryProvider + +**Files:** +- Create: `src/State/ActiveTimeEntryProvider.php` + +- [ ] **Step 1: Create the provider** + +Follow the same pattern as `MeProvider`. This provider returns the single active timer (`stoppedAt IS NULL`) for the authenticated user. + +```php + + */ +final readonly class ActiveTimeEntryProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private TimeEntryRepository $timeEntryRepository, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?TimeEntry + { + $user = $this->security->getUser(); + + if (!$user) { + return null; + } + + return $this->timeEntryRepository->findOneBy([ + 'user' => $user, + 'stoppedAt' => null, + ]); + } +} +``` + +- [ ] **Step 2: Add findActiveByUser to Repository** + +Add a dedicated method to `TimeEntryRepository` for clarity: + +```php +public function findActiveByUser(User $user): ?TimeEntry +{ + return $this->findOneBy([ + 'user' => $user, + 'stoppedAt' => null, + ]); +} +``` + +Then update `ActiveTimeEntryProvider` to use `$this->timeEntryRepository->findActiveByUser($user)`. + +- [ ] **Step 3: Test the API manually** + +```bash +# Start containers if not running +make start + +# Login as admin +curl -c cookies.txt -X POST http://localhost:8082/api/login_check \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin"}' + +# Get active timer (should return empty/null) +curl -b cookies.txt http://localhost:8082/api/time_entries/active + +# Create a time entry (timer start) +curl -b cookies.txt -X POST http://localhost:8082/api/time_entries \ + -H 'Content-Type: application/json' \ + -d '{"startedAt":"2026-03-10T10:00:00+00:00","user":"/api/users/1"}' + +# Get active timer (should return the entry) +curl -b cookies.txt http://localhost:8082/api/time_entries/active + +# Stop the timer +curl -b cookies.txt -X PATCH http://localhost:8082/api/time_entries/1 \ + -H 'Content-Type: application/merge-patch+json' \ + -d '{"stoppedAt":"2026-03-10T11:30:00+00:00"}' + +# Get active timer (should return null again) +curl -b cookies.txt http://localhost:8082/api/time_entries/active +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/State/ActiveTimeEntryProvider.php src/Repository/TimeEntryRepository.php +git commit -m "feat(time-tracking) : add ActiveTimeEntryProvider for current user timer" +``` + +--- + +### Task 3: Add TimeEntry fixtures + +**Files:** +- Modify: `src/DataFixtures/AppFixtures.php` + +- [ ] **Step 1: Add sample time entries to fixtures** + +At the end of the `load()` method in `AppFixtures.php`, after the existing task fixtures, add time entries for the SIRH project. Use the existing `$projectSirh`, `$admin` user, and task type references. + +First, add the import at the top of `AppFixtures.php` alongside the other entity imports: + +```php +use App\Entity\TimeEntry; +``` + +Then add at the end of the `load()` method, before the final `$manager->flush()`: + +```php +// --- Time Entries (SIRH project, admin user) --- +$timeEntryData = [ + ['title' => 'Réunion', 'project' => $projectSirh, 'type' => $typeAuth, 'start' => '09:00', 'stop' => '09:45', 'day' => 1], + ['title' => 'Page accueil', 'project' => $projectSirh, 'type' => $typePassword, 'start' => '10:00', 'stop' => '12:00', 'day' => 0], + ['title' => 'Design admin', 'project' => $projectSirh, 'type' => $typeAuth, 'start' => '09:30', 'stop' => '11:00', 'day' => 2], + ['title' => 'Page accueil', 'project' => $projectSirh, 'type' => $typePassword, 'start' => '10:30', 'stop' => '12:15', 'day' => 1], + ['title' => 'System os', 'project' => $projectSirh, 'type' => $typeCalendar, 'start' => '13:00', 'stop' => '15:30', 'day' => 0], + ['title' => 'Login', 'project' => $projectSirh, 'type' => $typePassword, 'start' => '13:00', 'stop' => '15:00', 'day' => 1], + ['title' => 'Script vault', 'project' => $projectSirh, 'type' => $typeCalendar, 'start' => '10:00', 'stop' => '12:00', 'day' => 3], + ['title' => 'Script backup BDD', 'project' => $projectSirh, 'type' => $typeAuth, 'start' => '13:30', 'stop' => '15:00', 'day' => 3], + ['title' => 'Maquette', 'project' => $projectSirh, 'type' => null, 'start' => '09:00', 'stop' => '11:00', 'day' => 4], + ['title' => 'PC compta', 'project' => $projectSirh, 'type' => null, 'start' => '13:30', 'stop' => '15:30', 'day' => 4], +]; + +$monday = new \DateTimeImmutable('monday this week', new \DateTimeZone('UTC')); + +foreach ($timeEntryData as $data) { + $entry = new TimeEntry(); + $entry->setTitle($data['title']); + $entry->setUser($admin); + $entry->setProject($data['project']); + $entry->setStartedAt($monday->modify("+{$data['day']} days")->modify($data['start'])); + $entry->setStoppedAt($monday->modify("+{$data['day']} days")->modify($data['stop'])); + if ($data['type']) { + $entry->addType($data['type']); + } + $manager->persist($entry); +} +``` + +Note: The existing `$manager->flush()` at the end of `load()` will persist these entries. Do not add a second `flush()`. + +- [ ] **Step 2: Reload fixtures** + +```bash +make db-reset +``` + +- [ ] **Step 3: Verify via API** + +```bash +curl -b cookies.txt "http://localhost:8082/api/time_entries" +``` + +Expected: JSON-LD collection with 10 time entries. + +- [ ] **Step 4: Commit** + +```bash +git add src/DataFixtures/AppFixtures.php +git commit -m "feat(time-tracking) : add time entry fixtures" +``` + +--- + +## Chunk 2: Frontend — DTO, Service, Timer Store + +### Task 4: Create TimeEntry DTO + +**Files:** +- Create: `frontend/services/dto/time-entry.ts` +- Modify: `frontend/services/dto/task.ts` + +- [ ] **Step 1: Add `project` field to Task DTO** + +In `frontend/services/dto/task.ts`, add the `project` field to the `Task` type. Import `Project` from `./project`: + +```typescript +import type { Project } from './project' +``` + +Add to `Task` type after `group`: + +```typescript +project: Project +``` + +- [ ] **Step 2: Create TimeEntry DTO** + +```typescript +import type { UserData } from './user-data' +import type { Project } from './project' +import type { Task } from './task' +import type { TaskType } from './task-type' + +export type TimeEntry = { + id: number + '@id'?: string + title: string | null + description: string | null + startedAt: string + stoppedAt: string | null + user: UserData + project: Project | null + task: Task | null + types: TaskType[] +} + +export type TimeEntryWrite = { + title?: string | null + description?: string | null + startedAt: string + stoppedAt?: string | null + user: string + project?: string | null + task?: string | null + types?: string[] +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/services/dto/time-entry.ts frontend/services/dto/task.ts +git commit -m "feat(time-tracking) : add TimeEntry DTO and project field to Task DTO" +``` + +--- + +### Task 5: Create TimeEntry service + +**Files:** +- Create: `frontend/services/time-entries.ts` + +- [ ] **Step 1: Create the service** + +Follow the exact same pattern as `frontend/services/tasks.ts`: + +```typescript +import type { TimeEntry, TimeEntryWrite } from './dto/time-entry' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useTimeEntryService() { + const api = useApi() + + async function getByDateRange(params: { + after: string + before: string + user?: number + types?: number[] + }): Promise { + const query: Record = { + 'startedAt[after]': params.after, + 'startedAt[before]': params.before, + } + if (params.user) { + query.user = `/api/users/${params.user}` + } + const data = await api.get>('/time_entries', query) + return extractHydraMembers(data) + } + + async function getActive(): Promise { + const result = await api.get('/time_entries/active', {}, { toast: false }) + return result ?? null + } + + async function create(payload: TimeEntryWrite): Promise { + return api.post('/time_entries', payload as Record, { + toastSuccessKey: 'timeEntries.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/time_entries/${id}`, payload as Record, { + toastSuccessKey: 'timeEntries.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/time_entries/${id}`, {}, { + toastSuccessKey: 'timeEntries.deleted', + }) + } + + return { getByDateRange, getActive, create, update, remove } +} +``` + +- [ ] **Step 2: Add i18n keys** + +In `frontend/i18n/locales/fr.json`, add: + +```json +"timeEntries": { + "created": "Temps enregistré", + "updated": "Temps modifié", + "deleted": "Temps supprimé" +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/services/time-entries.ts frontend/i18n/locales/fr.json +git commit -m "feat(time-tracking) : add time entry service and i18n keys" +``` + +--- + +### Task 6: Create Timer Store + +**Files:** +- Create: `frontend/stores/timer.ts` + +- [ ] **Step 1: Create the timer store** + +```typescript +import { defineStore } from 'pinia' +import type { TimeEntry } from '~/services/dto/time-entry' +import type { Task } from '~/services/dto/task' + +export const useTimerStore = defineStore('timer', () => { + const activeEntry = ref(null) + const now = ref(Date.now()) + let intervalId: ReturnType | null = null + + const isRunning = computed(() => activeEntry.value !== null) + + const elapsed = computed(() => { + if (!activeEntry.value) return 0 + const start = new Date(activeEntry.value.startedAt).getTime() + return Math.floor((now.value - start) / 1000) + }) + + const elapsedFormatted = computed(() => { + const total = elapsed.value + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + return [h, m, s].map((v) => String(v).padStart(2, '0')).join(':') + }) + + function startTicking() { + stopTicking() + now.value = Date.now() + intervalId = setInterval(() => { + now.value = Date.now() + }, 1000) + } + + function stopTicking() { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + + async function fetchActive() { + const { getActive } = useTimeEntryService() + activeEntry.value = await getActive() + if (activeEntry.value) { + startTicking() + } else { + stopTicking() + } + } + + async function start() { + const authStore = useAuthStore() + if (!authStore.user) return + + if (isRunning.value) { + await stop() + } + + const { create } = useTimeEntryService() + activeEntry.value = await create({ + startedAt: new Date().toISOString(), + user: `/api/users/${authStore.user.id}`, + }) + startTicking() + } + + async function startFromTask(task: Task) { + const authStore = useAuthStore() + if (!authStore.user) return + + if (isRunning.value) { + await stop() + } + + const { create } = useTimeEntryService() + activeEntry.value = await create({ + startedAt: new Date().toISOString(), + user: `/api/users/${authStore.user.id}`, + title: task.title, + project: task.project?.['@id'] ?? `/api/projects/${task.project?.id}`, + task: task['@id'] ?? `/api/tasks/${task.id}`, + types: task.types.map((t) => t['@id'] ?? `/api/task_types/${t.id}`), + }) + startTicking() + } + + async function stop() { + if (!activeEntry.value) return + + const { update } = useTimeEntryService() + await update(activeEntry.value.id, { + stoppedAt: new Date().toISOString(), + }) + activeEntry.value = null + stopTicking() + } + + return { + activeEntry, + isRunning, + elapsed, + elapsedFormatted, + fetchActive, + start, + startFromTask, + stop, + } +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/stores/timer.ts +git commit -m "feat(time-tracking) : add timer Pinia store" +``` + +--- + +## Chunk 3: Frontend — Sidebar Timer + TaskCard Integration + +### Task 7: Create SidebarTimer component + +**Files:** +- Create: `frontend/components/SidebarTimer.vue` + +- [ ] **Step 1: Create the component** + +```vue + + + +``` + +- [ ] **Step 2: Integrate into default.vue layout** + +In `frontend/layouts/default.vue`, add the `SidebarTimer` between the nav and the bottom version/collapse section. Find the closing `` tag and add before the `
`: + +```vue +
+ +
+``` + +Also add `fetchActive` call in the ` +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/TimeEntryDrawer.vue +git commit -m "feat(time-tracking) : add TimeEntryDrawer component" +``` + +--- + +### Task 10: Create TimeEntryBlock component + +**Files:** +- Create: `frontend/components/TimeEntryBlock.vue` + +- [ ] **Step 1: Create the block component** + +This component renders a single time entry block in the calendar grid. It handles drag-to-move and resize (drag bottom edge). + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/TimeEntryBlock.vue +git commit -m "feat(time-tracking) : add TimeEntryBlock component with drag and resize" +``` + +--- + +### Task 11: Create TimeEntryContextMenu component + +**Files:** +- Create: `frontend/components/TimeEntryContextMenu.vue` + +- [ ] **Step 1: Create the context menu** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/TimeEntryContextMenu.vue +git commit -m "feat(time-tracking) : add context menu for copy/paste/delete" +``` + +--- + +### Task 12: Create TimeTrackingCalendar component + +**Files:** +- Create: `frontend/components/TimeTrackingCalendar.vue` + +- [ ] **Step 1: Create the calendar grid** + +This is the largest component. It renders the hour rows and day columns, positions `TimeEntryBlock` components, and handles drop events for moving entries. + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/TimeTrackingCalendar.vue +git commit -m "feat(time-tracking) : add TimeTrackingCalendar grid component" +``` + +--- + +## Chunk 5: Frontend — Time Tracking Page (Assembly) + +### Task 13: Create the Time Tracking page + +**Files:** +- Create: `frontend/pages/time-tracking.vue` + +- [ ] **Step 1: Create the page** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/pages/time-tracking.vue +git commit -m "feat(time-tracking) : add time tracking page with calendar, drawer, and context menu" +``` + +--- + +### Task 14: Add sidebar navigation link + +**Files:** +- Modify: `frontend/layouts/default.vue` + +- [ ] **Step 1: Add "Suivi de temps" link** + +In `default.vue`, after the "Clients" `SidebarLink` and before the "Administration" link, add: + +```vue + +``` + +- [ ] **Step 2: Verify the full flow** + +```bash +make dev-nuxt +``` + +1. Login as `admin/admin` +2. Click "Suivi de temps" in sidebar → page loads with calendar +3. Fixtures should show time entries as colored blocks +4. Click a block → drawer opens in edit mode +5. Click "+ Ajouter une Activité" → drawer opens in create mode +6. Click on empty grid slot → drawer opens with pre-filled start time +7. Drag a block to another slot → entry moves +8. Drag bottom edge of a block → entry resizes +9. Right-click on block → context menu with Copy/Delete +10. Copy a block, right-click empty slot → Paste +11. Start timer from sidebar → timer counts +12. Go to kanban, click play on a card → timer starts with task data + +- [ ] **Step 3: Commit** + +```bash +git add frontend/layouts/default.vue +git commit -m "feat(time-tracking) : add time tracking link in sidebar navigation" +``` + +--- + +## Summary + +| Chunk | Tasks | Description | +|-------|-------|-------------| +| 1 | 1-3 | Backend: Entity, Migration, Provider, Fixtures | +| 2 | 4-6 | Frontend: DTO, Service, Timer Store | +| 3 | 7-8 | Frontend: Sidebar Timer, TaskCard integration | +| 4 | 9-12 | Frontend: Drawer, Block, ContextMenu, Calendar components | +| 5 | 13-14 | Frontend: Page assembly, Sidebar link, Full integration |