Files
Lesstime/docs/superpowers/plans/2026-03-10-time-tracking.md
2026-03-10 22:05:46 +01:00

55 KiB

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

declare(strict_types=1);

namespace App\Repository;

use App\Entity\TimeEntry;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<TimeEntry>
 */
class TimeEntryRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, TimeEntry::class);
    }
}
  • Step 2: Create the TimeEntry Entity
<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TimeEntryRepository;
use App\State\ActiveTimeEntryProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Get(
            uriTemplate: '/time_entries/active',
            provider: ActiveTimeEntryProvider::class,
            openapiContext: ['summary' => '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<int, TaskType> */
    #[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<int, TaskType> */
    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
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
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

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\TimeEntry;
use App\Repository\TimeEntryRepository;
use Symfony\Bundle\SecurityBundle\Security;

/**
 * @implements ProviderInterface<TimeEntry>
 */
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:

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

use App\Entity\TimeEntry;

Then add at the end of the load() method, before the final $manager->flush():

// --- 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
make db-reset
  • Step 3: Verify via API
curl -b cookies.txt "http://localhost:8082/api/time_entries"

Expected: JSON-LD collection with 10 time entries.

  • Step 4: Commit
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:

import type { Project } from './project'

Add to Task type after group:

project: Project
  • Step 2: Create TimeEntry DTO
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
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:

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<TimeEntry[]> {
        const query: Record<string, unknown> = {
            'startedAt[after]': params.after,
            'startedAt[before]': params.before,
        }
        if (params.user) {
            query.user = `/api/users/${params.user}`
        }
        const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
        return extractHydraMembers(data)
    }

    async function getActive(): Promise<TimeEntry | null> {
        const result = await api.get<TimeEntry | null>('/time_entries/active', {}, { toast: false })
        return result ?? null
    }

    async function create(payload: TimeEntryWrite): Promise<TimeEntry> {
        return api.post<TimeEntry>('/time_entries', payload as Record<string, unknown>, {
            toastSuccessKey: 'timeEntries.created',
        })
    }

    async function update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry> {
        return api.patch<TimeEntry>(`/time_entries/${id}`, payload as Record<string, unknown>, {
            toastSuccessKey: 'timeEntries.updated',
        })
    }

    async function remove(id: number): Promise<void> {
        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:

"timeEntries": {
    "created": "Temps enregistré",
    "updated": "Temps modifié",
    "deleted": "Temps supprimé"
}
  • Step 3: Commit
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

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<TimeEntry | null>(null)
    const now = ref(Date.now())
    let intervalId: ReturnType<typeof setInterval> | 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
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

<template>
    <div class="flex items-center gap-2">
        <button
            class="flex items-center justify-center rounded-full transition-colors"
            :class="timerStore.isRunning
                ? 'bg-red-500 hover:bg-red-600 text-white'
                : 'bg-green-500 hover:bg-green-600 text-white'"
            style="width: 32px; height: 32px;"
            :title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
            @click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
        >
            <Icon
                :name="timerStore.isRunning ? 'mdi:stop' : 'mdi:play'"
                size="18"
            />
        </button>
        <span
            v-if="!collapsed"
            class="font-mono text-sm font-bold"
            :class="timerStore.isRunning ? 'text-white' : 'text-neutral-400'"
        >
            {{ timerStore.elapsedFormatted }}
        </span>
    </div>
</template>

<script setup lang="ts">
defineProps<{
    collapsed: boolean
}>()

const timerStore = useTimerStore()
</script>
  • 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 </nav> tag and add before the <div class="flex flex-col gap-2 items-center p-4">:

<div class="px-4 py-3 border-t border-secondary-500">
    <SidebarTimer :collapsed="ui.sidebarCollapsed" />
</div>

Also add fetchActive call in the <script setup>. After const currentProjectId = computed(...):

const timerStore = useTimerStore()

onMounted(() => {
    timerStore.fetchActive()
})

onMounted is auto-imported by Nuxt — no manual import needed.

  • Step 3: Verify visually
make dev-nuxt

Open http://localhost:3002, login as admin/admin. The timer should appear in the sidebar bottom area with 00:00:00 and a green play button. Click play → timer starts counting. Click stop → resets.

  • Step 4: Commit
git add frontend/components/SidebarTimer.vue frontend/layouts/default.vue
git commit -m "feat(time-tracking) : add sidebar timer widget"

Task 8: Connect TaskCard play button to timer

Files:

  • Modify: frontend/components/TaskCard.vue

  • Step 1: Wire the play button

In TaskCard.vue, replace the play button @click.stop with an actual handler:

Replace:

<button
    class="shrink-0 text-neutral-400 hover:text-primary-500"
    @click.stop
>
    <Icon name="mdi:play-circle-outline" size="20" />
</button>

With:

<button
    class="shrink-0 text-neutral-400 hover:text-primary-500"
    @click.stop="onPlay"
>
    <Icon name="mdi:play-circle-outline" size="20" />
</button>

Add in the <script setup>:

const timerStore = useTimerStore()

function onPlay() {
    timerStore.startFromTask(props.task)
}
  • Step 2: Verify

Go to a project kanban page. Click the play button on a task card. The sidebar timer should start. The API should create a time entry with the task's title, project, and types.

  • Step 3: Commit
git add frontend/components/TaskCard.vue
git commit -m "feat(time-tracking) : connect TaskCard play button to timer store"

Chunk 4: Frontend — Time Tracking Page (Calendar Grid)

Task 9: Create TimeEntryDrawer component

Files:

  • Create: frontend/components/TimeEntryDrawer.vue

  • Step 1: Create the drawer

Uses AppDrawer pattern from existing drawers (e.g., TaskDrawer.vue).

<template>
    <AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
        <form class="space-y-4" @submit.prevent="onSubmit">
            <div>
                <label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
                <input
                    v-model="form.title"
                    type="text"
                    class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
                    placeholder="Que fais-tu ?"
                />
            </div>

            <div>
                <label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
                <textarea
                    v-model="form.description"
                    rows="3"
                    class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
                />
            </div>

            <div class="grid grid-cols-2 gap-3">
                <div>
                    <label class="mb-1 block text-sm font-semibold text-neutral-700">Heure début</label>
                    <input
                        v-model="form.startedAt"
                        type="datetime-local"
                        class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
                    />
                </div>
                <div>
                    <label class="mb-1 block text-sm font-semibold text-neutral-700">Heure fin</label>
                    <input
                        v-model="form.stoppedAt"
                        type="datetime-local"
                        class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
                    />
                </div>
            </div>

            <div>
                <label class="mb-1 block text-sm font-semibold text-neutral-700">Utilisateur</label>
                <select
                    v-model="form.userId"
                    class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
                >
                    <option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
                </select>
            </div>

            <div>
                <label class="mb-1 block text-sm font-semibold text-neutral-700">Projet</label>
                <select
                    v-model="form.projectId"
                    class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
                >
                    <option :value="null"> Aucun </option>
                    <option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
                </select>
            </div>

            <div>
                <label class="mb-1 block text-sm font-semibold text-neutral-700">Type</label>
                <select
                    v-model="form.typeId"
                    class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
                >
                    <option :value="null"> Aucun </option>
                    <option v-for="t in types" :key="t.id" :value="t.id">{{ t.label }}</option>
                </select>
            </div>

            <button
                type="submit"
                class="w-full rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
            >
                Enregistrer
            </button>
        </form>
    </AppDrawer>
</template>

<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskType } from '~/services/dto/task-type'

const props = defineProps<{
    modelValue: boolean
    entry?: TimeEntry | null
    prefillStartedAt?: string | null
    users: UserData[]
    projects: Project[]
    types: TaskType[]
}>()

const emit = defineEmits<{
    (e: 'update:modelValue', value: boolean): void
    (e: 'saved'): void
}>()

const isOpen = computed({
    get: () => props.modelValue,
    set: (v) => emit('update:modelValue', v),
})

const isEditing = computed(() => !!props.entry)

const authStore = useAuthStore()

const form = reactive({
    title: '',
    description: '',
    startedAt: '',
    stoppedAt: '',
    userId: authStore.user?.id ?? null as number | null,
    projectId: null as number | null,
    typeId: null as number | null,
})

watch(() => props.entry, (entry) => {
    if (entry) {
        form.title = entry.title ?? ''
        form.description = entry.description ?? ''
        form.startedAt = toLocalDatetimeInput(entry.startedAt)
        form.stoppedAt = entry.stoppedAt ? toLocalDatetimeInput(entry.stoppedAt) : ''
        form.userId = entry.user?.id ?? authStore.user?.id ?? null
        form.projectId = entry.project?.id ?? null
        form.typeId = entry.types?.[0]?.id ?? null
    } else {
        form.title = ''
        form.description = ''
        form.startedAt = props.prefillStartedAt ? toLocalDatetimeInput(props.prefillStartedAt) : ''
        form.stoppedAt = ''
        form.userId = authStore.user?.id ?? null
        form.projectId = null
        form.typeId = null
    }
}, { immediate: true })

function toLocalDatetimeInput(iso: string): string {
    const d = new Date(iso)
    const offset = d.getTimezoneOffset()
    const local = new Date(d.getTime() - offset * 60000)
    return local.toISOString().slice(0, 16)
}

function toISOFromLocal(localStr: string): string {
    return new Date(localStr).toISOString()
}

async function onSubmit() {
    const { create, update } = useTimeEntryService()

    const payload: Record<string, unknown> = {
        title: form.title || null,
        description: form.description || null,
        startedAt: toISOFromLocal(form.startedAt),
        stoppedAt: form.stoppedAt ? toISOFromLocal(form.stoppedAt) : null,
        user: `/api/users/${form.userId}`,
        project: form.projectId ? `/api/projects/${form.projectId}` : null,
        types: form.typeId ? [`/api/task_types/${form.typeId}`] : [],
    }

    if (isEditing.value && props.entry) {
        await update(props.entry.id, payload)
    } else {
        await create(payload as any)
    }

    emit('saved')
    isOpen.value = false
}
</script>
  • Step 2: Commit
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).

<template>
    <div
        ref="blockEl"
        class="absolute left-1 right-1 cursor-pointer overflow-hidden rounded-md px-2 py-1 text-xs text-white shadow-sm select-none"
        :style="blockStyle"
        draggable="true"
        @click.stop="emit('click', entry)"
        @contextmenu.prevent="emit('contextmenu', $event, entry)"
        @dragstart="onDragStart"
        @dragend="onDragEnd"
    >
        <div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
        <div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
        <div class="mt-0.5 flex items-center gap-1">
            <span
                v-for="type in entry.types"
                :key="type.id"
                class="rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
                :style="{ backgroundColor: type.color }"
            >
                {{ type.label }}
            </span>
            <span class="ml-auto text-[10px] opacity-80">{{ duration }}</span>
        </div>

        <!-- Resize handle -->
        <div
            class="absolute bottom-0 left-0 right-0 h-2 cursor-s-resize"
            @mousedown.stop.prevent="onResizeStart"
        />
    </div>
</template>

<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'

const props = defineProps<{
    entry: TimeEntry
    hourHeight: number
    dayStartHour: number
}>()

const emit = defineEmits<{
    (e: 'click', entry: TimeEntry): void
    (e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
    (e: 'resize', entry: TimeEntry, newStoppedAt: string): void
    (e: 'move', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
}>()

const blockEl = ref<HTMLElement | null>(null)

const startDate = computed(() => new Date(props.entry.startedAt))
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())

const duration = computed(() => {
    const diff = endDate.value.getTime() - startDate.value.getTime()
    const h = Math.floor(diff / 3600000)
    const m = Math.floor((diff % 3600000) / 60000)
    const s = Math.floor((diff % 60000) / 1000)
    return [h, m, s].map((v) => String(v).padStart(2, '0')).join(' : ')
})

const blockStyle = computed(() => {
    const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes()
    const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes()
    const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
    const heightPx = Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
    const bgColor = props.entry.project?.color ?? '#94a3b8'

    return {
        top: `${topPx}px`,
        height: `${heightPx}px`,
        backgroundColor: bgColor,
    }
})

function onDragStart(event: DragEvent) {
    event.dataTransfer!.effectAllowed = 'move'
    event.dataTransfer!.setData('application/time-entry-id', String(props.entry.id))
    event.dataTransfer!.setData('application/time-entry-start', props.entry.startedAt)
    event.dataTransfer!.setData('application/time-entry-stop', props.entry.stoppedAt ?? '')
    ;(event.target as HTMLElement).classList.add('opacity-50')
}

function onDragEnd(event: DragEvent) {
    ;(event.target as HTMLElement).classList.remove('opacity-50')
}

function onResizeStart(event: MouseEvent) {
    const startY = event.clientY
    const originalHeight = blockEl.value?.offsetHeight ?? 0

    function onMouseMove(e: MouseEvent) {
        const delta = e.clientY - startY
        const newHeight = Math.max(originalHeight + delta, 20)
        if (blockEl.value) {
            blockEl.value.style.height = `${newHeight}px`
        }
    }

    function onMouseUp(e: MouseEvent) {
        document.removeEventListener('mousemove', onMouseMove)
        document.removeEventListener('mouseup', onMouseUp)

        const delta = e.clientY - startY
        const deltaMinutes = Math.round((delta / props.hourHeight) * 60 / 15) * 15
        const originalEnd = endDate.value
        const newEnd = new Date(originalEnd.getTime() + deltaMinutes * 60000)
        emit('resize', props.entry, newEnd.toISOString())
    }

    document.addEventListener('mousemove', onMouseMove)
    document.addEventListener('mouseup', onMouseUp)
}
</script>
  • Step 2: Commit
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

<template>
    <Teleport to="body">
        <div
            v-if="visible"
            ref="menuEl"
            class="fixed z-50 min-w-36 rounded-md border border-neutral-200 bg-white py-1 shadow-lg"
            :style="{ top: `${y}px`, left: `${x}px` }"
        >
            <button
                v-if="entry"
                class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
                @click="onCopy"
            >
                <Icon name="mdi:content-copy" size="16" />
                Copier
            </button>
            <button
                v-if="canPaste"
                class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
                @click="onPaste"
            >
                <Icon name="mdi:content-paste" size="16" />
                Coller
            </button>
            <button
                v-if="entry"
                class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
                @click="onDelete"
            >
                <Icon name="mdi:delete-outline" size="16" />
                Supprimer
            </button>
        </div>
    </Teleport>
</template>

<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'

const props = defineProps<{
    visible: boolean
    x: number
    y: number
    entry?: TimeEntry | null
    canPaste: boolean
}>()

const emit = defineEmits<{
    (e: 'close'): void
    (e: 'copy', entry: TimeEntry): void
    (e: 'paste'): void
    (e: 'delete', entry: TimeEntry): void
}>()

const menuEl = ref<HTMLElement | null>(null)

function onCopy() {
    if (props.entry) emit('copy', props.entry)
    emit('close')
}

function onPaste() {
    emit('paste')
    emit('close')
}

function onDelete() {
    if (props.entry) emit('delete', props.entry)
    emit('close')
}

function onClickOutside(event: MouseEvent) {
    if (menuEl.value && !menuEl.value.contains(event.target as Node)) {
        emit('close')
    }
}

watch(() => props.visible, (v) => {
    if (v) {
        setTimeout(() => document.addEventListener('click', onClickOutside), 0)
    } else {
        document.removeEventListener('click', onClickOutside)
    }
})

onUnmounted(() => {
    document.removeEventListener('click', onClickOutside)
})
</script>
  • Step 2: Commit
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.

<template>
    <div class="relative overflow-auto rounded-lg border border-neutral-200 bg-white" style="max-height: calc(100vh - 220px);">
        <!-- Day headers -->
        <div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white">
            <div class="w-16 shrink-0 border-r border-neutral-200" />
            <div
                v-for="day in days"
                :key="day.dateStr"
                class="flex-1 border-r border-neutral-100 py-2 text-center"
            >
                <div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
                    {{ day.dayNum }}
                </div>
                <div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
                    {{ day.label }}
                </div>
                <div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
            </div>
        </div>

        <!-- Grid body -->
        <div class="relative flex">
            <!-- Hour labels -->
            <div class="w-16 shrink-0">
                <div
                    v-for="hour in hours"
                    :key="hour"
                    class="flex items-start justify-end border-r border-neutral-200 pr-2 text-xs text-neutral-400"
                    :style="{ height: `${hourHeight}px` }"
                >
                    {{ String(hour).padStart(2, '0') }} : 00
                </div>
            </div>

            <!-- Day columns -->
            <div
                v-for="day in days"
                :key="day.dateStr"
                class="relative flex-1 border-r border-neutral-100"
                @dragover.prevent
                @drop="onDropOnDay($event, day)"
                @click="onClickGrid($event, day)"
                @contextmenu.prevent="onContextMenuGrid($event, day)"
            >
                <!-- Hour row lines -->
                <div
                    v-for="hour in hours"
                    :key="hour"
                    class="border-b border-neutral-100"
                    :style="{ height: `${hourHeight}px` }"
                />

                <!-- Time entry blocks -->
                <TimeEntryBlock
                    v-for="entry in entriesForDay(day.dateStr)"
                    :key="entry.id"
                    :entry="entry"
                    :hour-height="hourHeight"
                    :day-start-hour="0"
                    @click="emit('editEntry', $event)"
                    @contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
                    @resize="(ent, newStop) => emit('resizeEntry', ent, newStop)"
                />
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'

const props = defineProps<{
    entries: TimeEntry[]
    startDate: Date
    viewMode: 'week' | 'day'
}>()

const emit = defineEmits<{
    (e: 'editEntry', entry: TimeEntry): void
    (e: 'createEntry', startedAt: string): void
    (e: 'moveEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
    (e: 'resizeEntry', entry: TimeEntry, newStoppedAt: string): void
    (e: 'contextmenu', event: MouseEvent, entry: TimeEntry | null): void
}>()

const hourHeight = 60
const hours = Array.from({ length: 24 }, (_, i) => i)

const dayLabels = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']

const days = computed(() => {
    const count = props.viewMode === 'week' ? 7 : 1
    const result = []
    for (let i = 0; i < count; i++) {
        const d = new Date(props.startDate)
        d.setDate(d.getDate() + i)
        const dateStr = toDateStr(d)
        const dayEntries = props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
        const totalMs = dayEntries.reduce((sum, e) => {
            if (!e.stoppedAt) return sum
            return sum + (new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime())
        }, 0)
        const totalH = Math.floor(totalMs / 3600000)
        const totalM = Math.floor((totalMs % 3600000) / 60000)
        const totalS = Math.floor((totalMs % 60000) / 1000)

        result.push({
            date: new Date(d),
            dateStr,
            dayNum: d.getDate(),
            label: dayLabels[d.getDay()],
            totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
        })
    }
    return result
})

function toDateStr(d: Date): string {
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}

function isToday(d: Date): boolean {
    return toDateStr(d) === toDateStr(new Date())
}

function entriesForDay(dateStr: string): TimeEntry[] {
    return props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
}

function getHourFromY(event: MouseEvent, dayEl: HTMLElement): number {
    const rect = dayEl.getBoundingClientRect()
    const y = event.clientY - rect.top
    const minutes = Math.round((y / hourHeight) * 60 / 15) * 15
    return minutes / 60
}

function onClickGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
    const target = event.currentTarget as HTMLElement
    const hourDecimal = getHourFromY(event, target)
    const h = Math.floor(hourDecimal)
    const m = Math.round((hourDecimal - h) * 60)
    const d = new Date(day.date)
    d.setHours(h, m, 0, 0)
    emit('createEntry', d.toISOString())
}

function onContextMenuGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
    emit('contextmenu', event, null)
}

function onDropOnDay(event: DragEvent, day: { date: Date; dateStr: string }) {
    const entryId = event.dataTransfer?.getData('application/time-entry-id')
    const startStr = event.dataTransfer?.getData('application/time-entry-start')
    const stopStr = event.dataTransfer?.getData('application/time-entry-stop')

    if (!entryId || !startStr) return

    const entry = props.entries.find((e) => e.id === Number(entryId))
    if (!entry) return

    const target = event.currentTarget as HTMLElement
    const hourDecimal = getHourFromY(event as unknown as MouseEvent, target)
    const h = Math.floor(hourDecimal)
    const m = Math.round((hourDecimal - h) * 60)

    const originalStart = new Date(startStr)
    const originalStop = stopStr ? new Date(stopStr) : null
    const durationMs = originalStop ? originalStop.getTime() - originalStart.getTime() : 3600000

    const newStart = new Date(day.date)
    newStart.setHours(h, m, 0, 0)
    const newStop = new Date(newStart.getTime() + durationMs)

    emit('moveEntry', entry, newStart.toISOString(), newStop.toISOString())
}
</script>
  • Step 2: Commit
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

<template>
    <div>
        <div class="flex items-center justify-between">
            <h1 class="text-2xl font-bold text-neutral-900">Suivi des temps</h1>
            <button
                class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
                @click="openCreateDrawer()"
            >
                + Ajouter une Activité
            </button>
        </div>

        <div class="mt-4 flex items-center gap-4">
            <h2 class="text-lg font-bold text-orange-500">
                {{ currentMonthLabel }}
            </h2>

            <div class="flex items-center gap-1 rounded-md border border-neutral-200">
                <button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
                    <Icon name="mdi:chevron-left" size="20" />
                </button>
                <button
                    v-for="mode in (['week', 'day'] as const)"
                    :key="mode"
                    class="px-3 py-1 text-sm font-semibold transition"
                    :class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
                    @click="viewMode = mode"
                >
                    {{ mode === 'week' ? 'Semaine' : 'Jour' }}
                </button>
                <button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
                    <Icon name="mdi:chevron-right" size="20" />
                </button>
            </div>

            <select
                v-model="selectedUserId"
                class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
                @change="loadEntries"
            >
                <option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
            </select>

            <select
                v-model="selectedTypeId"
                class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
                @change="loadEntries"
            >
                <option :value="null">Type</option>
                <option v-for="t in types" :key="t.id" :value="t.id">{{ t.label }}</option>
            </select>
        </div>

        <div class="mt-4">
            <TimeTrackingCalendar
                :entries="filteredEntries"
                :start-date="startDate"
                :view-mode="viewMode"
                @edit-entry="openEditDrawer"
                @create-entry="openCreateDrawer"
                @move-entry="onMoveEntry"
                @resize-entry="onResizeEntry"
                @contextmenu="onContextMenu"
            />
        </div>

        <TimeEntryDrawer
            v-model="drawerOpen"
            :entry="editingEntry"
            :prefill-started-at="prefillStartedAt"
            :users="users"
            :projects="projects"
            :types="types"
            @saved="loadEntries"
        />

        <TimeEntryContextMenu
            :visible="contextMenu.visible"
            :x="contextMenu.x"
            :y="contextMenu.y"
            :entry="contextMenu.entry"
            :can-paste="!!clipboard"
            @close="contextMenu.visible = false"
            @copy="onCopy"
            @paste="onPaste"
            @delete="onDelete"
        />
    </div>
</template>

<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskType } from '~/services/dto/task-type'
import { extractHydraMembers } from '~/utils/api'

