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