` (line 15), after the tag MalioSelect block (after line 72), add:
+
+```vue
+
+```
+
+- [ ] **Step 3: Add date filtering to filteredEntries computed**
+
+In `frontend/pages/time-tracking.vue`, update the `filteredEntries` computed (around line 184) to include date filtering:
+
+```typescript
+const filteredEntries = computed(() => {
+ let result = entries.value
+ if (selectedProjectId.value) {
+ result = result.filter((e) => e.project?.id === selectedProjectId.value)
+ }
+ if (selectedTagId.value) {
+ result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
+ }
+ if (selectedDateFilter.value) {
+ if (Array.isArray(selectedDateFilter.value)) {
+ const [start, end] = selectedDateFilter.value
+ const startDay = new Date(start)
+ startDay.setHours(0, 0, 0, 0)
+ const endDay = new Date(end)
+ endDay.setHours(23, 59, 59, 999)
+ result = result.filter((e) => {
+ const entryDate = new Date(e.startedAt)
+ return entryDate >= startDay && entryDate <= endDay
+ })
+ } else {
+ const day = new Date(selectedDateFilter.value)
+ day.setHours(0, 0, 0, 0)
+ const nextDay = new Date(day)
+ nextDay.setDate(nextDay.getDate() + 1)
+ result = result.filter((e) => {
+ const entryDate = new Date(e.startedAt)
+ return entryDate >= day && entryDate < nextDay
+ })
+ }
+ }
+ return result
+})
+```
+
+- [ ] **Step 4: Verify manually**
+
+Run `make dev-nuxt`, navigate to time-tracking page:
+1. Verify DateFilter appears in the filter bar
+2. Click a single day — entries filter to that day
+3. Click a second day — entries filter to the range
+4. Click "Aujourd'hui" — filters to today
+5. Click "Cette semaine" — filters to current week
+6. Clear the filter — all entries show again
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/pages/time-tracking.vue
+git commit -m "feat(frontend) : integrate date filter into time-tracking page"
+```
diff --git a/docs/superpowers/specs/2026-03-15-date-filter-design.md b/docs/superpowers/specs/2026-03-15-date-filter-design.md
new file mode 100644
index 0000000..df611d8
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-15-date-filter-design.md
@@ -0,0 +1,86 @@
+# Date Filter Component - Design Spec
+
+## Summary
+
+Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`. Allows filtering by single day or date range via text input and mini calendar dropdown.
+
+## Behavior
+
+- **Single click** on a day = select that day
+- **Second click** on another day = select range between the two dates
+- **Text input**: type a date (`15/03/2026`) or a range (`15/03/2026 - 20/03/2026`)
+- **Calendar dropdown**: opens on input click/focus
+- **Quick shortcuts**: "Aujourd'hui" and "Cette semaine" buttons in calendar
+- **No time picker**: filter by day granularity only
+- **Format**: `dd/MM/yyyy` (French locale)
+
+## Component: `DateFilter.vue`
+
+Location: `frontend/components/ui/DateFilter.vue`
+
+### Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `modelValue` | `Date \| [Date, Date] \| null` | `null` | Selected date or range |
+| `placeholder` | `string` | `t('common.dateFilter')` | Input placeholder |
+
+### Emits
+
+| Event | Payload | Description |
+|-------|---------|-------------|
+| `update:modelValue` | `Date \| [Date, Date] \| null` | Date selection changed |
+
+### Implementation
+
+- Wraps `VueDatePicker` with project-consistent styling
+- Uses `#dp-input` slot for custom input matching MalioSelect style
+- Configures `range` mode with `multi-calendars: false`
+- Sets `text-input` with `format: 'dd/MM/yyyy'`, `rangeSeparator: ' - '`
+- Disables time picker (`enable-time-picker: false`)
+- Applies project primary color (`#222783`) via CSS overrides
+- Responsive width: `!w-44 sm:!w-52`
+
+## Integration: Time Tracking Page
+
+### Filter bar addition
+
+Add `DateFilter` to the existing filter bar in `frontend/pages/time-tracking.vue`, alongside user/project/tag filters.
+
+### Filtering logic
+
+- Client-side filtering (same pattern as project and tag filters)
+- When a single date is selected: show only entries matching that day
+- When a range is selected: show entries within the range (inclusive)
+- When null: show all entries (no date filter)
+
+## Files Impacted
+
+| File | Action | Description |
+|------|--------|-------------|
+| `frontend/components/ui/DateFilter.vue` | Create | Reusable date filter wrapper |
+| `frontend/nuxt.config.ts` | Modify | Add `@vuepic/vue-datepicker` to `build.transpile` |
+| `frontend/pages/time-tracking.vue` | Modify | Integrate DateFilter in filter bar + client-side filtering |
+| `frontend/i18n/locales/fr.json` | Modify | Add French translations |
+| `frontend/i18n/locales/en.json` | Modify | Add English translations |
+| `package.json` | Modify | Add `@vuepic/vue-datepicker` dependency |
+
+## i18n Keys
+
+```json
+{
+ "common": {
+ "dateFilter": "Date",
+ "today": "Aujourd'hui",
+ "thisWeek": "Cette semaine"
+ }
+}
+```
+
+## Style
+
+- Input height and borders match MalioSelect components
+- Text size: `text-sm`
+- Selected date highlight: project primary color `#222783`
+- Calendar dropdown: subtle shadow, rounded corners matching project style
+- Override default vue-datepicker CSS variables to match project theme
diff --git a/frontend/components/ui/DateFilter.vue b/frontend/components/ui/DateFilter.vue
new file mode 100644
index 0000000..c416202
--- /dev/null
+++ b/frontend/components/ui/DateFilter.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json
index f977996..f71191e 100644
--- a/frontend/i18n/locales/fr.json
+++ b/frontend/i18n/locales/fr.json
@@ -166,7 +166,11 @@
},
"common": {
"cancel": "Annuler",
- "loading": "Chargement..."
+ "loading": "Chargement...",
+ "dateFilter": "Date",
+ "today": "Aujourd'hui",
+ "thisWeek": "Cette semaine",
+ "clear": "Effacer"
},
"gitea": {
"settings": {
diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts
index 69c96c8..ebec92b 100644
--- a/frontend/nuxt.config.ts
+++ b/frontend/nuxt.config.ts
@@ -62,5 +62,8 @@ export default defineNuxtConfig({
},
typescript: {
strict: true
+ },
+ build: {
+ transpile: ['@vuepic/vue-datepicker']
}
})
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index db1ab76..4ba6645 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,7 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
+ "@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
@@ -541,6 +542,12 @@
"postcss-selector-parser": "^7.0.0"
}
},
+ "node_modules/@date-fns/tz": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
+ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
+ "license": "MIT"
+ },
"node_modules/@dxup/nuxt": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
@@ -1094,6 +1101,68 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
+ },
+ "node_modules/@floating-ui/vue": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
+ "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.6",
+ "@floating-ui/utils": "^0.2.11",
+ "vue-demi": ">=0.13.0"
+ }
+ },
+ "node_modules/@floating-ui/vue/node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -5259,6 +5328,12 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT"
},
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.21",
+ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+ "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/project-service": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
@@ -5720,6 +5795,62 @@
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"license": "MIT"
},
+ "node_modules/@vuepic/vue-datepicker": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-12.1.0.tgz",
+ "integrity": "sha512-QuWcO+CqIGYFoRNCagp9xUY9sMK/OHUlVIDxBYjw7HjCTWXfuE/r3l3loB00faEtb0Teo3DeBn26hT3tYA5pgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@date-fns/tz": "^1.4.1",
+ "@floating-ui/vue": "^1.1.9",
+ "@vueuse/core": "^14.1.0",
+ "date-fns": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ },
+ "peerDependencies": {
+ "vue": ">=3.5.0"
+ }
+ },
+ "node_modules/@vueuse/core": {
+ "version": "14.2.1",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
+ "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "14.2.1",
+ "@vueuse/shared": "14.2.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "14.2.1",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
+ "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "14.2.1",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
+ "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
"node_modules/abbrev": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -7126,6 +7257,16 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/db0": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ef4aff3..bccdaab 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,6 +16,7 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
+ "@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
diff --git a/frontend/pages/projects/[id]/index.vue b/frontend/pages/projects/[id]/index.vue
index 5bd364d..5d24a9d 100644
--- a/frontend/pages/projects/[id]/index.vue
+++ b/frontend/pages/projects/[id]/index.vue
@@ -3,13 +3,22 @@
{{ project?.name ?? '' }}
-
+
+
+
+
@@ -120,6 +129,13 @@
@saved="onSaved"
/>
+
+
@@ -132,7 +148,9 @@ 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 { Client } from '~/services/dto/client'
import { useProjectService } from '~/services/projects'
+import { useClientService } from '~/services/clients'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
@@ -147,6 +165,7 @@ const projectId = computed(() => Number(route.params.id))
useHead({ title: 'Projet' })
const projectService = useProjectService()
+const clientService = useClientService()
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
@@ -163,6 +182,7 @@ const priorities = ref
([])
const tags = ref([])
const groups = ref([])
const users = ref([])
+const clients = ref([])
const isLoading = ref(true)
const selectedGroupId = ref(null)
@@ -172,6 +192,7 @@ const selectedStatusId = ref(null)
const dragOverStatusId = ref(null)
const dragCounter = ref(0)
const taskDrawerOpen = ref(false)
+const projectDrawerOpen = ref(false)
const selectedTask = ref(null)
const groupFilterOptions = computed(() =>
@@ -218,7 +239,7 @@ const backlogTasks = computed(() =>
async function loadData() {
isLoading.value = true
try {
- const [p, t, s, e, pr, ty, g, u] = await Promise.all([
+ const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProject(projectId.value),
statusService.getAll(),
@@ -227,6 +248,7 @@ async function loadData() {
tagService.getAll(),
groupService.getByProject(projectId.value),
userService.getAll(),
+ clientService.getAll(),
])
project.value = p
tasks.value = t
@@ -236,6 +258,7 @@ async function loadData() {
tags.value = ty
groups.value = g
users.value = u
+ clients.value = c
} finally {
isLoading.value = false
}
@@ -290,6 +313,10 @@ async function onSaved() {
await loadData()
}
+async function onProjectSaved() {
+ await loadData()
+}
+
onMounted(() => {
loadData()
})
diff --git a/frontend/pages/time-tracking.vue b/frontend/pages/time-tracking.vue
index b567208..c3518d9 100644
--- a/frontend/pages/time-tracking.vue
+++ b/frontend/pages/time-tracking.vue
@@ -70,6 +70,8 @@
text-value="text-sm"
/>
+
+
@@ -136,6 +138,7 @@ const startDate = ref(getMonday(new Date()))
const selectedUserId = ref