- Separate @Version from occurrenceCount (use dedicated version column) - Fix processor chaining: TaskNumberProcessor for Post, TaskCalendarProcessor for Patch/Delete - Detect status CHANGE to isFinal (not just current isFinal) to avoid duplicate recurrence - Add DeleteTaskTool CalDAV cleanup for MCP deletions - Add "Mes tâches" page update task (sort + columns) - Use i18n for weekDays labels instead of hardcoded French - Clarify documents/bookStackLinks NOT copied for recurring tasks - Use multi-line getter/setter style note Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1466 lines
51 KiB
Markdown
1466 lines
51 KiB
Markdown
# Zimbra CalDAV Calendar Integration — 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:** Sync Lesstime tasks to a Zimbra OVH calendar via CalDAV, with support for scheduled dates, deadlines, recurring tasks, and one-way push sync.
|
|
|
|
**Architecture:** New entities (`TaskRecurrence`, `ZimbraConfiguration`) + new fields on `Task` for dates and calendar UIDs. A `CalDavService` handles HTTP CalDAV calls. Sync is triggered by an API Platform `TaskCalendarProcessor` (after DB flush) and explicitly in MCP tools. Frontend adds a "Planification" tab in TaskModal with date pickers, calendar toggle, and recurrence form.
|
|
|
|
**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, `sabre/vobject` (ICS generation), Symfony HttpClient (CalDAV requests), Nuxt 4, Vue 3, Tailwind CSS.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md`
|
|
|
|
---
|
|
|
|
## Task 1: Install PHP dependency `sabre/vobject`
|
|
|
|
**Files:**
|
|
- Modify: `composer.json`
|
|
|
|
- [ ] **Step 1: Install sabre/vobject**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm composer require sabre/vobject
|
|
```
|
|
|
|
- [ ] **Step 2: Verify installation**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm composer show sabre/vobject
|
|
```
|
|
|
|
Expected: shows sabre/vobject version info.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add composer.json composer.lock
|
|
git commit -m "chore : add sabre/vobject for CalDAV ICS generation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create `RecurrenceType` PHP Enum
|
|
|
|
**Files:**
|
|
- Create: `src/Enum/RecurrenceType.php`
|
|
|
|
- [ ] **Step 1: Create the enum**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Enum;
|
|
|
|
enum RecurrenceType: string
|
|
{
|
|
case Daily = 'daily';
|
|
case Weekly = 'weekly';
|
|
case Monthly = 'monthly';
|
|
case Yearly = 'yearly';
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/Enum/RecurrenceType.php
|
|
git commit -m "feat : add RecurrenceType backed enum"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Create `TaskRecurrence` Entity
|
|
|
|
**Files:**
|
|
- Create: `src/Entity/TaskRecurrence.php`
|
|
- Create: `src/Repository/TaskRecurrenceRepository.php`
|
|
|
|
- [ ] **Step 1: Create the repository**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Repository;
|
|
|
|
use App\Entity\TaskRecurrence;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
/**
|
|
* @extends ServiceEntityRepository<TaskRecurrence>
|
|
*/
|
|
class TaskRecurrenceRepository extends ServiceEntityRepository
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, TaskRecurrence::class);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create the entity**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Entity;
|
|
|
|
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\Enum\RecurrenceType;
|
|
use App\Repository\TaskRecurrenceRepository;
|
|
use Doctrine\Common\Collections\ArrayCollection;
|
|
use Doctrine\Common\Collections\Collection;
|
|
use Doctrine\ORM\Mapping as ORM;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
|
|
#[ApiResource(
|
|
operations: [
|
|
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
|
new Get(security: "is_granted('ROLE_USER')"),
|
|
new Post(security: "is_granted('ROLE_ADMIN')"),
|
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
|
],
|
|
normalizationContext: ['groups' => ['task_recurrence:read']],
|
|
denormalizationContext: ['groups' => ['task_recurrence:write']],
|
|
)]
|
|
#[ORM\Entity(repositoryClass: TaskRecurrenceRepository::class)]
|
|
class TaskRecurrence
|
|
{
|
|
#[ORM\Id]
|
|
#[ORM\GeneratedValue]
|
|
#[ORM\Column]
|
|
#[Groups(['task_recurrence:read', 'task:read'])]
|
|
private ?int $id = null;
|
|
|
|
#[ORM\Column(type: 'string', enumType: RecurrenceType::class)]
|
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
|
private RecurrenceType $type = RecurrenceType::Daily;
|
|
|
|
#[ORM\Column(type: 'integer', options: ['default' => 1])]
|
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
|
private int $interval = 1;
|
|
|
|
#[ORM\Column(type: 'json', nullable: true)]
|
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
|
private ?array $daysOfWeek = null;
|
|
|
|
#[ORM\Column(type: 'integer', nullable: true)]
|
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
|
private ?int $dayOfMonth = null;
|
|
|
|
#[ORM\Column(type: 'integer', nullable: true)]
|
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
|
private ?int $weekOfMonth = null;
|
|
|
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
|
private ?\DateTimeImmutable $endDate = null;
|
|
|
|
#[ORM\Column(type: 'integer', nullable: true)]
|
|
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
|
|
private ?int $maxOccurrences = null;
|
|
|
|
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
|
#[Groups(['task_recurrence:read'])]
|
|
private int $occurrenceCount = 0;
|
|
|
|
#[ORM\Version]
|
|
#[ORM\Column(type: 'integer', options: ['default' => 1])]
|
|
private int $version = 1;
|
|
|
|
/** @var Collection<int, Task> */
|
|
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'recurrence')]
|
|
private Collection $tasks;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->tasks = new ArrayCollection();
|
|
}
|
|
|
|
// Standard multi-line getters/setters following codebase convention (PHP CS Fixer).
|
|
// All properties get standard get/set methods.
|
|
// Special methods: incrementOccurrenceCount() and getVersion()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/TaskRecurrence.php src/Repository/TaskRecurrenceRepository.php
|
|
git commit -m "feat : add TaskRecurrence entity with RecurrenceType enum"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Create `ZimbraConfiguration` Entity
|
|
|
|
**Files:**
|
|
- Create: `src/Entity/ZimbraConfiguration.php`
|
|
- Create: `src/Repository/ZimbraConfigurationRepository.php`
|
|
|
|
- [ ] **Step 1: Create the repository**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Repository;
|
|
|
|
use App\Entity\ZimbraConfiguration;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
/**
|
|
* @extends ServiceEntityRepository<ZimbraConfiguration>
|
|
*/
|
|
class ZimbraConfigurationRepository extends ServiceEntityRepository
|
|
{
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, ZimbraConfiguration::class);
|
|
}
|
|
|
|
public function findSingleton(): ?ZimbraConfiguration
|
|
{
|
|
return $this->createQueryBuilder('z')
|
|
->setMaxResults(1)
|
|
->getQuery()
|
|
->getOneOrNullResult();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create the entity** (follows `GiteaConfiguration` pattern)
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Entity;
|
|
|
|
use App\Repository\ZimbraConfigurationRepository;
|
|
use Doctrine\ORM\Mapping as ORM;
|
|
use Symfony\Component\Validator\Constraints as Assert;
|
|
|
|
#[ORM\Entity(repositoryClass: ZimbraConfigurationRepository::class)]
|
|
class ZimbraConfiguration
|
|
{
|
|
#[ORM\Id]
|
|
#[ORM\GeneratedValue]
|
|
#[ORM\Column]
|
|
private ?int $id = null;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
#[Assert\Url]
|
|
private ?string $serverUrl = null;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $username = null;
|
|
|
|
#[ORM\Column(type: 'text', nullable: true)]
|
|
private ?string $encryptedPassword = null;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $calendarPath = null;
|
|
|
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
|
private bool $enabled = false;
|
|
|
|
public function getId(): ?int { return $this->id; }
|
|
public function getServerUrl(): ?string { return $this->serverUrl; }
|
|
public function setServerUrl(?string $serverUrl): static { $this->serverUrl = $serverUrl; return $this; }
|
|
public function getUsername(): ?string { return $this->username; }
|
|
public function setUsername(?string $username): static { $this->username = $username; return $this; }
|
|
public function getEncryptedPassword(): ?string { return $this->encryptedPassword; }
|
|
public function setEncryptedPassword(?string $encryptedPassword): static { $this->encryptedPassword = $encryptedPassword; return $this; }
|
|
public function hasPassword(): bool { return null !== $this->encryptedPassword; }
|
|
public function getCalendarPath(): ?string { return $this->calendarPath; }
|
|
public function setCalendarPath(?string $calendarPath): static { $this->calendarPath = $calendarPath; return $this; }
|
|
public function isEnabled(): bool { return $this->enabled; }
|
|
public function setEnabled(bool $enabled): static { $this->enabled = $enabled; return $this; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/ZimbraConfiguration.php src/Repository/ZimbraConfigurationRepository.php
|
|
git commit -m "feat : add ZimbraConfiguration entity for CalDAV settings"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Extend `Task` Entity with Calendar Fields
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/Task.php`
|
|
|
|
- [ ] **Step 1: Add new properties and imports**
|
|
|
|
Add to `Task.php`:
|
|
- `use ApiPlatform\Doctrine\Orm\Filter\DateFilter;`
|
|
- `use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;`
|
|
- `use Symfony\Component\Validator\Constraints as Assert;`
|
|
- `use Symfony\Component\Validator\Context\ExecutionContextInterface;`
|
|
- `#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]`
|
|
- `#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]`
|
|
- `#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]`
|
|
|
|
New properties (after `$clientTicket`):
|
|
|
|
```php
|
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
|
#[Groups(['task:read', 'task:write'])]
|
|
private ?\DateTimeImmutable $scheduledStart = null;
|
|
|
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
|
#[Groups(['task:read', 'task:write'])]
|
|
private ?\DateTimeImmutable $scheduledEnd = null;
|
|
|
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
|
#[Groups(['task:read', 'task:write'])]
|
|
private ?\DateTimeImmutable $deadline = null;
|
|
|
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
|
#[Groups(['task:read', 'task:write'])]
|
|
private bool $syncToCalendar = false;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $calendarEventUid = null;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $calendarTodoUid = null;
|
|
|
|
#[ORM\Column(type: 'text', nullable: true)]
|
|
#[Groups(['task:read'])]
|
|
private ?string $calendarSyncError = null;
|
|
|
|
#[ORM\ManyToOne(targetEntity: TaskRecurrence::class, inversedBy: 'tasks')]
|
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
#[Groups(['task:read', 'task:write'])]
|
|
private ?TaskRecurrence $recurrence = null;
|
|
```
|
|
|
|
- [ ] **Step 2: Add getters/setters**
|
|
|
|
```php
|
|
public function getScheduledStart(): ?\DateTimeImmutable { return $this->scheduledStart; }
|
|
public function setScheduledStart(?\DateTimeImmutable $scheduledStart): static { $this->scheduledStart = $scheduledStart; return $this; }
|
|
public function getScheduledEnd(): ?\DateTimeImmutable { return $this->scheduledEnd; }
|
|
public function setScheduledEnd(?\DateTimeImmutable $scheduledEnd): static { $this->scheduledEnd = $scheduledEnd; return $this; }
|
|
public function getDeadline(): ?\DateTimeImmutable { return $this->deadline; }
|
|
public function setDeadline(?\DateTimeImmutable $deadline): static { $this->deadline = $deadline; return $this; }
|
|
public function isSyncToCalendar(): bool { return $this->syncToCalendar; }
|
|
public function setSyncToCalendar(bool $syncToCalendar): static { $this->syncToCalendar = $syncToCalendar; return $this; }
|
|
public function getCalendarEventUid(): ?string { return $this->calendarEventUid; }
|
|
public function setCalendarEventUid(?string $calendarEventUid): static { $this->calendarEventUid = $calendarEventUid; return $this; }
|
|
public function getCalendarTodoUid(): ?string { return $this->calendarTodoUid; }
|
|
public function setCalendarTodoUid(?string $calendarTodoUid): static { $this->calendarTodoUid = $calendarTodoUid; return $this; }
|
|
public function getCalendarSyncError(): ?string { return $this->calendarSyncError; }
|
|
public function setCalendarSyncError(?string $calendarSyncError): static { $this->calendarSyncError = $calendarSyncError; return $this; }
|
|
public function getRecurrence(): ?TaskRecurrence { return $this->recurrence; }
|
|
public function setRecurrence(?TaskRecurrence $recurrence): static { $this->recurrence = $recurrence; return $this; }
|
|
```
|
|
|
|
- [ ] **Step 3: Add validation callback**
|
|
|
|
```php
|
|
#[Assert\Callback]
|
|
public function validateScheduledDates(ExecutionContextInterface $context): void
|
|
{
|
|
if (($this->scheduledStart === null) !== ($this->scheduledEnd === null)) {
|
|
$context->buildViolation('scheduledStart and scheduledEnd must both be set or both be null.')
|
|
->atPath('scheduledEnd')
|
|
->addViolation();
|
|
}
|
|
if ($this->scheduledStart !== null && $this->scheduledEnd !== null
|
|
&& $this->scheduledEnd <= $this->scheduledStart) {
|
|
$context->buildViolation('scheduledEnd must be after scheduledStart.')
|
|
->atPath('scheduledEnd')
|
|
->addViolation();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/Task.php
|
|
git commit -m "feat : add calendar fields to Task entity (dates, sync, recurrence)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Generate and Run Migration
|
|
|
|
**Files:**
|
|
- Create: `migrations/Version*.php` (auto-generated)
|
|
|
|
- [ ] **Step 1: Generate migration**
|
|
|
|
```bash
|
|
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff
|
|
```
|
|
|
|
- [ ] **Step 2: Review migration** — verify it creates `task_recurrence` table, `zimbra_configuration` table, and adds new columns to `task`.
|
|
|
|
- [ ] **Step 3: Run migration**
|
|
|
|
```bash
|
|
make migration-migrate
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add migrations/
|
|
git commit -m "feat : migration for TaskRecurrence, ZimbraConfiguration, and Task calendar fields"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Create `CalDavService`
|
|
|
|
**Files:**
|
|
- Create: `src/Service/CalDavService.php`
|
|
|
|
- [ ] **Step 1: Create the service**
|
|
|
|
The service handles all CalDAV HTTP communication with Zimbra. Key points:
|
|
- Uses `Symfony\Contracts\HttpClient\HttpClientInterface` for HTTP requests
|
|
- Uses `Sabre\VObject\Component\VCalendar` for ICS generation
|
|
- Uses `TokenEncryptor` to decrypt the stored password
|
|
- All dates in UTC with `Z` suffix
|
|
- 5 second timeout on all requests
|
|
- VEVENT for scheduled time slots (with RRULE if recurring)
|
|
- VTODO for deadlines
|
|
- HTTP Basic Auth
|
|
- PUT to create/update, DELETE to remove
|
|
|
|
Methods:
|
|
- `isConfigured(): bool` — checks ZimbraConfiguration exists and is enabled
|
|
- `testConnection(): bool` — PROPFIND on calendar path
|
|
- `createEvent(Task): ?string` — creates VEVENT, returns UID
|
|
- `createTodo(Task): ?string` — creates VTODO, returns UID
|
|
- `updateEvent(Task): bool` — updates VEVENT by existing UID
|
|
- `updateTodo(Task): bool` — updates VTODO by existing UID
|
|
- `deleteEvent(?string $uid): bool` — DELETE by UID, safe with null
|
|
- `deleteTodo(?string $uid): bool` — DELETE by UID, safe with null
|
|
- `syncTask(Task): void` — orchestrates create/update/delete logic based on task state, updates `calendarSyncError`
|
|
- Private: `buildEventCalendar(Task): VCalendar`, `buildTodoCalendar(Task): VCalendar`, `buildRRule(TaskRecurrence): string`, `makeRequest(string $method, string $url, ?string $body): bool`
|
|
|
|
SUMMARY format: `[PROJECT_CODE-NUMBER] Task Title`
|
|
DESCRIPTION: task description + `\n\nLesstime task`
|
|
|
|
RRULE mapping from `TaskRecurrence`:
|
|
- `Daily` → `FREQ=DAILY;INTERVAL={interval}`
|
|
- `Weekly` → `FREQ=WEEKLY;INTERVAL={interval};BYDAY=MO,WE` (from daysOfWeek)
|
|
- `Monthly` with dayOfMonth → `FREQ=MONTHLY;INTERVAL={interval};BYMONTHDAY={dayOfMonth}`
|
|
- `Monthly` with weekOfMonth → `FREQ=MONTHLY;INTERVAL={interval};BYDAY={weekOfMonth}MO` (e.g. `2MO` for 2nd Monday)
|
|
- `Yearly` → `FREQ=YEARLY;INTERVAL={interval}`
|
|
- Append `;UNTIL={endDate}` or `;COUNT={maxOccurrences}` if set
|
|
|
|
Day name mapping for BYDAY: `monday→MO`, `tuesday→TU`, `wednesday→WE`, `thursday→TH`, `friday→FR`, `saturday→SA`, `sunday→SU`
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/Service/CalDavService.php
|
|
git commit -m "feat : add CalDavService for Zimbra CalDAV sync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Create `RecurrenceCalculator` Service
|
|
|
|
**Files:**
|
|
- Create: `src/Service/RecurrenceCalculator.php`
|
|
|
|
- [ ] **Step 1: Create the service**
|
|
|
|
Methods:
|
|
- `getNextDate(Task): ?\DateTimeImmutable` — calculates next scheduledStart based on current task's scheduledStart and recurrence pattern. Returns null if max occurrences/end date reached.
|
|
- `hasReachedEnd(TaskRecurrence): bool` — checks if maxOccurrences or endDate is reached
|
|
|
|
Calculation logic (from current task's `scheduledStart`, NOT from completion date):
|
|
- **Daily**: `scheduledStart + interval days`
|
|
- **Weekly**: find next matching day from `daysOfWeek`, `interval` weeks ahead from current week
|
|
- **Monthly** with dayOfMonth: same day, `interval` months ahead
|
|
- **Monthly** with weekOfMonth: same Nth weekday, `interval` months ahead
|
|
- **Yearly**: same date, `interval` years ahead
|
|
|
|
Duration preservation: `scheduledEnd - scheduledStart` is reapplied to the new start date.
|
|
|
|
Deadline recalculation: if the task has a deadline, advance it by the same offset (`deadline - scheduledStart`) from the new start date.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/Service/RecurrenceCalculator.php
|
|
git commit -m "feat : add RecurrenceCalculator service for next occurrence dates"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Create Zimbra Settings API (Admin Config)
|
|
|
|
**Files:**
|
|
- Create: `src/ApiResource/ZimbraSettings.php`
|
|
- Create: `src/State/ZimbraSettingsProvider.php`
|
|
- Create: `src/State/ZimbraSettingsProcessor.php`
|
|
- Create: `src/ApiResource/ZimbraTestConnection.php`
|
|
- Create: `src/State/ZimbraTestConnectionProvider.php`
|
|
|
|
- [ ] **Step 1: Create `ZimbraSettings` API Resource** (follow `GiteaSettings` pattern exactly)
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ApiResource;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Get;
|
|
use ApiPlatform\Metadata\Put;
|
|
use App\State\ZimbraSettingsProcessor;
|
|
use App\State\ZimbraSettingsProvider;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
|
|
#[ApiResource(
|
|
operations: [
|
|
new Get(
|
|
uriTemplate: '/settings/zimbra',
|
|
normalizationContext: ['groups' => ['zimbra_settings:read']],
|
|
provider: ZimbraSettingsProvider::class,
|
|
security: "is_granted('ROLE_ADMIN')",
|
|
),
|
|
new Put(
|
|
uriTemplate: '/settings/zimbra',
|
|
denormalizationContext: ['groups' => ['zimbra_settings:write']],
|
|
normalizationContext: ['groups' => ['zimbra_settings:read']],
|
|
provider: ZimbraSettingsProvider::class,
|
|
processor: ZimbraSettingsProcessor::class,
|
|
security: "is_granted('ROLE_ADMIN')",
|
|
),
|
|
],
|
|
)]
|
|
final class ZimbraSettings
|
|
{
|
|
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
|
public ?string $serverUrl = null;
|
|
|
|
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
|
public ?string $username = null;
|
|
|
|
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
|
public ?string $calendarPath = null;
|
|
|
|
#[Groups(['zimbra_settings:write'])]
|
|
public ?string $password = null;
|
|
|
|
#[Groups(['zimbra_settings:read', 'zimbra_settings:write'])]
|
|
public bool $enabled = false;
|
|
|
|
#[Groups(['zimbra_settings:read'])]
|
|
public bool $hasPassword = false;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create Provider** — reads `ZimbraConfiguration` singleton, maps to DTO
|
|
- [ ] **Step 3: Create Processor** — persists settings, uses `TokenEncryptor::encrypt()` for password (only if non-empty), follows `GiteaSettingsProcessor` pattern
|
|
- [ ] **Step 4: Create `ZimbraTestConnection` API Resource** (follow `GiteaTestConnection` pattern)
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ApiResource;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Post;
|
|
use App\State\ZimbraTestConnectionProvider;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
|
|
#[ApiResource(
|
|
operations: [
|
|
new Post(
|
|
uriTemplate: '/settings/zimbra/test',
|
|
input: false,
|
|
normalizationContext: ['groups' => ['zimbra_test:read']],
|
|
provider: ZimbraTestConnectionProvider::class,
|
|
processor: ZimbraTestConnectionProvider::class,
|
|
security: "is_granted('ROLE_ADMIN')",
|
|
),
|
|
],
|
|
)]
|
|
final class ZimbraTestConnection
|
|
{
|
|
#[Groups(['zimbra_test:read'])]
|
|
public bool $success = false;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create `ZimbraTestConnectionProvider`** — calls `CalDavService::testConnection()`, returns result
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ApiResource/ZimbraSettings.php src/ApiResource/ZimbraTestConnection.php src/State/ZimbraSettings*.php src/State/ZimbraTestConnectionProvider.php
|
|
git commit -m "feat : add Zimbra settings API (CRUD + test connection)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Create `TaskCalendarProcessor` (API Platform Sync)
|
|
|
|
**Files:**
|
|
- Create: `src/State/TaskCalendarProcessor.php`
|
|
- Modify: `src/Entity/Task.php` (update processor references)
|
|
|
|
- [ ] **Step 1: Create the processor**
|
|
|
|
This processor decorates the persist/remove processors. It:
|
|
1. Calls the inner processor first (DB flush happens)
|
|
2. Then calls `CalDavService::syncTask()` (after DB is committed)
|
|
3. For Delete: stores UIDs before deletion, then deletes from Zimbra after DB removal
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Delete;
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\Metadata\Patch;
|
|
use ApiPlatform\Metadata\Post;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use App\Entity\Task;
|
|
use App\Service\CalDavService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
|
|
/**
|
|
* @implements ProcessorInterface<Task, Task>
|
|
*/
|
|
final readonly class TaskCalendarProcessor implements ProcessorInterface
|
|
{
|
|
/**
|
|
* @param ProcessorInterface<Task, Task> $persistProcessor
|
|
* @param ProcessorInterface<Task, Task> $removeProcessor
|
|
*/
|
|
public function __construct(
|
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
private ProcessorInterface $persistProcessor,
|
|
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
|
private ProcessorInterface $removeProcessor,
|
|
private CalDavService $calDavService,
|
|
private EntityManagerInterface $entityManager,
|
|
) {}
|
|
|
|
/**
|
|
* @param Task $data
|
|
*/
|
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
{
|
|
if ($operation instanceof Delete) {
|
|
$eventUid = $data->getCalendarEventUid();
|
|
$todoUid = $data->getCalendarTodoUid();
|
|
|
|
$result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
|
|
|
if ($eventUid) {
|
|
$this->calDavService->deleteEvent($eventUid);
|
|
}
|
|
if ($todoUid) {
|
|
$this->calDavService->deleteTodo($todoUid);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
// For Post: wrap in transaction for task number (delegates to TaskNumberProcessor logic)
|
|
// For Patch: normal persist
|
|
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
|
|
// After DB flush: sync to Zimbra
|
|
$this->calDavService->syncTask($data);
|
|
$this->entityManager->flush(); // persist any UID/error changes
|
|
|
|
return $result;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update Task entity and TaskNumberProcessor**
|
|
|
|
**Approach chosen:** Keep `TaskNumberProcessor` for Post (inject `CalDavService` into it). Use `TaskCalendarProcessor` for Patch and Delete only.
|
|
|
|
Update `Task.php` operations:
|
|
|
|
```php
|
|
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
|
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
|
|
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
|
|
```
|
|
|
|
Modify `TaskNumberProcessor`: inject `CalDavService`, after the transaction completes call sync:
|
|
|
|
```php
|
|
public function __construct(
|
|
// ... existing deps ...
|
|
private CalDavService $calDavService,
|
|
) {}
|
|
|
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
{
|
|
if ($operation instanceof Post && null !== $data->getProject()) {
|
|
$result = $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
|
|
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject());
|
|
$data->setNumber($maxNumber + 1);
|
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
});
|
|
|
|
// After DB commit: sync to Zimbra
|
|
$this->calDavService->syncTask($data);
|
|
$this->entityManager->flush();
|
|
|
|
return $result;
|
|
}
|
|
|
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/State/TaskCalendarProcessor.php src/State/TaskNumberProcessor.php src/Entity/Task.php
|
|
git commit -m "feat : add TaskCalendarProcessor for CalDAV sync after DB operations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Recurrence Auto-Creation on Task Completion
|
|
|
|
**Files:**
|
|
- Modify: `src/State/TaskCalendarProcessor.php`
|
|
|
|
- [ ] **Step 1: Add recurrence logic to Patch handling**
|
|
|
|
**CRITICAL: Detect status CHANGE to isFinal, not just current isFinal.**
|
|
|
|
In `TaskCalendarProcessor`, when processing a Patch:
|
|
1. **Before** calling `persistProcessor->process()`, store the original status: `$originalStatus = $data->getStatus()`
|
|
2. After `persistProcessor->process()`, check if the status **changed** to `isFinal` (original status was NOT isFinal, new status IS isFinal) AND task has a recurrence
|
|
3. If the status was already isFinal before the Patch, skip recurrence creation (avoids duplicates on subsequent edits)
|
|
4. If conditions met:
|
|
- Archive the current task (`archived = true`)
|
|
- Clear `calendarEventUid` and `calendarTodoUid` on archived task
|
|
- Use `RecurrenceCalculator::getNextDate()` to compute next dates
|
|
- If next date is null (recurrence ended), stop
|
|
- Create a new Task with same fields (title, description, assignee, tags, project, group, effort, priority, syncToCalendar, recurrence)
|
|
- Generate new task number via `TaskRepository::findMaxNumberByProjectForUpdate()`
|
|
- Set first status (lowest position, non-final)
|
|
- Set recalculated scheduledStart, scheduledEnd, deadline
|
|
- **Do NOT copy** documents or bookStackLinks (those are specific to the completed task instance)
|
|
- Copy `calendarEventUid` from the archived task (before clearing) to the new task
|
|
- Create new VTODO for the new deadline
|
|
- Increment `occurrenceCount` on `TaskRecurrence`
|
|
- Flush all changes
|
|
|
|
- [ ] **Step 2: Inject dependencies** — add `RecurrenceCalculator`, `TaskRepository`, `TaskStatusRepository`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/State/TaskCalendarProcessor.php
|
|
git commit -m "feat : auto-create next recurring task when current task reaches final status"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Update MCP Tools
|
|
|
|
**Files:**
|
|
- Modify: `src/Mcp/Tool/Task/CreateTaskTool.php`
|
|
- Modify: `src/Mcp/Tool/Task/UpdateTaskTool.php`
|
|
- Create: `src/Mcp/Tool/Task/CreateTaskRecurrenceTool.php`
|
|
- Create: `src/Mcp/Tool/Task/UpdateTaskRecurrenceTool.php`
|
|
- Create: `src/Mcp/Tool/Task/DeleteTaskRecurrenceTool.php`
|
|
|
|
- [ ] **Step 1: Update `CreateTaskTool`** — add optional params `?string $scheduledStart`, `?string $scheduledEnd`, `?string $deadline`, `?bool $syncToCalendar`. After the transaction, call `$this->calDavService->syncTask($task)` and flush. Add `CalDavService` to constructor.
|
|
|
|
- [ ] **Step 2: Update `UpdateTaskTool`** — same new params. After flush, call `$this->calDavService->syncTask($task)` and flush. Include calendar fields in JSON response.
|
|
|
|
- [ ] **Step 2b: Update `DeleteTaskTool`** — inject `CalDavService`. Before removing the task, store `calendarEventUid` and `calendarTodoUid`. After `$entityManager->flush()`, call `$this->calDavService->deleteEvent($eventUid)` and `$this->calDavService->deleteTodo($todoUid)` if non-null. **This is critical — MCP delete bypasses API Platform processors.**
|
|
|
|
- [ ] **Step 3: Create `CreateTaskRecurrenceTool`**
|
|
|
|
```php
|
|
#[McpTool(name: 'create-task-recurrence', description: 'Create a recurrence pattern for a task. Type: daily, weekly, monthly, yearly. For weekly, provide daysOfWeek array. For monthly, provide dayOfMonth OR weekOfMonth.')]
|
|
class CreateTaskRecurrenceTool
|
|
{
|
|
// Constructor: EntityManagerInterface, TaskRepository, CalDavService
|
|
|
|
public function __invoke(
|
|
int $taskId,
|
|
string $type,
|
|
int $interval = 1,
|
|
?array $daysOfWeek = null,
|
|
?int $dayOfMonth = null,
|
|
?int $weekOfMonth = null,
|
|
?string $endDate = null,
|
|
?int $maxOccurrences = null,
|
|
): string {
|
|
// Find task, create TaskRecurrence, set on task, flush
|
|
// Sync to calendar (updates RRULE on existing VEVENT)
|
|
// Return JSON response
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create `UpdateTaskRecurrenceTool`** and `DeleteTaskRecurrenceTool`** — similar patterns
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/Mcp/Tool/Task/
|
|
git commit -m "feat : update MCP tools with calendar fields and add recurrence tools"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Frontend DTOs and Services
|
|
|
|
**Files:**
|
|
- Modify: `frontend/services/dto/task.ts`
|
|
- Create: `frontend/services/dto/task-recurrence.ts`
|
|
- Create: `frontend/services/dto/zimbra.ts`
|
|
- Create: `frontend/services/zimbra.ts`
|
|
- Create: `frontend/services/task-recurrences.ts`
|
|
|
|
- [ ] **Step 1: Update `Task` DTO** — add fields:
|
|
|
|
```typescript
|
|
// Add to Task type:
|
|
scheduledStart: string | null
|
|
scheduledEnd: string | null
|
|
deadline: string | null
|
|
syncToCalendar: boolean
|
|
calendarSyncError: string | null
|
|
recurrence: {
|
|
id: number
|
|
'@id'?: string
|
|
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
|
interval: number
|
|
daysOfWeek: string[] | null
|
|
dayOfMonth: number | null
|
|
weekOfMonth: number | null
|
|
endDate: string | null
|
|
maxOccurrences: number | null
|
|
occurrenceCount: number
|
|
} | null
|
|
|
|
// Add to TaskWrite type:
|
|
scheduledStart?: string | null
|
|
scheduledEnd?: string | null
|
|
deadline?: string | null
|
|
syncToCalendar?: boolean
|
|
recurrence?: string | null
|
|
```
|
|
|
|
- [ ] **Step 2: Create `task-recurrence.ts` DTO**
|
|
|
|
```typescript
|
|
export type TaskRecurrence = {
|
|
id: number
|
|
'@id'?: string
|
|
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
|
interval: number
|
|
daysOfWeek: string[] | null
|
|
dayOfMonth: number | null
|
|
weekOfMonth: number | null
|
|
endDate: string | null
|
|
maxOccurrences: number | null
|
|
occurrenceCount: number
|
|
}
|
|
|
|
export type TaskRecurrenceWrite = {
|
|
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
|
interval: number
|
|
daysOfWeek?: string[] | null
|
|
dayOfMonth?: number | null
|
|
weekOfMonth?: number | null
|
|
endDate?: string | null
|
|
maxOccurrences?: number | null
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `zimbra.ts` DTO and service**
|
|
|
|
```typescript
|
|
// frontend/services/dto/zimbra.ts
|
|
export type ZimbraSettings = {
|
|
serverUrl: string | null
|
|
username: string | null
|
|
calendarPath: string | null
|
|
enabled: boolean
|
|
hasPassword: boolean
|
|
}
|
|
|
|
export type ZimbraSettingsWrite = {
|
|
serverUrl: string | null
|
|
username: string | null
|
|
calendarPath: string | null
|
|
password?: string | null
|
|
enabled: boolean
|
|
}
|
|
|
|
export type ZimbraTestResult = {
|
|
success: boolean
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// frontend/services/zimbra.ts
|
|
export function useZimbraService() {
|
|
const api = useApi()
|
|
|
|
async function getSettings(): Promise<ZimbraSettings> {
|
|
return api.get<ZimbraSettings>('/settings/zimbra')
|
|
}
|
|
|
|
async function saveSettings(payload: ZimbraSettingsWrite): Promise<ZimbraSettings> {
|
|
return api.put<ZimbraSettings>('/settings/zimbra', payload as Record<string, unknown>, {
|
|
toastSuccessKey: 'zimbra.settings.saved',
|
|
})
|
|
}
|
|
|
|
async function testConnection(): Promise<ZimbraTestResult> {
|
|
return api.post<ZimbraTestResult>('/settings/zimbra/test', {})
|
|
}
|
|
|
|
return { getSettings, saveSettings, testConnection }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create `task-recurrences.ts` service**
|
|
|
|
```typescript
|
|
export function useTaskRecurrenceService() {
|
|
const api = useApi()
|
|
|
|
async function create(payload: TaskRecurrenceWrite): Promise<TaskRecurrence> {
|
|
return api.post<TaskRecurrence>('/task_recurrences', payload as Record<string, unknown>, {
|
|
toastSuccessKey: 'taskRecurrence.created',
|
|
})
|
|
}
|
|
|
|
async function update(id: number, payload: Partial<TaskRecurrenceWrite>): Promise<TaskRecurrence> {
|
|
return api.patch<TaskRecurrence>(`/task_recurrences/${id}`, payload as Record<string, unknown>, {
|
|
toastSuccessKey: 'taskRecurrence.updated',
|
|
})
|
|
}
|
|
|
|
async function remove(id: number): Promise<void> {
|
|
await api.delete(`/task_recurrences/${id}`, {}, {
|
|
toastSuccessKey: 'taskRecurrence.deleted',
|
|
})
|
|
}
|
|
|
|
return { create, update, remove }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/services/dto/task.ts frontend/services/dto/task-recurrence.ts frontend/services/dto/zimbra.ts frontend/services/zimbra.ts frontend/services/task-recurrences.ts
|
|
git commit -m "feat(ui) : add DTOs and services for calendar fields, recurrence, and Zimbra settings"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Add "Planification" Tab to TaskModal
|
|
|
|
**Files:**
|
|
- Modify: `frontend/components/task/TaskModal.vue`
|
|
|
|
- [ ] **Step 1: Add tab navigation**
|
|
|
|
After the header `<div>` and before the `<form>`, add tab buttons:
|
|
|
|
```vue
|
|
<!-- Tabs -->
|
|
<div class="border-b border-neutral-100 px-4 sm:px-8">
|
|
<nav class="flex gap-6">
|
|
<button
|
|
v-for="tab in ['details', 'planning']"
|
|
:key="tab"
|
|
type="button"
|
|
class="px-1 pb-3 text-sm font-semibold transition"
|
|
:class="activeTab === tab
|
|
? 'border-b-2 border-primary-500 text-primary-500'
|
|
: 'text-neutral-500 hover:text-neutral-700'"
|
|
@click="activeTab = tab"
|
|
>
|
|
{{ $t(`tasks.${tab}Tab`) }}
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 2: Wrap existing form content in `v-show="activeTab === 'details'"`**
|
|
|
|
- [ ] **Step 3: Add "Planification" tab content**
|
|
|
|
After the details `<div>`, add:
|
|
|
|
```vue
|
|
<div v-show="activeTab === 'planning'" class="space-y-6">
|
|
<!-- Dates -->
|
|
<div>
|
|
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.dates') }}</h3>
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<MalioInputText
|
|
v-model="form.scheduledStart"
|
|
:label="$t('tasks.planning.scheduledStart')"
|
|
type="datetime-local"
|
|
input-class="w-full"
|
|
/>
|
|
<MalioInputText
|
|
v-model="form.scheduledEnd"
|
|
:label="$t('tasks.planning.scheduledEnd')"
|
|
type="datetime-local"
|
|
input-class="w-full"
|
|
/>
|
|
</div>
|
|
<div class="mt-4">
|
|
<MalioInputText
|
|
v-model="form.deadline"
|
|
:label="$t('tasks.planning.deadline')"
|
|
type="date"
|
|
input-class="w-full sm:w-1/2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Calendar sync -->
|
|
<div class="rounded-lg border border-neutral-200 p-4">
|
|
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.calendar') }}</h3>
|
|
<label class="flex items-center gap-3">
|
|
<input
|
|
v-model="form.syncToCalendar"
|
|
type="checkbox"
|
|
class="rounded border-neutral-300"
|
|
/>
|
|
<span class="text-sm">{{ $t('tasks.planning.syncToCalendar') }}</span>
|
|
</label>
|
|
<div v-if="isEditing && task?.syncToCalendar" class="mt-3 flex items-center gap-2">
|
|
<Icon
|
|
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:check-circle'"
|
|
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
|
size="18"
|
|
/>
|
|
<span class="text-xs" :class="task.calendarSyncError ? 'text-red-600' : 'text-green-600'">
|
|
{{ task.calendarSyncError || $t('tasks.planning.syncOk') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recurrence -->
|
|
<div class="rounded-lg border border-neutral-200 p-4">
|
|
<h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.recurrence') }}</h3>
|
|
<label class="flex items-center gap-3">
|
|
<input
|
|
v-model="form.isRecurring"
|
|
type="checkbox"
|
|
class="rounded border-neutral-300"
|
|
/>
|
|
<span class="text-sm">{{ $t('tasks.planning.isRecurring') }}</span>
|
|
</label>
|
|
|
|
<div v-if="form.isRecurring" class="mt-4 space-y-4">
|
|
<!-- Type -->
|
|
<div>
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.type') }}</label>
|
|
<select v-model="form.recurrenceType" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
|
<option value="daily">{{ $t('tasks.planning.daily') }}</option>
|
|
<option value="weekly">{{ $t('tasks.planning.weekly') }}</option>
|
|
<option value="monthly">{{ $t('tasks.planning.monthly') }}</option>
|
|
<option value="yearly">{{ $t('tasks.planning.yearly') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Interval -->
|
|
<MalioInputText
|
|
v-model="form.recurrenceInterval"
|
|
:label="$t('tasks.planning.interval')"
|
|
type="number"
|
|
input-class="w-full sm:w-1/3"
|
|
min="1"
|
|
max="100"
|
|
/>
|
|
|
|
<!-- Weekly: days of week -->
|
|
<div v-if="form.recurrenceType === 'weekly'">
|
|
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('tasks.planning.daysOfWeek') }}</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<label
|
|
v-for="day in weekDays"
|
|
:key="day.value"
|
|
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
|
:class="form.recurrenceDaysOfWeek.includes(day.value)
|
|
? 'bg-primary-500 text-white'
|
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
class="hidden"
|
|
:value="day.value"
|
|
:checked="form.recurrenceDaysOfWeek.includes(day.value)"
|
|
@change="toggleDay(day.value)"
|
|
/>
|
|
{{ day.label }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Monthly options -->
|
|
<div v-if="form.recurrenceType === 'monthly'" class="space-y-3">
|
|
<div class="flex gap-4">
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input v-model="form.monthlyMode" value="dayOfMonth" type="radio" />
|
|
{{ $t('tasks.planning.dayOfMonth') }}
|
|
</label>
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input v-model="form.monthlyMode" value="weekOfMonth" type="radio" />
|
|
{{ $t('tasks.planning.weekOfMonth') }}
|
|
</label>
|
|
</div>
|
|
<MalioInputText
|
|
v-if="form.monthlyMode === 'dayOfMonth'"
|
|
v-model="form.recurrenceDayOfMonth"
|
|
:label="$t('tasks.planning.dayOfMonthLabel')"
|
|
type="number"
|
|
input-class="w-full sm:w-1/3"
|
|
min="1"
|
|
max="31"
|
|
/>
|
|
<div v-if="form.monthlyMode === 'weekOfMonth'" class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.weekOfMonthLabel') }}</label>
|
|
<select v-model="form.recurrenceWeekOfMonth" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
|
<option :value="1">1er</option>
|
|
<option :value="2">2ème</option>
|
|
<option :value="3">3ème</option>
|
|
<option :value="4">4ème</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.dayLabel') }}</label>
|
|
<select v-model="form.recurrenceWeekDay" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
|
|
<option v-for="day in weekDays" :key="day.value" :value="day.value">{{ day.label }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- End of recurrence -->
|
|
<div class="space-y-3">
|
|
<p class="text-sm font-medium text-neutral-700">{{ $t('tasks.planning.endRecurrence') }}</p>
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input v-model="form.recurrenceEnd" value="never" type="radio" />
|
|
{{ $t('tasks.planning.neverEnds') }}
|
|
</label>
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input v-model="form.recurrenceEnd" value="occurrences" type="radio" />
|
|
{{ $t('tasks.planning.afterOccurrences') }}
|
|
</label>
|
|
<MalioInputText
|
|
v-if="form.recurrenceEnd === 'occurrences'"
|
|
v-model="form.recurrenceMaxOccurrences"
|
|
:label="$t('tasks.planning.occurrences')"
|
|
type="number"
|
|
input-class="w-full sm:w-1/3"
|
|
min="1"
|
|
/>
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input v-model="form.recurrenceEnd" value="date" type="radio" />
|
|
{{ $t('tasks.planning.onDate') }}
|
|
</label>
|
|
<MalioInputText
|
|
v-if="form.recurrenceEnd === 'date'"
|
|
v-model="form.recurrenceEndDate"
|
|
:label="$t('tasks.planning.endDate')"
|
|
type="date"
|
|
input-class="w-full sm:w-1/2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 4: Add script reactive state**
|
|
|
|
```typescript
|
|
const activeTab = ref<'details' | 'planning'>('details')
|
|
|
|
// Add to form reactive:
|
|
const form = reactive({
|
|
// ... existing fields ...
|
|
scheduledStart: '' as string,
|
|
scheduledEnd: '' as string,
|
|
deadline: '' as string,
|
|
syncToCalendar: false,
|
|
isRecurring: false,
|
|
recurrenceType: 'daily' as string,
|
|
recurrenceInterval: '1' as string,
|
|
recurrenceDaysOfWeek: [] as string[],
|
|
recurrenceDayOfMonth: '' as string,
|
|
monthlyMode: 'dayOfMonth' as string,
|
|
recurrenceWeekOfMonth: 1 as number,
|
|
recurrenceWeekDay: 'monday' as string,
|
|
recurrenceEnd: 'never' as string,
|
|
recurrenceMaxOccurrences: '' as string,
|
|
recurrenceEndDate: '' as string,
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
const weekDays = computed(() => [
|
|
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
|
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
|
{ value: 'wednesday', label: t('tasks.planning.days.wed') },
|
|
{ value: 'thursday', label: t('tasks.planning.days.thu') },
|
|
{ value: 'friday', label: t('tasks.planning.days.fri') },
|
|
{ value: 'saturday', label: t('tasks.planning.days.sat') },
|
|
{ value: 'sunday', label: t('tasks.planning.days.sun') },
|
|
])
|
|
|
|
function toggleDay(day: string) {
|
|
const idx = form.recurrenceDaysOfWeek.indexOf(day)
|
|
if (idx >= 0) form.recurrenceDaysOfWeek.splice(idx, 1)
|
|
else form.recurrenceDaysOfWeek.push(day)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Update `populateForm()`** — populate calendar fields from task data
|
|
- [ ] **Step 6: Update `handleSubmit()`** — include calendar fields in payload, handle recurrence create/update via `useTaskRecurrenceService()`
|
|
- [ ] **Step 7: Reset `activeTab` to `'details'` when modal opens**
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add frontend/components/task/TaskModal.vue
|
|
git commit -m "feat(ui) : add Planification tab to TaskModal with dates, calendar sync, and recurrence"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Add Calendar Badges to TaskCard and TaskListItem
|
|
|
|
**Files:**
|
|
- Modify: `frontend/components/task/TaskCard.vue`
|
|
- Modify: `frontend/components/task/TaskListItem.vue`
|
|
|
|
- [ ] **Step 1: Update `TaskCard.vue`** — add after tags/priority badges, before assignee:
|
|
|
|
```vue
|
|
<!-- Deadline badge -->
|
|
<span
|
|
v-if="task.deadline"
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
:style="{ backgroundColor: deadlineColor }"
|
|
:title="task.deadline"
|
|
>
|
|
{{ formatDeadline(task.deadline) }}
|
|
</span>
|
|
<!-- Calendar sync icon -->
|
|
<Icon
|
|
v-if="task.syncToCalendar"
|
|
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
|
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
|
size="14"
|
|
/>
|
|
<!-- Recurrence icon -->
|
|
<Icon
|
|
v-if="task.recurrence"
|
|
name="mdi:repeat"
|
|
class="text-blue-500"
|
|
size="14"
|
|
/>
|
|
```
|
|
|
|
Script additions:
|
|
|
|
```typescript
|
|
const deadlineColor = computed(() => {
|
|
if (!props.task.deadline) return ''
|
|
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
|
if (daysLeft < 0) return '#DC2626'
|
|
if (daysLeft < 2) return '#F59E0B'
|
|
return '#9CA3AF'
|
|
})
|
|
|
|
function formatDeadline(d: string): string {
|
|
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update `TaskListItem.vue`** — add deadline, scheduled date, and recurrence indicators (similar badges/icons)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/components/task/TaskCard.vue frontend/components/task/TaskListItem.vue
|
|
git commit -m "feat(ui) : add deadline badges and calendar/recurrence icons to task cards and list items"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15b: Update "Mes tâches" Page
|
|
|
|
**Files:**
|
|
- Modify: `frontend/pages/my-tasks.vue`
|
|
|
|
- [ ] **Step 1: Add deadline and scheduled date columns/badges** — same pattern as TaskListItem/TaskCard updates from Task 15.
|
|
|
|
- [ ] **Step 2: Add sort options** — allow sorting by `deadline` and `scheduledStart` (use API Platform OrderFilter added in Task 5). Add sort dropdown or clickable column headers.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/pages/my-tasks.vue
|
|
git commit -m "feat(ui) : add deadline/scheduled columns and sort options to Mes tâches page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 16: Create Admin Zimbra Tab
|
|
|
|
**Files:**
|
|
- Create: `frontend/components/admin/AdminZimbraTab.vue`
|
|
- Modify: `frontend/pages/admin.vue`
|
|
|
|
- [ ] **Step 1: Create `AdminZimbraTab.vue`** — follow `AdminGiteaTab.vue` pattern exactly:
|
|
- Form with: serverUrl, username, calendarPath, password, enabled toggle
|
|
- "Tester la connexion" button calling `testConnection()`
|
|
- Uses `useZimbraService()`
|
|
- Loads settings on mount, saves with toast
|
|
|
|
- [ ] **Step 2: Update `admin.vue`** — add tab `{ key: 'zimbra', label: 'Zimbra' }` and `<AdminZimbraTab v-if="activeTab === 'zimbra'" />`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/components/admin/AdminZimbraTab.vue frontend/pages/admin.vue
|
|
git commit -m "feat(ui) : add Zimbra CalDAV configuration tab in admin page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 17: Add i18n Translations
|
|
|
|
**Files:**
|
|
- Modify: `frontend/i18n/locales/fr.json`
|
|
- Modify: `frontend/i18n/locales/en.json`
|
|
|
|
- [ ] **Step 1: Add French translations** — keys for:
|
|
- `tasks.detailsTab`, `tasks.planningTab`
|
|
- `tasks.planning.*` (dates, scheduledStart, scheduledEnd, deadline, calendar, syncToCalendar, syncOk, recurrence, isRecurring, type, daily, weekly, monthly, yearly, interval, daysOfWeek, dayOfMonth, dayOfMonthLabel, weekOfMonth, weekOfMonthLabel, dayLabel, endRecurrence, neverEnds, afterOccurrences, occurrences, onDate, endDate)
|
|
- `zimbra.settings.*` (title, serverUrl, serverUrlPlaceholder, username, usernamePlaceholder, calendarPath, calendarPathPlaceholder, password, passwordConfigured, enabled, save, testConnection, testSuccess, testFailed, saved)
|
|
- `taskRecurrence.*` (created, updated, deleted)
|
|
|
|
- [ ] **Step 2: Add English translations** — same keys
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/i18n/locales/
|
|
git commit -m "feat(ui) : add i18n translations for calendar integration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 18: Update Fixtures
|
|
|
|
**Files:**
|
|
- Modify: `src/DataFixtures/AppFixtures.php`
|
|
|
|
- [ ] **Step 1: Add ZimbraConfiguration fixture** (disabled by default)
|
|
|
|
```php
|
|
$zimbraConfig = new ZimbraConfiguration();
|
|
$zimbraConfig->setServerUrl('https://mail.ovh.com');
|
|
$zimbraConfig->setUsername('lesstime@ovh.fr');
|
|
$zimbraConfig->setCalendarPath('/dav/lesstime@ovh.fr/Calendar/');
|
|
$zimbraConfig->setEnabled(false);
|
|
$manager->persist($zimbraConfig);
|
|
```
|
|
|
|
- [ ] **Step 2: Add sample tasks with dates and recurrence**
|
|
|
|
- [ ] **Step 3: Run fixtures**
|
|
|
|
```bash
|
|
make db-reset
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/DataFixtures/AppFixtures.php
|
|
git commit -m "feat : add Zimbra config and calendar task fixtures"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 19: Update CLAUDE.md
|
|
|
|
**Files:**
|
|
- Modify: `CLAUDE.md`
|
|
|
|
- [ ] **Step 1: Update entity list** — add `TaskRecurrence`, `ZimbraConfiguration`
|
|
- [ ] **Step 2: Update ApiResource list** — add `ZimbraSettings`, `ZimbraTestConnection`
|
|
- [ ] **Step 3: Update State list** — add `ZimbraSettingsProvider/Processor`, `ZimbraTestConnectionProvider`, `TaskCalendarProcessor`
|
|
- [ ] **Step 4: Update Service list** — add `CalDavService`, `RecurrenceCalculator`
|
|
- [ ] **Step 5: Update MCP tools count** if needed
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add CLAUDE.md
|
|
git commit -m "docs : update CLAUDE.md with Zimbra calendar integration references"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 20: Manual Testing & Verification
|
|
|
|
- [ ] **Step 1: Start dev environment** — `make start && make dev-nuxt`
|
|
- [ ] **Step 2: Test admin Zimbra config** — go to admin page, fill in Zimbra settings, test connection
|
|
- [ ] **Step 3: Test task creation with dates** — create a task, go to Planification tab, set dates and deadline
|
|
- [ ] **Step 4: Test calendar sync** — check the "Envoyer au calendrier" box, save, verify VEVENT in Zimbra
|
|
- [ ] **Step 5: Test recurrence** — create a recurring task, complete it, verify new task is auto-created with correct dates
|
|
- [ ] **Step 6: Test MCP tools** — use create-task with scheduledStart/deadline params via MCP
|
|
- [ ] **Step 7: Test edge cases** — delete a synced task, uncheck sync, remove dates
|