Files
Lesstime/docs/superpowers/plans/2026-03-24-time-entry-export.md
Matthieu 040cbfc588 docs : add time entry export implementation plan (LST-41)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:54:06 +01:00

24 KiB
Raw Blame History

Time Entry XLSX Export — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add XLSX export of time tracking data with detail + summary sheets for CIR/JEI tax documents.

Architecture: Custom Symfony controller generates XLSX via PhpSpreadsheet, returns BinaryFileResponse. Frontend adds an export button on time-tracking page that triggers download with current filters.

Tech Stack: PHP 8.4, Symfony 8.0, PhpSpreadsheet, Nuxt 4 / Vue 3

Spec: docs/superpowers/specs/2026-03-24-time-entry-export-design.md


Task 1: Install PhpSpreadsheet

Files:

  • Modify: composer.json

  • Step 1: Install the dependency

docker exec -t php-lesstime-fpm composer require phpoffice/phpspreadsheet
  • Step 2: Verify installation
docker exec -t php-lesstime-fpm php -r "require 'vendor/autoload.php'; new \PhpOffice\PhpSpreadsheet\Spreadsheet(); echo 'OK';"

Expected: OK

  • Step 3: Commit
git add composer.json composer.lock
git commit -m "chore : add phpoffice/phpspreadsheet dependency for time entry export"

Task 2: Add repository method for filtered time entries

Files:

  • Modify: src/Repository/TimeEntryRepository.php

  • Step 1: Add findForExport method

Add this method to TimeEntryRepository:

/**
 * @param int[]|null $tagIds
 * @return TimeEntry[]
 */
public function findForExport(
    \DateTimeImmutable $after,
    \DateTimeImmutable $before,
    ?User $user = null,
    ?Project $project = null,
    ?array $tagIds = null,
): array {
    $qb = $this->createQueryBuilder('te')
        ->andWhere('te.startedAt >= :after')
        ->andWhere('te.startedAt < :before')
        ->setParameter('after', $after)
        ->setParameter('before', $before)
        ->orderBy('te.startedAt', 'ASC');

    if (null !== $user) {
        $qb->andWhere('te.user = :user')
            ->setParameter('user', $user);
    }

    if (null !== $project) {
        $qb->andWhere('te.project = :project')
            ->setParameter('project', $project);
    }

    if (null !== $tagIds && [] !== $tagIds) {
        $qb->join('te.tags', 'tag')
            ->andWhere('tag.id IN (:tagIds)')
            ->setParameter('tagIds', $tagIds);
    }

    return $qb->getQuery()->getResult();
}
  • Step 2: Add missing use statements if needed

Ensure these imports are at the top of the file:

use App\Entity\Project;
use App\Entity\User;
  • Step 3: Verify no syntax errors
docker exec -t php-lesstime-fpm php -l src/Repository/TimeEntryRepository.php

Expected: No syntax errors detected

  • Step 4: Commit
git add src/Repository/TimeEntryRepository.php
git commit -m "feat : add findForExport repository method for time entries"

Task 3: Create TimeEntryExportService

Files:

  • Create: src/Service/TimeEntryExportService.php

  • Step 1: Create the service with all three sheets

Create src/Service/TimeEntryExportService.php:

<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\TimeEntry;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

class TimeEntryExportService
{
    private const array DETAIL_HEADERS = [
        'Date', 'Utilisateur', 'Projet', 'Tâche', 'Titre',
        'Tags', 'Début', 'Fin', 'Durée (h)', 'Description',
    ];

    private const array MONTH_NAMES = [
        1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
        5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
        9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
    ];

    /**
     * @param TimeEntry[] $timeEntries
     *
     * @return string Path to the generated temp file
     */
    public function generate(array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): string
    {
        $spreadsheet = new Spreadsheet();

        $this->buildDetailSheet($spreadsheet, $timeEntries);
        $this->buildProjectRecapSheet($spreadsheet, $timeEntries);
        $this->buildMonthRecapSheet($spreadsheet, $timeEntries, $from, $to);

        $spreadsheet->setActiveSheetIndex(0);

        $tempFile = tempnam(sys_get_temp_dir(), 'export_temps_') . '.xlsx';
        $writer = new Xlsx($spreadsheet);
        $writer->save($tempFile);

        return $tempFile;
    }