useHead({ title: 'Suivi des temps' })

const authStore = useAuthStore()
const timeEntryService = useTimeEntryService()

const viewMode = ref<'week' | 'day'>('week')
const startDate = ref(getMonday(new Date()))
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
const selectedTypeId = ref<number | null>(null)

const entries = ref<TimeEntry[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const types = ref<TaskType[]>([])

const drawerOpen = ref(false)
const editingEntry = ref<TimeEntry | null>(null)
const prefillStartedAt = ref<string | null>(null)
const clipboard = ref<TimeEntry | null>(null)

const contextMenu = reactive({
    visible: false,
    x: 0,
    y: 0,
    entry: null as TimeEntry | null,
    targetDate: null as string | null,
})

const currentMonthLabel = computed(() => {
    const d = startDate.value
    const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
    return `${months[d.getMonth()]} ${d.getFullYear()}`
})

const filteredEntries = computed(() => {
    if (!selectedTypeId.value) return entries.value
    return entries.value.filter((e) =>
        e.types.some((t) => t.id === selectedTypeId.value)
    )
})

function getMonday(d: Date): Date {
    const date = new Date(d)
    const day = date.getDay()
    const diff = date.getDate() - day + (day === 0 ? -6 : 1)
    date.setDate(diff)
    date.setHours(0, 0, 0, 0)
    return date
}

function navigatePrev() {
    const d = new Date(startDate.value)
    d.setDate(d.getDate() - (viewMode.value === 'week' ? 7 : 1))
    startDate.value = viewMode.value === 'week' ? getMonday(d) : d
    loadEntries()
}

function navigateNext() {
    const d = new Date(startDate.value)
    d.setDate(d.getDate() + (viewMode.value === 'week' ? 7 : 1))
    startDate.value = viewMode.value === 'week' ? getMonday(d) : d
    loadEntries()
}

function openCreateDrawer(startedAt?: string) {
    editingEntry.value = null
    prefillStartedAt.value = startedAt ?? null
    drawerOpen.value = true
}

function openEditDrawer(entry: TimeEntry) {
    editingEntry.value = entry
    prefillStartedAt.value = null
    drawerOpen.value = true
}

async function onMoveEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
    await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
    await loadEntries()
}

