19 KiB
My Tasks Page 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 "/my-tasks" page that displays all non-archived tasks across projects with Kanban and List views, filtered by current user by default.
Architecture: Backend: add SearchFilter annotations on Task entity for server-side filtering. Frontend: new page with filter bar + two view modes (Kanban/List), reusing existing TaskCard and TaskModal components.
Tech Stack: PHP 8.4 / API Platform 4 (SearchFilter), Nuxt 4 / Vue 3, Tailwind CSS, MalioSelect, Pinia
Spec: docs/superpowers/specs/2026-03-13-my-tasks-page-design.md
Chunk 1: Backend — API Filters
Task 1: Add SearchFilter annotations on Task entity
Files:
-
Modify:
src/Entity/Task.php:35(ApiFilter line) -
Step 1: Add new SearchFilter properties
In src/Entity/Task.php, replace the existing #[ApiFilter(SearchFilter::class, ...)] line (line 35) with an expanded version that includes assignee, priority, effort, tags, and status:
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
- Step 2: Disable pagination on GetCollection
In src/Entity/Task.php, modify the GetCollection operation (line 25) to disable pagination:
new GetCollection(paginationEnabled: false),
- Step 3: Verify filters work
Run in the container:
docker exec -t php-lesstime-fpm php bin/console debug:router | grep tasks
Then test the API call:
curl -s 'http://localhost:8082/api/tasks?archived=false&assignee=/api/users/1' -H 'Cookie: BEARER=...' | head -c 500
Expected: JSON response with filtered tasks.
- Step 4: Commit
git add src/Entity/Task.php
git commit -m "feat(backend) : add SearchFilter for assignee, priority, effort, tags, status on Task"
Chunk 2: Frontend — Service, i18n, Sidebar
Task 2: Add getFiltered method to task service
Files:
-
Modify:
frontend/services/tasks.ts -
Step 1: Add the
getFilteredmethod
Add after the getByProjectArchived method (after line 27) in frontend/services/tasks.ts:
async function getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', params as Record<string, unknown>)
return extractHydraMembers(data)
}
- Step 2: Export the new method
Update the return statement (line 47) to include getFiltered:
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
- Step 3: Commit
git add frontend/services/tasks.ts
git commit -m "feat(frontend) : add getFiltered method to task service"
Task 3: Add i18n translations
Files:
-
Modify:
frontend/i18n/locales/fr.json -
Step 1: Add myTasks and sidebar keys
Add these entries to frontend/i18n/locales/fr.json (before the closing }):
"myTasks": {
"title": "Mes tâches",
"viewKanban": "Vue Kanban",
"viewList": "Vue Liste",
"allProjects": "Tous les projets",
"allGroups": "Tous les groupes",
"allTypes": "Tous les types",
"allPriorities": "Toutes les priorités",
"allEfforts": "Tous les efforts",
"allAssignees": "Tous",
"noTasks": "Aucune tâche",
"backlog": "Backlog"
},
"sidebar": {
"myTasks": "Mes tâches"
}
- Step 2: Commit
git add frontend/i18n/locales/fr.json
git commit -m "feat(frontend) : add i18n translations for my-tasks page"
Task 4: Add sidebar navigation link
Files:
-
Modify:
frontend/layouts/default.vue:23-35(nav section) -
Step 1: Add SidebarLink for "Mes tâches"
In frontend/layouts/default.vue, add a new SidebarLink between the "Tableau de bord" link (line 29) and the "Projets" link (line 30):
<SidebarLink
to="/my-tasks"
icon="mdi:clipboard-check-outline"
label="Mes tâches"
:collapsed="ui.sidebarCollapsed"
/>
- Step 2: Commit
git add frontend/layouts/default.vue
git commit -m "feat(frontend) : add Mes tâches link to sidebar navigation"
Chunk 3: Frontend — My Tasks Page (Kanban + List views)
Task 5: Create the my-tasks page
Files:
-
Create:
frontend/pages/my-tasks.vue -
Step 1: Create the page file with imports and data loading
Create frontend/pages/my-tasks.vue with the full page implementation. The page structure:
Script section — data loading pattern (same as projects/[id]/index.vue):
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
import { useProjectService } from '~/services/projects'
const { t } = useI18n()
const auth = useAuthStore()
useHead({ title: t('myTasks.title') })
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
const priorityService = useTaskPriorityService()
const tagService = useTaskTagService()
const groupService = useTaskGroupService()
const userService = useUserService()
const projectService = useProjectService()
const tasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const efforts = ref<TaskEffort[]>([])
const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const isLoading = ref(true)
// Filters
const selectedProjectId = ref<number | null>(null)
const selectedGroupId = ref<number | null>(null)
const selectedTagId = ref<number | null>(null)
const selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
// View toggle
const viewMode = ref<'kanban' | 'list'>('kanban')
// Modal
const taskModalOpen = ref(false)
const selectedTask = ref<Task | null>(null)
// Filter options
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const groupOptions = computed(() => {
let g = groups.value.filter(g => !g.archived)
if (selectedProjectId.value) {
g = g.filter(g => g.project?.id === selectedProjectId.value)
}
return g.map(g => ({ label: g.title, value: g.id }))
})
const tagOptions = computed(() =>
tags.value.map(t => ({ label: t.label, value: t.id }))
)
const priorityOptions = computed(() =>
priorities.value.map(p => ({ label: p.label, value: p.id }))
)
const effortOptions = computed(() =>
efforts.value.map(e => ({ label: e.label, value: e.id }))
)
const assigneeOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
// Kanban helpers
const sortedStatuses = computed(() =>
[...statuses.value].sort((a, b) => a.position - b.position)
)
function tasksByStatus(statusId: number): Task[] {
return tasks.value.filter(t => t.status?.id === statusId)
}
const backlogTasks = computed(() =>
tasks.value.filter(t => !t.status)
)
// Data loading
async function loadReferenceData() {
const [s, e, pr, tg, g, u, p] = await Promise.all([
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
groupService.getAll(),
userService.getAll(),
projectService.getAll(),
])
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = tg
groups.value = g
users.value = u
projects.value = p
}
async function loadTasks() {
const params: Record<string, string | number | boolean | string[]> = {
archived: false,
}
if (selectedAssigneeId.value) {
params.assignee = `/api/users/${selectedAssigneeId.value}`
}
if (selectedProjectId.value) {
params.project = `/api/projects/${selectedProjectId.value}`
}
if (selectedGroupId.value) {
params.group = `/api/task_groups/${selectedGroupId.value}`
}
if (selectedPriorityId.value) {
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
}
if (selectedEffortId.value) {
params.effort = `/api/task_efforts/${selectedEffortId.value}`
}
if (selectedTagId.value) {
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
}
tasks.value = await taskService.getFiltered(params)
}
async function loadAll() {
isLoading.value = true
try {
await Promise.all([loadReferenceData(), loadTasks()])
} finally {
isLoading.value = false
}
}
// Watch filters to reload tasks
watch(
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
() => { loadTasks() },
)
// Reset group when project changes (no extra loadTasks — the above watcher handles it)
watch(selectedProjectId, () => {
selectedGroupId.value = null
}, { flush: 'sync' })
// Modal
function openTaskEdit(task: Task) {
selectedTask.value = task
taskModalOpen.value = true
}
async function onSaved() {
await loadTasks()
}
onMounted(() => {
loadAll()
})
</script>
Template section:
<template>
<div>
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary-500">{{ $t('myTasks.title') }}</h1>
<div class="flex gap-1">
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
>
<Icon name="mdi:view-column-outline" size="20" />
</button>
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="20" />
</button>
</div>
</div>
<!-- Filters -->
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
label="Projet"
:empty-option-label="$t('myTasks.allProjects')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedGroupId"
:options="groupOptions"
label="Groupe"
:empty-option-label="$t('myTasks.allGroups')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedTagId"
:options="tagOptions"
label="Type"
:empty-option-label="$t('myTasks.allTypes')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedPriorityId"
:options="priorityOptions"
label="Priorité"
:empty-option-label="$t('myTasks.allPriorities')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedEffortId"
:options="effortOptions"
label="Effort"
:empty-option-label="$t('myTasks.allEfforts')"
min-width="w-48"
/>
<MalioSelect
v-model="selectedAssigneeId"
:options="assigneeOptions"
label="Assigné"
:empty-option-label="$t('myTasks.allAssignees')"
min-width="w-48"
/>
</div>
<!-- Kanban View -->
<div v-if="viewMode === 'kanban'" class="mt-6 flex gap-4 overflow-x-auto pb-4">
<!-- Backlog column (tasks without status) -->
<div
v-if="backlogTasks.length > 0"
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50"
>
<div class="rounded-t-lg bg-neutral-500 px-4 py-3 text-sm font-bold text-white">
{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in backlogTasks"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
</div>
</div>
<!-- Status columns -->
<div
v-for="status in sortedStatuses"
:key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50"
>
<div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
</div>
</div>
<!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6">
<div
v-for="task in tasks"
:key="task.id"
class="flex cursor-pointer items-center justify-between border-b border-neutral-100 px-4 py-3 transition-colors hover:bg-neutral-50"
@click="openTaskEdit(task)"
>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<div class="mt-1 flex items-center gap-1.5">
<span
v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: task.priority.color }"
>
{{ task.priority.label }}
</span>
<span
v-for="tag in task.tags"
:key="tag.id"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
>
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
<span
v-if="task.project && task.number"
class="text-sm font-medium text-primary-500"
>
{{ task.project.code }}-{{ task.number }}
</span>
</div>
</div>
<p
v-if="tasks.length === 0 && !isLoading"
class="py-8 text-center text-sm text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
<!-- TaskModal -->
<TaskModal
v-model="taskModalOpen"
:task="selectedTask"
:project-id="selectedTask?.project?.id ?? 0"
:statuses="statuses"
:efforts="efforts"
:priorities="priorities"
:tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users"
@saved="onSaved"
/>
</div>
</template>
Note: add the timerStore and isTimerOnTask helper in the script section:
const timerStore = useTimerStore()
function isTimerOnTask(task: Task): boolean {
const entry = timerStore.activeEntry
if (!entry?.task) return false
const entryTaskId = typeof entry.task === 'string'
? entry.task
: (entry.task['@id'] ?? entry.task.id)
const taskId = task['@id'] ?? task.id
return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}`
}
- Step 2: Verify the page loads
Run: make dev-nuxt
Navigate to http://localhost:3002/my-tasks.
Expected: page loads with filters and shows tasks assigned to current user in Kanban view.
- Step 3: Test view toggle
Click the list icon. Expected: tasks display in list format with title, badges, project code. Click the kanban icon. Expected: tasks display in columns by status.
- Step 4: Test filters
Change the assignee filter to "Tous". Expected: all tasks from all users appear. Select a specific project. Expected: only tasks from that project appear. Reset all filters. Expected: all non-archived tasks appear.
- Step 5: Test TaskModal integration
Click on a task card/row. Expected: TaskModal opens with task details pre-filled. Edit and save. Expected: modal closes, tasks reload with updated data.
- Step 6: Commit
git add frontend/pages/my-tasks.vue
git commit -m "feat(frontend) : add my-tasks page with Kanban and List views"