    /**
     * @param TimeEntry[] $timeEntries
     */
    private function buildDetailSheet(Spreadsheet $spreadsheet, array $timeEntries): void
    {
        $sheet = $spreadsheet->getActiveSheet();
        $sheet->setTitle('Détail');

        // Headers
        foreach (self::DETAIL_HEADERS as $col => $header) {
            $colLetter = Coordinate::stringFromColumnIndex($col + 1);
            $sheet->setCellValue("{$colLetter}1", $header);
        }
        $this->boldRow($sheet, 1, \count(self::DETAIL_HEADERS));

        // Data rows
        $row = 2;
        foreach ($timeEntries as $entry) {
            $duration = $this->computeDuration($entry);
            $task = $entry->getTask();
            $taskLabel = '';
            if (null !== $task) {
                $project = $task->getProject();
                $code = $project?->getCode() ?? '';
                $taskLabel = $code . '-' . $task->getNumber() . ' - ' . $task->getTitle();
            }

            $tagLabels = $entry->getTags()->map(fn ($t) => $t->getLabel() ?? '')->toArray();

            $sheet->setCellValue("A{$row}", $entry->getStartedAt()->format('Y-m-d'));
            $sheet->setCellValue("B{$row}", $entry->getUser()?->getUsername() ?? '');
            $sheet->setCellValue("C{$row}", $entry->getProject()?->getName() ?? '');
            $sheet->setCellValue("D{$row}", $taskLabel);
            $sheet->setCellValue("E{$row}", $entry->getTitle() ?? '');
            $sheet->setCellValue("F{$row}", implode(', ', $tagLabels));
            $sheet->setCellValue("G{$row}", $entry->getStartedAt()->format('H:i'));
            $sheet->setCellValue("H{$row}", $entry->getStoppedAt()?->format('H:i') ?? '');
            $sheet->setCellValue("I{$row}", round($duration, 2));
            $sheet->setCellValue("J{$row}", $entry->getDescription() ?? '');

            ++$row;
        }

        // Total row
        if ($row > 2) {
            $sheet->setCellValue("H{$row}", 'Total');
            $sheet->getStyle("H{$row}")->getFont()->setBold(true);
            $sheet->setCellValue("I{$row}", "=SUM(I2:I" . ($row - 1) . ')');
            $sheet->getStyle("I{$row}")->getFont()->setBold(true);
        }

        // Auto-size columns
        foreach (range('A', 'J') as $col) {
            $sheet->getColumnDimension($col)->setAutoSize(true);
        }
    }

    /**
     * @param TimeEntry[] $timeEntries
     */
    private function buildProjectRecapSheet(Spreadsheet $spreadsheet, array $timeEntries): void
    {
        $sheet = $spreadsheet->createSheet();
        $sheet->setTitle('Récap par projet');

        // Aggregate: user → project → hours
        $data = [];
        $projects = [];
        $users = [];

        foreach ($timeEntries as $entry) {
            $userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
            $projectName = $entry->getProject()?->getName() ?? 'Sans projet';
            $duration = $this->computeDuration($entry);

            $users[$userName] = true;
            $projects[$projectName] = true;
            $data[$userName][$projectName] = ($data[$userName][$projectName] ?? 0) + $duration;
        }

        ksort($users);
        ksort($projects);
        $projectList = array_keys($projects);
        $userList = array_keys($users);

        // Headers
        $sheet->setCellValue('A1', 'Utilisateur');
        $col = 2;
        foreach ($projectList as $project) {
            $letter = Coordinate::stringFromColumnIndex($col);
            $sheet->setCellValue("{$letter}1", $project);
            ++$col;
        }
        $totalLetter = Coordinate::stringFromColumnIndex($col);
        $sheet->setCellValue("{$totalLetter}1", 'Total');
        $this->boldRow($sheet, 1, $col);

        // Data rows
        $row = 2;
        foreach ($userList as $user) {
            $sheet->setCellValue("A{$row}", $user);
            $col = 2;
            $userTotal = 0;
            foreach ($projectList as $project) {
                $val = round($data[$user][$project] ?? 0, 2);
                $letter = Coordinate::stringFromColumnIndex($col);
                $sheet->setCellValue("{$letter}{$row}", $val);
                $userTotal += $val;
                ++$col;
            }
            $letter = Coordinate::stringFromColumnIndex($col);
            $sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
            $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
            ++$row;
        }

        // Total row
        $sheet->setCellValue("A{$row}", 'Total');
        $sheet->getStyle("A{$row}")->getFont()->setBold(true);
        $col = 2;
        foreach ($projectList as $project) {
            $projectTotal = 0;
            foreach ($userList as $user) {
                $projectTotal += $data[$user][$project] ?? 0;
            }
            $letter = Coordinate::stringFromColumnIndex($col);
            $sheet->setCellValue("{$letter}{$row}", round($projectTotal, 2));
            $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
            ++$col;
        }
        // Grand total
        $grandTotal = 0;
        foreach ($data as $userData) {
            foreach ($userData as $hours) {
                $grandTotal += $hours;
            }
        }
        $letter = Coordinate::stringFromColumnIndex($col);
        $sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
        $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);