async function onResizeEntry(entry: TimeEntry, newStoppedAt: string) {
    await timeEntryService.update(entry.id, { stoppedAt: newStoppedAt })
    await loadEntries()
}

function onContextMenu(event: MouseEvent, entry: TimeEntry | null) {
    contextMenu.visible = true
    contextMenu.x = event.clientX
    contextMenu.y = event.clientY
    contextMenu.entry = entry
}

function onCopy(entry: TimeEntry) {
    clipboard.value = entry
}

async function onPaste() {
    if (!clipboard.value) return
    const { create } = useTimeEntryService()
    await create({
        title: clipboard.value.title ?? undefined,
        description: clipboard.value.description ?? undefined,
        startedAt: clipboard.value.startedAt,
        stoppedAt: clipboard.value.stoppedAt ?? undefined,
        user: `/api/users/${selectedUserId.value}`,
        project: clipboard.value.project ? `/api/projects/${clipboard.value.project.id}` : null,
        types: clipboard.value.types.map((t) => `/api/task_types/${t.id}`),
    })
    await loadEntries()
}

async function onDelete(entry: TimeEntry) {
    await timeEntryService.remove(entry.id)
    await loadEntries()
}

async function loadEntries() {
    const end = new Date(startDate.value)
    end.setDate(end.getDate() + (viewMode.value === 'week' ? 7 : 1))

    entries.value = await timeEntryService.getByDateRange({
        after: startDate.value.toISOString(),
        before: end.toISOString(),
        user: selectedUserId.value ?? undefined,
    })
}

async function loadReferenceData() {
    const api = useApi()

    const [usersData, projectsData, typesData] = await Promise.all([
        api.get<any>('/users'),
        api.get<any>('/projects'),
        api.get<any>('/task_types'),
    ])

    users.value = extractHydraMembers(usersData)
    projects.value = extractHydraMembers(projectsData)
    types.value = extractHydraMembers(typesData)
}

onMounted(async () => {
    await loadReferenceData()
    await loadEntries()
})

watch(viewMode, () => {
    startDate.value = viewMode.value === 'week' ? getMonday(startDate.value) : startDate.value
    loadEntries()
})
</script>
  • Step 2: Commit
git add frontend/pages/time-tracking.vue
git commit -m "feat(time-tracking) : add time tracking page with calendar, drawer, and context menu"

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:

<SidebarLink
    to="/time-tracking"
    icon="mdi:clock-outline"
    label="Suivi de temps"
    :collapsed="ui.sidebarCollapsed"
/>
  • Step 2: Verify the full flow
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
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