Files
malio-layer-ui/docs/superpowers/plans/2026-06-22-date-year-picker.md
T
2026-06-22 09:52:13 +02:00

935 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sélecteur d'année dans le calendrier — 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:** Ajouter un 3ᵉ niveau de navigation au calendrier de la famille `date/` : depuis la vue mois, recliquer sur le header ouvre un sélecteur d'année calqué sur le sélecteur de mois, avec respect des bornes `min`/`max`.
**Architecture:** Le shell partagé `internal/CalendarField.vue` orchestre l'input, le popover, le header (`CalendarHeader`) et la commutation entre les vues `days` / `months` / `years`. `MonthPicker` et le nouveau `YearPicker` sont rendus dans `CalendarField` ; la grille de jours reste fournie par chaque consommateur via slot scoped. La logique d'état vit dans deux composables (`useCalendarPopover`, `useCalendarView`) et les bornes dans des helpers purs (`dateFormat.ts`).
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, TypeScript strict, Tailwind (palette `m-*`), `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
## Global Constraints
- Composants : `defineOptions({name: 'MalioXxx'})` (les internes utilisent `MalioDateXxx`, sans `inheritAttrs: false` pour les pickers — calquer l'existant `MonthPicker`).
- Valeurs ISO : `min`/`max` sont des chaînes `YYYY-MM-DD` (`DateTime` les tronque via `.slice(0, 10)`).
- Tests : Vitest run mode, fichiers colocalisés `*.test.ts`, jsdom. Déterminisme via `vi.setSystemTime(new Date(2026, 4, 19))` (19 mai 2026).
- Lancer un test ciblé : `npx vitest run <chemin>`.
- Commits : Conventional Commits **avec espace avant `:`** (`feat : message (#…)`), type minuscule, **pas de scope majuscule** (préférer sans scope). Terminer par `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- Hook pre-commit flaky (timeouts WSL2) : après 2 échecs flaky, vérifier le test ciblé à la main puis committer avec `--no-verify`.
- Ne PAS toucher / committer `nuxt.config.ts` (modif ngrok locale intentionnelle). Stager des fichiers explicites, jamais `git add -A`.
- Aucune API publique des 4 composants consommateurs ne change.
---
### Task 1: Helpers de plage mois/année (`dateFormat.ts`)
**Files:**
- Modify: `app/components/malio/date/composables/dateFormat.ts`
- Test: `app/components/malio/date/composables/dateFormat.test.ts`
**Interfaces:**
- Consumes: rien.
- Produces:
- `isMonthInRange(year: number, month: number, min?: string, max?: string): boolean` (month 0-11)
- `isYearInRange(year: number, min?: string, max?: string): boolean`
- [ ] **Step 1: Write the failing tests**
Ajouter dans `dateFormat.test.ts` — l'import en tête devient :
```ts
import {formatIsoToDisplay, isDateInRange, isMonthInRange, isValidIso, isYearInRange, parseDisplayToIso} from './dateFormat'
```
Puis ajouter ces deux blocs `describe` à l'intérieur du `describe('dateFormat', …)` (après le bloc `isDateInRange`) :
```ts
describe('isMonthInRange', () => {
it('returns true when no bounds are given', () => {
expect(isMonthInRange(2026, 4)).toBe(true)
})
it('respects the min bound by month (inclusive)', () => {
expect(isMonthInRange(2026, 4, '2026-05-10')).toBe(true) // mai chevauche
expect(isMonthInRange(2026, 3, '2026-05-10')).toBe(false) // avril < mai
})
it('respects the max bound by month (inclusive)', () => {
expect(isMonthInRange(2026, 4, undefined, '2026-05-31')).toBe(true)
expect(isMonthInRange(2026, 5, undefined, '2026-05-31')).toBe(false) // juin > mai
})
it('disables months in years outside the range', () => {
expect(isMonthInRange(2025, 11, '2026-05-10')).toBe(false)
expect(isMonthInRange(2027, 0, undefined, '2026-05-31')).toBe(false)
})
})
describe('isYearInRange', () => {
it('returns true when no bounds are given', () => {
expect(isYearInRange(2026)).toBe(true)
})
it('respects the min bound by year (inclusive)', () => {
expect(isYearInRange(2026, '2026-05-10')).toBe(true)
expect(isYearInRange(2025, '2026-05-10')).toBe(false)
})
it('respects the max bound by year (inclusive)', () => {
expect(isYearInRange(2026, undefined, '2026-05-31')).toBe(true)
expect(isYearInRange(2027, undefined, '2026-05-31')).toBe(false)
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run app/components/malio/date/composables/dateFormat.test.ts`
Expected: FAIL — `isMonthInRange is not a function` / `isYearInRange is not a function`.
- [ ] **Step 3: Write minimal implementation**
Ajouter à la fin de `dateFormat.ts` :
```ts
export function isMonthInRange(year: number, month: number, min?: string, max?: string): boolean {
const ym = `${year}-${String(month + 1).padStart(2, '0')}`
if (min && ym < min.slice(0, 7)) return false
if (max && ym > max.slice(0, 7)) return false
return true
}
export function isYearInRange(year: number, min?: string, max?: string): boolean {
if (min && year < Number(min.slice(0, 4))) return false
if (max && year > Number(max.slice(0, 4))) return false
return true
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run app/components/malio/date/composables/dateFormat.test.ts`
Expected: PASS (tous les blocs, anciens + nouveaux).
- [ ] **Step 5: Commit**
```bash
git add app/components/malio/date/composables/dateFormat.ts app/components/malio/date/composables/dateFormat.test.ts
git commit -m "feat : helpers isMonthInRange/isYearInRange (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Machine à états 3 vues (`useCalendarPopover.ts`)
**Files:**
- Modify: `app/components/malio/date/composables/useCalendarPopover.ts`
- Test: `app/components/malio/date/composables/useCalendarPopover.test.ts`
**Interfaces:**
- Consumes: rien.
- Produces (retour du composable) : `{isOpen, viewMode, open, close, goToHigherView}``viewMode: Ref<'days' | 'months' | 'years'>` et `goToHigherView(): void`. **Remplace `toggleView`.**
- [ ] **Step 1: Update the failing test**
Dans `useCalendarPopover.test.ts`, remplacer le test `toggleView() switches between days and months` (lignes ~33-40) par :
```ts
it('goToHigherView() climbs days -> months -> years and stops', () => {
const {api} = mountHost()
api.open()
api.goToHigherView()
expect(api.viewMode.value).toBe('months')
api.goToHigherView()
expect(api.viewMode.value).toBe('years')
api.goToHigherView()
expect(api.viewMode.value).toBe('years') // no-op au niveau le plus haut
})
```
Et dans le test `close() resets isOpen and viewMode` (lignes ~42-49), remplacer `api.toggleView()` par `api.goToHigherView()`.
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run app/components/malio/date/composables/useCalendarPopover.test.ts`
Expected: FAIL — `api.goToHigherView is not a function`.
- [ ] **Step 3: Write minimal implementation**
Dans `useCalendarPopover.ts`, remplacer la ligne du ref et la fonction `toggleView` :
```ts
const viewMode = ref<'days' | 'months' | 'years'>('days')
```
```ts
const goToHigherView = () => {
if (viewMode.value === 'days') viewMode.value = 'months'
else if (viewMode.value === 'months') viewMode.value = 'years'
// 'years' : niveau le plus haut, no-op
}
```
Et le `return` :
```ts
return {isOpen, viewMode, open, close, goToHigherView}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run app/components/malio/date/composables/useCalendarPopover.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add app/components/malio/date/composables/useCalendarPopover.ts app/components/malio/date/composables/useCalendarPopover.test.ts
git commit -m "feat : viewMode 3 niveaux + goToHigherView (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Navigation & fenêtre d'années (`useCalendarView.ts`)
**Files:**
- Modify: `app/components/malio/date/composables/useCalendarView.ts`
- Test: `app/components/malio/date/composables/useCalendarView.test.ts`
**Interfaces:**
- Consumes: `viewMode: Ref<'days' | 'months' | 'years'>` (depuis Task 2).
- Produces (retour) : `{currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}``yearPageStart: Ref<number>` et `selectYear(y: number): void`.
- [ ] **Step 1: Write the failing tests**
Dans `useCalendarView.test.ts` : ajouter l'import de `nextTick` en tête :
```ts
import {nextTick, ref} from 'vue'
```
Puis ajouter ces tests dans le `describe('useCalendarView', …)` :
```ts
it('paginates years by 12 in years view', () => {
const {yearPageStart, goToNext, goToPrev} = useCalendarView(ref('years'))
const start = yearPageStart.value
goToNext()
expect(yearPageStart.value).toBe(start + 12)
goToPrev()
expect(yearPageStart.value).toBe(start)
})
it('selectYear sets the current year', () => {
const {currentYear, selectYear} = useCalendarView(ref('days'))
selectYear(2030)
expect(currentYear.value).toBe(2030)
})
it('recenters the year page on entering years view (current - 5)', async () => {
const mode = ref<'days' | 'months' | 'years'>('days')
const {yearPageStart} = useCalendarView(mode)
mode.value = 'years'
await nextTick()
expect(yearPageStart.value).toBe(2021) // 2026 - 5
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run app/components/malio/date/composables/useCalendarView.test.ts`
Expected: FAIL — `yearPageStart` / `selectYear` undefined.
- [ ] **Step 3: Write minimal implementation**
Dans `useCalendarView.ts` :
Élargir la signature et importer `watch` :
```ts
import {ref, watch, type Ref} from 'vue'
import {isValidIso} from './dateFormat'
export function useCalendarView(viewMode: Ref<'days' | 'months' | 'years'>) {
const today = new Date()
const currentMonth = ref(today.getMonth())
const currentYear = ref(today.getFullYear())
const yearPageStart = ref(today.getFullYear() - 5)
watch(viewMode, (mode) => {
if (mode === 'years') yearPageStart.value = currentYear.value - 5
})
```
Dans `goToPrev`, ajouter la branche `years` en tête :
```ts
const goToPrev = () => {
if (viewMode.value === 'years') {
yearPageStart.value -= 12
return
}
if (viewMode.value === 'months') {
currentYear.value -= 1
return
}
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
```
Dans `goToNext`, idem :
```ts
const goToNext = () => {
if (viewMode.value === 'years') {
yearPageStart.value += 12
return
}
if (viewMode.value === 'months') {
currentYear.value += 1
return
}
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
```
Ajouter `selectYear` après `selectMonth` :
```ts
const selectYear = (y: number) => {
currentYear.value = y
}
```
Mettre à jour le `return` :
```ts
return {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run app/components/malio/date/composables/useCalendarView.test.ts`
Expected: PASS (anciens + nouveaux).
- [ ] **Step 5: Commit**
```bash
git add app/components/malio/date/composables/useCalendarView.ts app/components/malio/date/composables/useCalendarView.test.ts
git commit -m "feat : pagination des années + selectYear + recentrage (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Header contextuel (`CalendarHeader.vue`)
**Files:**
- Modify: `app/components/malio/date/internal/CalendarHeader.vue`
**Interfaces:**
- Consumes: `viewMode`, `currentMonth`, `currentYear`, `yearPageStart` (props).
- Produces: émet `prev` / `next` / `toggle-view` (inchangé). En vue `years`, le bouton libellé n'émet pas `toggle-view` et ne montre pas le chevron-bas.
> Pas de test colocalisé pour `CalendarHeader` (l'existant n'en a pas) — vérifié via l'e2e en Task 8. Ce Task est purement implémentation ; pas de cycle test propre, donc on le commit avec son cycle de vérification = build/lint + e2e en Task 8. Le step de vérif ci-dessous est un contrôle visuel du diff.
- [ ] **Step 1: Implement the contextual label and years behavior**
Remplacer le `<template>` par :
```vue
<template>
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
<button
type="button"
data-test="header-prev"
class="ml-2 flex self-start rounded"
:aria-label="prevLabel"
@click="emit('prev')"
>
<Icon
icon="mdi:chevron-left"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-toggle"
class="flex gap-1 rounded text-base font-medium"
@click="viewMode !== 'years' && emit('toggle-view')"
>
<span class="mt-[2px]">{{ label }}</span>
<Icon
v-if="viewMode !== 'years'"
icon="mdi:chevron-down"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-next"
class="mr-2 flex self-start rounded"
:aria-label="nextLabel"
@click="emit('next')"
>
<Icon
icon="mdi:chevron-right"
:width="25"
:height="25"
/>
</button>
</div>
</template>
```
Dans le `<script setup>` : élargir le type `viewMode`, ajouter `yearPageStart`, et calculer `label` + `prevLabel` + `nextLabel` :
```ts
const props = defineProps<{
viewMode: 'days' | 'months' | 'years'
currentMonth: number
currentYear: number
yearPageStart: number
}>()
```
```ts
const label = computed(() => {
if (props.viewMode === 'years') return `${props.yearPageStart} ${props.yearPageStart + 11}`
if (props.viewMode === 'months') return `${props.currentYear}`
const name = monthsLong[props.currentMonth]
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
})
const prevLabel = computed(() =>
props.viewMode === 'days' ? 'Mois précédent'
: props.viewMode === 'months' ? 'Année précédente'
: 'Période précédente',
)
const nextLabel = computed(() =>
props.viewMode === 'days' ? 'Mois suivant'
: props.viewMode === 'months' ? 'Année suivante'
: 'Période suivante',
)
```
(Supprimer les anciens `:aria-label` ternaires inline du template — remplacés par `prevLabel`/`nextLabel`.)
- [ ] **Step 2: Lint the file**
Run: `npx eslint app/components/malio/date/internal/CalendarHeader.vue`
Expected: aucune erreur.
- [ ] **Step 3: Commit**
```bash
git add app/components/malio/date/internal/CalendarHeader.vue
git commit -m "feat : header contextuel jours/mois/années (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Composant `YearPicker.vue` + tests
**Files:**
- Create: `app/components/malio/date/internal/YearPicker.vue`
- Test: `app/components/malio/date/internal/YearPicker.test.ts`
**Interfaces:**
- Consumes: `isYearInRange` (Task 1).
- Produces: composant `<YearPicker :page-start :selected-year? :min? :max? @select="(year:number)=>…" />`. Rend 12 boutons `data-test="year"` avec `data-year`. Conteneur `data-test="year-picker"`.
- [ ] **Step 1: Write the failing test**
Créer `app/components/malio/date/internal/YearPicker.test.ts` :
```ts
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import YearPicker from './YearPicker.vue'
const mountPicker = (props: {pageStart: number, selectedYear?: number, min?: string, max?: string}) =>
mount(YearPicker, {props})
describe('MalioDateYearPicker', () => {
it('renders 12 years from pageStart', () => {
const wrapper = mountPicker({pageStart: 2021})
const years = wrapper.findAll('[data-test="year"]')
expect(years).toHaveLength(12)
expect(years[0].attributes('data-year')).toBe('2021')
expect(years[11].attributes('data-year')).toBe('2032')
})
it('emits select with the clicked year', async () => {
const wrapper = mountPicker({pageStart: 2021})
await wrapper.get('[data-test="year"][data-year="2026"]').trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual([2026])
})
it('disables years outside [min, max] and does not emit', async () => {
const wrapper = mountPicker({pageStart: 2021, min: '2025-01-01', max: '2027-12-31'})
const out = wrapper.get('[data-test="year"][data-year="2024"]')
expect(out.attributes('disabled')).toBeDefined()
await out.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
})
it('highlights the selected year', () => {
const wrapper = mountPicker({pageStart: 2021, selectedYear: 2026})
const span = wrapper.get('[data-test="year"][data-year="2026"] span')
expect(span.classes()).toContain('bg-m-primary')
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run app/components/malio/date/internal/YearPicker.test.ts`
Expected: FAIL — impossible de résoudre `./YearPicker.vue`.
- [ ] **Step 3: Write the component**
Créer `app/components/malio/date/internal/YearPicker.vue` :
```vue
<template>
<div
data-test="year-picker"
class="grid grid-cols-3 gap-3"
>
<button
v-for="year in years"
:key="year"
type="button"
data-test="year"
:data-year="year"
:disabled="!isYearInRange(year, min, max)"
:aria-disabled="!isYearInRange(year, min, max)"
class="flex h-[45px] w-full items-center justify-center"
:class="isYearInRange(year, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="emit('select', year)"
>
<span
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
:class="year === selectedYear
? 'bg-m-primary text-white'
: isYearInRange(year, min, max)
? 'text-black hover:bg-m-primary/10'
: 'text-m-muted/30'"
>
{{ year }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {isYearInRange} from '../composables/dateFormat'
defineOptions({name: 'MalioDateYearPicker'})
const props = defineProps<{
pageStart: number
selectedYear?: number
min?: string
max?: string
}>()
const emit = defineEmits<{(e: 'select', year: number): void}>()
const years = computed(() => Array.from({length: 12}, (_, i) => props.pageStart + i))
</script>
```
> Note : `@click` émet toujours, mais un `<button disabled>` ne déclenche pas l'événement clic dans jsdom/navigateur — le test « does not emit » le valide.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run app/components/malio/date/internal/YearPicker.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add app/components/malio/date/internal/YearPicker.vue app/components/malio/date/internal/YearPicker.test.ts
git commit -m "feat : composant YearPicker avec bornage min/max (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Bornage min/max du `MonthPicker` + tests
**Files:**
- Modify: `app/components/malio/date/internal/MonthPicker.vue`
- Test (create): `app/components/malio/date/internal/MonthPicker.test.ts`
**Interfaces:**
- Consumes: `isMonthInRange` (Task 1).
- Produces: `<MonthPicker :selected-month? :current-year :min? :max? @select="(month:number)=>…" />`. `currentYear` devient **requis** (nécessaire pour borner les mois).
- [ ] **Step 1: Write the failing test**
Créer `app/components/malio/date/internal/MonthPicker.test.ts` :
```ts
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import MonthPicker from './MonthPicker.vue'
const mountPicker = (props: {currentYear: number, selectedMonth?: number, min?: string, max?: string}) =>
mount(MonthPicker, {props})
describe('MalioDateMonthPicker', () => {
it('renders 12 months', () => {
const wrapper = mountPicker({currentYear: 2026})
expect(wrapper.findAll('[data-test="month"]')).toHaveLength(12)
})
it('emits select with the clicked month index', async () => {
const wrapper = mountPicker({currentYear: 2026})
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual([0])
})
it('disables months before min in the current year and does not emit', async () => {
const wrapper = mountPicker({currentYear: 2026, min: '2026-05-01'})
const april = wrapper.get('[data-test="month"][data-month="3"]')
expect(april.attributes('disabled')).toBeDefined()
await april.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
})
it('disables months after max in the current year', () => {
const wrapper = mountPicker({currentYear: 2026, max: '2026-05-31'})
expect(wrapper.get('[data-test="month"][data-month="5"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-test="month"][data-month="4"]').attributes('disabled')).toBeUndefined()
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run app/components/malio/date/internal/MonthPicker.test.ts`
Expected: FAIL — le mois avril n'est pas désactivé (`disabled` undefined).
- [ ] **Step 3: Update the component**
Remplacer `MonthPicker.vue` par :
```vue
<template>
<div
data-test="month-picker"
class="grid grid-cols-3 gap-3"
>
<button
v-for="(name, index) in months"
:key="name"
type="button"
data-test="month"
:data-month="index"
:disabled="!isMonthInRange(currentYear, index, min, max)"
:aria-disabled="!isMonthInRange(currentYear, index, min, max)"
class="flex h-[45px] w-full items-center justify-center"
:class="isMonthInRange(currentYear, index, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="emit('select', index)"
>
<span
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
:class="index === selectedMonth
? 'bg-m-primary text-white'
: isMonthInRange(currentYear, index, min, max)
? 'text-black hover:bg-m-primary/10'
: 'text-m-muted/30'"
>
{{ name }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
import {isMonthInRange} from '../composables/dateFormat'
defineOptions({name: 'MalioDateMonthPicker'})
defineProps<{
currentYear: number
selectedMonth?: number
min?: string
max?: string
}>()
const emit = defineEmits<{(e: 'select', month: number): void}>()
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
</script>
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run app/components/malio/date/internal/MonthPicker.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add app/components/malio/date/internal/MonthPicker.vue app/components/malio/date/internal/MonthPicker.test.ts
git commit -m "feat : bornage min/max du MonthPicker (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 7: Câblage `CalendarField.vue`
**Files:**
- Modify: `app/components/malio/date/internal/CalendarField.vue`
**Interfaces:**
- Consumes: `useCalendarPopover` (`goToHigherView`), `useCalendarView` (`yearPageStart`, `selectYear`), `CalendarHeader` (prop `yearPageStart`), `MonthPicker` (props `currentYear`/`min`/`max`), `YearPicker` (Task 5).
- Produces: `CalendarField` accepte les props `min?: string` et `max?: string` ; rend les 3 vues.
> Vérifié via l'e2e en Task 8 (pas de test colocalisé propre à `CalendarField`). Steps = implémentation + lint, le cycle test rouge/vert vit en Task 8.
- [ ] **Step 1: Add min/max props**
Dans le bloc `defineProps`, ajouter `min` et `max` (après `reserveMessageSpace?: boolean`) :
```ts
reserveMessageSpace?: boolean
min?: string
max?: string
```
Et dans `withDefaults({...})`, après `reserveMessageSpace: true,` :
```ts
reserveMessageSpace: true,
min: undefined,
max: undefined,
```
- [ ] **Step 2: Import YearPicker and update composable destructuring**
Après `import MonthPicker from './MonthPicker.vue'` :
```ts
import YearPicker from './YearPicker.vue'
```
Remplacer les deux lignes de destructuration (≈218-219) :
```ts
const {isOpen, viewMode, open, close: closePopover, goToHigherView} = useCalendarPopover(root)
const {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso} = useCalendarView(viewMode)
```
- [ ] **Step 3: Update the popover template**
Remplacer le bloc `<CalendarHeader>` + `<slot>` + `<MonthPicker>` (≈85-103) par :
```vue
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
:year-page-start="yearPageStart"
@prev="goToPrev"
@next="goToNext"
@toggle-view="goToHigherView"
/>
<slot
v-if="viewMode === 'days'"
:current-month="currentMonth"
:current-year="currentYear"
:close="closePopover"
/>
<MonthPicker
v-else-if="viewMode === 'months'"
:selected-month="currentMonth"
:current-year="currentYear"
:min="min"
:max="max"
@select="onSelectMonth"
/>
<YearPicker
v-else
:page-start="yearPageStart"
:selected-year="currentYear"
:min="min"
:max="max"
@select="onSelectYear"
/>
```
- [ ] **Step 4: Update handlers**
`goToHigherView` ne sert qu'au header (zoom arrière). À la **sélection**, on redescend d'un niveau. `useCalendarPopover` n'expose pas de setter de vue, mais `viewMode` est un `ref` déstructuré et donc directement mutable. Remplacer `onSelectMonth` (≈324-327) par les deux handlers suivants :
```ts
const onSelectMonth = (m: number) => {
selectMonth(m)
viewMode.value = 'days'
}
const onSelectYear = (y: number) => {
selectYear(y)
viewMode.value = 'months'
}
```
- [ ] **Step 5: Lint**
Run: `npx eslint app/components/malio/date/internal/CalendarField.vue`
Expected: aucune erreur (notamment, `viewMode` est bien mutable car c'est un `ref`).
- [ ] **Step 6: Commit**
```bash
git add app/components/malio/date/internal/CalendarField.vue
git commit -m "feat : intégration du sélecteur d'année dans CalendarField (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 8: Binder min/max chez les consommateurs + e2e
**Files:**
- Modify: `app/components/malio/date/Date.vue`
- Modify: `app/components/malio/date/DateRange.vue`
- Modify: `app/components/malio/date/DateTime.vue`
- Modify: `app/components/malio/date/DateWeek.vue`
- Test: `app/components/malio/date/Date.test.ts`
**Interfaces:**
- Consumes: `CalendarField` props `min`/`max` (Task 7).
- Produces: les 4 composants propagent leurs bornes au popover. API publique inchangée.
- [ ] **Step 1: Write the failing e2e test**
Dans `Date.test.ts`, à l'intérieur du `describe('vue mois', …)` (ou un nouveau `describe('vue années')`), ajouter :
```ts
describe('vue années', () => {
it('opens the year picker on second header toggle', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> mois
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> années
expect(wrapper.find('[data-test="year-picker"]').exists()).toBe(true)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2021 2032')
})
it('navigates days -> months -> years -> months -> days', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="year"][data-year="2024"]').trigger('click') // -> mois 2024
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2024')
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click') // -> jours
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2024')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('paginates the year window with chevrons', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2033 2044')
})
it('greys out years outside [min, max]', async () => {
const wrapper = mountDate({min: '2025-01-01', max: '2027-12-31'})
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
expect(wrapper.get('[data-test="year"][data-year="2024"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-test="year"][data-year="2026"]').attributes('disabled')).toBeUndefined()
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run app/components/malio/date/Date.test.ts`
Expected: FAIL — `year-picker` introuvable (Date.vue ne passe pas encore `min`/`max` à `CalendarField`).
- [ ] **Step 3: Bind min/max on each consumer's `<CalendarField>`**
Dans `Date.vue`, `DateRange.vue`, `DateWeek.vue` : ajouter sur la balise racine `<CalendarField …>` (au même niveau que les autres props, ex. après `:readonly="readonly"`) :
```vue
:min="min"
:max="max"
```
Dans `DateTime.vue` : ajouter (les bornes y sont des `YYYY-MM-DDTHH:MM`, tronquer à la date) :
```vue
:min="min?.slice(0, 10)"
:max="max?.slice(0, 10)"
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run app/components/malio/date/Date.test.ts`
Expected: PASS (anciens + nouveaux).
- [ ] **Step 5: Run the whole date suite (non-régression)**
Run: `npx vitest run app/components/malio/date/`
Expected: PASS — `Date`, `DateRange`, `DateTime`, `DateWeek`, composables, pickers. (En cas de timeout flaky WSL2, relancer ; voir Global Constraints.)
- [ ] **Step 6: Commit**
```bash
git add app/components/malio/date/Date.vue app/components/malio/date/DateRange.vue app/components/malio/date/DateTime.vue app/components/malio/date/DateWeek.vue app/components/malio/date/Date.test.ts
git commit -m "feat : propage min/max au popover + e2e sélecteur d'année (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 9: Documentation (`COMPONENTS.md`, `CHANGELOG.md`)
**Files:**
- Modify: `COMPONENTS.md`
- Modify: `CHANGELOG.md`
**Interfaces:**
- Consumes: comportement livré aux tasks 1-8.
- Produces: doc à jour (convention projet : maj manuelle).
- [ ] **Step 1: Locate the date section in COMPONENTS.md**
Run: `grep -nE "MalioDate|Date|calendrier|sélecteur" COMPONENTS.md | head -20`
Expected: repère la section calendrier/date. Si aucune entrée, l'ajouter à la suite des autres composants date.
- [ ] **Step 2: Document the 3-level navigation**
Ajouter (dans la section du calendrier date) une description du 3ᵉ niveau :
```markdown
Le calendrier propose trois niveaux de navigation : jours → clic sur l'en-tête →
sélecteur de mois → nouveau clic sur l'en-tête → sélecteur d'année (grille de 12 ans,
chevrons par pas de 12 ans). Les props `min`/`max` grisent les mois et les années hors
plage. Sélectionner une année revient au sélecteur de mois, sélectionner un mois revient
à la grille de jours.
```
- [ ] **Step 3: Add a CHANGELOG entry**
Sous la section non publiée (ou en tête, selon le format existant — vérifier `head -20 CHANGELOG.md`) :
```markdown
- Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau) et
grisage des mois/années hors `min`/`max`.
```
- [ ] **Step 4: Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs : sélecteur d'année dans le calendrier (#date-year-picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Notes de vérification finale
- Suite ciblée complète : `npx vitest run app/components/malio/date/`
- Lint global : `npm run lint`
- Vérif manuelle navigateur : **proposer** à l'utilisateur (ne pas lancer le MCP Chrome sans accord — coût tokens). Parcours : ouvrir un `MalioDate`, cliquer le header 2× → grille d'années, paginer, choisir une année puis un mois, et tester un champ avec `min`/`max` pour voir le grisage.