        // Auto-size
        for ($c = 1; $c <= $col; ++$c) {
            $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
        }
    }

    /**
     * @param TimeEntry[] $timeEntries
     */
    private function buildMonthRecapSheet(Spreadsheet $spreadsheet, array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): void
    {
        $sheet = $spreadsheet->createSheet();
        $sheet->setTitle('Récap par mois');

        // Build month columns from the date range
        $months = [];
        $current = $from->modify('first day of this month');
        $end = $to->modify('first day of this month');
        while ($current <= $end) {
            $key = $current->format('Y-m');
            $label = self::MONTH_NAMES[(int) $current->format('n')] . ' ' . $current->format('Y');
            $months[$key] = $label;
            $current = $current->modify('+1 month');
        }

        // Aggregate: user → month-key → hours
        $data = [];
        $users = [];

        foreach ($timeEntries as $entry) {
            $userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
            $monthKey = $entry->getStartedAt()->format('Y-m');
            $duration = $this->computeDuration($entry);

            $users[$userName] = true;
            $data[$userName][$monthKey] = ($data[$userName][$monthKey] ?? 0) + $duration;
        }

        ksort($users);
        $userList = array_keys($users);
        $monthKeys = array_keys($months);

        // Headers
        $sheet->setCellValue('A1', 'Utilisateur');
        $col = 2;
        foreach ($months as $label) {
            $letter = Coordinate::stringFromColumnIndex($col);
            $sheet->setCellValue("{$letter}1", $label);
            ++$col;
        }
        $totalLetter = Coordinate::stringFromColumnIndex($col);
        $sheet->setCellValue("{$totalLetter}1", 'Total');
        $this->boldRow($sheet, 1, $col);

        // Data rows
        $row = 2;
        foreach ($userList as $user) {
            $sheet->setCellValue("A{$row}", $user);
            $col = 2;
            $userTotal = 0;
            foreach ($monthKeys as $monthKey) {
                $val = round($data[$user][$monthKey] ?? 0, 2);
                $letter = Coordinate::stringFromColumnIndex($col);
                $sheet->setCellValue("{$letter}{$row}", $val);
                $userTotal += $val;
                ++$col;
            }
            $letter = Coordinate::stringFromColumnIndex($col);
            $sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
            $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
            ++$row;
        }

        // Total row
        $sheet->setCellValue("A{$row}", 'Total');
        $sheet->getStyle("A{$row}")->getFont()->setBold(true);
        $col = 2;
        foreach ($monthKeys as $monthKey) {
            $monthTotal = 0;
            foreach ($userList as $user) {
                $monthTotal += $data[$user][$monthKey] ?? 0;
            }
            $letter = Coordinate::stringFromColumnIndex($col);
            $sheet->setCellValue("{$letter}{$row}", round($monthTotal, 2));
            $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
            ++$col;
        }
        $grandTotal = 0;
        foreach ($data as $userData) {
            foreach ($userData as $hours) {
                $grandTotal += $hours;
            }
        }
        $letter = Coordinate::stringFromColumnIndex($col);
        $sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
        $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);

        // Auto-size
        for ($c = 1; $c <= $col; ++$c) {
            $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
        }
    }

    private function computeDuration(TimeEntry $entry): float
    {
        $start = $entry->getStartedAt();
        $end = $entry->getStoppedAt();

        if (null === $start || null === $end) {
            return 0;
        }

        return ($end->getTimestamp() - $start->getTimestamp()) / 3600;
    }

    private function boldRow(Worksheet $sheet, int $row, int $colCount): void
    {
        for ($c = 1; $c <= $colCount; ++$c) {
            $letter = Coordinate::stringFromColumnIndex($c);
            $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
        }
    }
}
  • Step 2: Verify no syntax errors
