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

33 KiB
Raw Blame History

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 :

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) :

  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 :

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
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 :

  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 :

  const viewMode = ref<'days' | 'months' | 'years'>('days')
  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 :

  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
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 :

import {nextTick, ref} from 'vue'

Puis ajouter ces tests dans le describe('useCalendarView', …) :

  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 :

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 :

  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 :

  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 :

  const selectYear = (y: number) => {
    currentYear.value = y
  }

Mettre à jour le return :

  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
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 :

<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 :

const props = defineProps<{
  viewMode: 'days' | 'months' | 'years'
  currentMonth: number
  currentYear: number
  yearPageStart: number
}>()
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
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 :

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 :

<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
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 :

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 :

<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
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) :

    reserveMessageSpace?: boolean
    min?: string
    max?: string

Et dans withDefaults({...}), après reserveMessageSpace: true, :

    reserveMessageSpace: true,
    min: undefined,
    max: undefined,
  • Step 2: Import YearPicker and update composable destructuring

Après import MonthPicker from './MonthPicker.vue' :

import YearPicker from './YearPicker.vue'

Remplacer les deux lignes de destructuration (≈218-219) :

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 :

        <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 :

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
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 :

  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") :

    :min="min"
    :max="max"

Dans DateTime.vue : ajouter (les bornes y sont des YYYY-MM-DDTHH:MM, tronquer à la date) :

    :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
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 :

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) :

- Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau) et
  grisage des mois/années hors `min`/`max`.
  • Step 4: Commit
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.