docker exec -t php-lesstime-fpm php -l src/Service/TimeEntryExportService.php

Expected: No syntax errors detected

  • Step 3: Commit
git add src/Service/TimeEntryExportService.php
git commit -m "feat : add TimeEntryExportService generating XLSX with detail and recap sheets"

Task 4: Create TimeEntryExportController

Files:

  • Create: src/Controller/TimeEntryExportController.php

  • Step 1: Create the controller

Create src/Controller/TimeEntryExportController.php:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Project;
use App\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class TimeEntryExportController extends AbstractController
{
    public function __construct(
        private readonly TimeEntryRepository $timeEntryRepository,
        private readonly TimeEntryExportService $exportService,
        private readonly EntityManagerInterface $entityManager,
        private readonly Security $security,
    ) {}

    #[Route('/api/time_entries/export', name: 'time_entry_export', methods: ['GET'], priority: 1)]
    #[IsGranted('ROLE_USER')]
    public function __invoke(Request $request): BinaryFileResponse
    {
        $afterStr = $request->query->getString('after');
        $beforeStr = $request->query->getString('before');

        if ('' === $afterStr || '' === $beforeStr) {
            throw new BadRequestHttpException('Les paramètres "after" et "before" sont obligatoires.');
        }

        try {
            $after = new \DateTimeImmutable($afterStr);
            $before = new \DateTimeImmutable($beforeStr);
        } catch (\Exception) {
            throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
        }

        // Max range: 12 months
        if ($after->modify('+12 months') < $before) {
            throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
        }

        // Authorization: non-admin users can only export their own data
        $user = null;
        if (!$this->security->isGranted('ROLE_ADMIN')) {
            /** @var User $user */
            $user = $this->security->getUser();
        } else {
            $userId = $request->query->getInt('user');
            if ($userId > 0) {
                $user = $this->entityManager->getRepository(User::class)->find($userId);
            }
        }

        $project = null;
        $projectId = $request->query->getInt('project');
        if ($projectId > 0) {
            $project = $this->entityManager->getRepository(Project::class)->find($projectId);
        }

        /** @var int[] $tagIds */
        $tagIds = array_filter(
            array_map('intval', (array) $request->query->all('tags')),
            fn (int $id) => $id > 0,
        );

        $entries = $this->timeEntryRepository->findForExport(
            $after,
            $before,
            $user,
            $project,
            $tagIds ?: null,
        );

        $tempFile = $this->exportService->generate($entries, $after, $before);

        $filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));

        $response = new BinaryFileResponse($tempFile);
        $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
        $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
        $response->deleteFileAfterSend(true);

        return $response;
    }
}
  • Step 2: Verify no syntax errors
docker exec -t php-lesstime-fpm php -l src/Controller/TimeEntryExportController.php

Expected: No syntax errors detected

  • Step 3: Clear cache and verify route is registered
docker exec -t php-lesstime-fpm php bin/console cache:clear
docker exec -t php-lesstime-fpm php bin/console debug:router | grep time_entry_export

Expected: line showing time_entry_export route mapped to GET /api/time_entries/export

  • Step 4: Commit
git add src/Controller/TimeEntryExportController.php
git commit -m "feat : add TimeEntryExportController with auth, validation, and filters"

Task 5: Manual backend smoke test

  • Step 1: Test missing params returns 400
docker exec -t php-lesstime-fpm php bin/console debug:router time_entry_export

Then via curl (using admin fixture token):

curl -s -o /dev/null -w "%{http_code}" -b "BEARER=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)" "http://localhost:8082/api/time_entries/export"

Expected: 400

  • Step 2: Test valid export returns XLSX
TOKEN=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
curl -s -o /tmp/test-export.xlsx -w "%{http_code}" -b "BEARER=${TOKEN}" "http://localhost:8082/api/time_entries/export?after=2025-01-01&before=2026-12-31"
echo ""
file /tmp/test-export.xlsx

Expected: HTTP 200, file type contains Microsoft Excel or Zip archive

  • Step 3: Commit (no changes — verification only)

Task 6: Add frontend export method and i18n

Files:

  • Modify: frontend/services/time-entries.ts

  • Modify: frontend/i18n/locales/fr.json

  • Step 1: Add getExportUrl method to time-entries service

Add this function inside useTimeEntryService() before the return statement in frontend/services/time-entries.ts:

function getExportUrl(params: {
    after: string
    before: string
    user?: number
    project?: number
    tags?: number[]
}): string {
    const query = new URLSearchParams()
    query.set('after', params.after)
    query.set('before', params.before)
    if (params.user) query.set('user', String(params.user))
    if (params.project) query.set('project', String(params.project))
    if (params.tags?.length) {
        params.tags.forEach(id => query.append('tags[]', String(id)))
    }
    return `/api/time_entries/export?${query.toString()}`
}

Update the return statement to include getExportUrl:

return { getByDateRange, getActive, create, update, remove, getExportUrl }
  • Step 2: Add i18n key

In frontend/i18n/locales/fr.json, add "export": "Exporter" inside the "timeEntries" object.

  • Step 3: Commit
git add frontend/services/time-entries.ts frontend/i18n/locales/fr.json
git commit -m "feat : add getExportUrl to time-entries service and i18n key"

Task 7: Add export button to time-tracking page

Files:

  • Modify: frontend/pages/time-tracking.vue

  • Step 1: Add export button in template

In frontend/pages/time-tracking.vue, find the <div> containing the MalioSelect for tags (the last filter). After its closing </div>, add:

<button
    class="flex shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition"
    @click="exportTimeEntries"
>
    <Icon name="mdi:download" size="18" />
    {{ $t('timeEntries.export') }}
</button>
  • Step 2: Add export function in script

Add this function in the <script setup> section, after the existing helper functions (near loadEntries):

function getExportDateRange(): { after: string, before: string } {
    if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
        return {
            after: selectedDateFilter.value[0].toISOString().slice(0, 10),
            before: selectedDateFilter.value[1].toISOString().slice(0, 10),
        }
    }
    const end = new Date(startDate.value)
    end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
    return {
        after: startDate.value.toISOString().slice(0, 10),
        before: end.toISOString().slice(0, 10),
    }
}

function exportTimeEntries() {
    const { after, before } = getExportDateRange()

    const url = timeEntryService.getExportUrl({
        after,
        before,
        user: selectedUserId.value ?? undefined,
        project: selectedProjectId.value ?? undefined,
        tags: selectedTagId.value ? [selectedTagId.value] : undefined,
    })

    const a = document.createElement('a')
    a.href = url
    a.download = ''
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
}
  • Step 3: Verify dev server compiles without errors
cd frontend && npx nuxi typecheck

Expected: no errors (or only pre-existing ones)

  • Step 4: Commit
git add frontend/pages/time-tracking.vue
git commit -m "feat : add export button to time-tracking page"

Task 8: End-to-end manual test

  • Step 1: Start dev server and test in browser
  1. Open http://localhost:3002/time-tracking
  2. Verify the "Exporter" button appears in the filter bar
  3. Select a date range with existing time entries
  4. Click "Exporter"
  5. Verify an .xlsx file downloads
  • Step 2: Open the XLSX and verify structure
  1. Feuille "Détail" — rows with Date, Utilisateur, Projet, etc. + total row
  2. Feuille "Récap par projet" — users × projects cross-table
  3. Feuille "Récap par mois" — users × months cross-table
  • Step 3: Test as non-admin user
  1. Log in as alice / alice
  2. Export — verify only Alice's entries appear (even if user filter was different)
  • Step 4: Run PHP CS Fixer
make php-cs-fixer-allow-risky

Fix any issues, then commit if needed:

git add -A && git commit -m "style : fix code style for time entry export"