From 13b0ea685a1dbc2586c49f4298657550a8b5c447 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 20 May 2026 11:49:39 +0200 Subject: [PATCH] =?UTF-8?q?docs=20:=20plan=20d'impl=C3=A9mentation=20de=20?= =?UTF-8?q?MalioDateRange=20+=20shell=20partag=C3=A9=20(#MUI-33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../superpowers/plans/2026-05-20-daterange.md | 1362 +++++++++++++++++ 1 file changed, 1362 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-daterange.md diff --git a/docs/superpowers/plans/2026-05-20-daterange.md b/docs/superpowers/plans/2026-05-20-daterange.md new file mode 100644 index 0000000..c571126 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-daterange.md @@ -0,0 +1,1362 @@ +# MalioDateRange + shell partagé — 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:** Composant `` (sélection période, hover preview, surlignage demi-barre) bâti sur un shell `CalendarField` extrait de `MalioDate`, sans changer l'API publique de `Date`. + +**Architecture:** Extraction d'un shell `CalendarField.vue` (champ + popover + header + vue mois + navigation) consommé par `Date` et `DateRange` via slot scoped. `MonthGrid` étendu pour le mode plage. Logique pure dans `dateRange.ts` + `useCalendarView.ts`. + +**Tech Stack:** Nuxt 4 layer, Vue 3 ` + + +``` + +- [ ] **Step 2 : Lint** — `npx eslint app/components/malio/date/internal/CalendarField.vue` → 0 erreur + +- [ ] **Step 3 : Commit** + +```bash +git add app/components/malio/date/internal/CalendarField.vue +git commit -m "feat : shell CalendarField partagé (champ + popover + navigation) (#MUI-33)" +``` + +--- + +## Task 4 : Refacto `Date.vue` en enveloppe + +**Files:** +- Modify: `app/components/malio/date/Date.vue` (remplacement complet du fichier) +- Test: `app/components/malio/date/Date.test.ts` (inchangé — doit rester vert) + +- [ ] **Step 1 : Remplacer `Date.vue` par l'enveloppe** + +```vue + + + + +``` + +- [ ] **Step 2 : Lancer Date.test.ts, vérifier qu'il reste vert** + +Run: `npx vitest run app/components/malio/date/Date.test.ts` +Expected: PASS (21 tests). Si un test casse → régression de refacto à corriger dans `CalendarField`/`Date` (ne pas adapter le test). + +- [ ] **Step 3 : Lint** — `npx eslint app/components/malio/date/Date.vue` → 0 erreur + +- [ ] **Step 4 : Commit** + +```bash +git add app/components/malio/date/Date.vue +git commit -m "refactor : Date.vue devient une enveloppe du shell CalendarField (#MUI-33)" +``` + +--- + +## Task 5 : Extension `MonthGrid.vue` (mode plage) + +**Files:** +- Modify: `app/components/malio/date/internal/MonthGrid.vue` + +Couvert par `DateRange.test.ts` (Task 6) pour le mode plage et `Date.test.ts` pour le mode simple. + +- [ ] **Step 1 : Remplacer `MonthGrid.vue`** (ajout props range, hover, data-range-role, double couche) + +```vue + + + + +``` + +- [ ] **Step 2 : Vérifier la non-régression simple** — `npx vitest run app/components/malio/date/Date.test.ts` → PASS (21) + +- [ ] **Step 3 : Lint** — `npx eslint app/components/malio/date/internal/MonthGrid.vue` → 0 erreur + +- [ ] **Step 4 : Commit** + +```bash +git add app/components/malio/date/internal/MonthGrid.vue +git commit -m "feat : MonthGrid mode plage (surlignage demi-barre + hover + data-range-role) (#MUI-33)" +``` + +--- + +## Task 6 : `DateRange.vue` + tests d'intégration + +**Files:** +- Create: `app/components/malio/date/DateRange.vue` +- Test: `app/components/malio/date/DateRange.test.ts` + +- [ ] **Step 1 : Créer `DateRange.vue`** + +```vue + + + + +``` + +- [ ] **Step 2 : Écrire `DateRange.test.ts`** + +```ts +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import DateRange from './DateRange.vue' + +type RangeValue = {start: string; end: string} +type DateRangeProps = { + modelValue?: RangeValue | null + label?: string + disabled?: boolean + readonly?: boolean + error?: string + min?: string + max?: string + clearable?: boolean +} + +const DateRangeForTest = DateRange as DefineComponent +const mountRange = (props: DateRangeProps = {}) => + mount(DateRangeForTest, {props, attachTo: document.body}) + +const openAndClickDays = async (wrapper: ReturnType, isos: string[]) => { + await wrapper.get('[data-test="date-input"]').trigger('click') + for (const iso of isos) { + await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click') + } +} + +describe('MalioDateRange', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + it('renders the label and calendar icon', () => { + const wrapper = mountRange({label: 'Période'}) + expect(wrapper.get('label').text()).toBe('Période') + expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) + }) + + it('displays the formatted range when modelValue is set', () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('19/05/2026 - 25/05/2026') + }) + + it('shows an empty field without a value', () => { + const wrapper = mountRange() + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('') + }) + + it('opens on the start month when a range is set', async () => { + const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025') + }) + + it('does not emit on the first click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19']) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) + }) + + it('emits the range and closes on the second click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}]) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('auto-inverts when the second click is before the first', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}]) + }) + + it('allows a single-day range', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}]) + }) + + it('restarts a new range on the third click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25']) // complete + await wrapper.get('[data-test="date-input"]').trigger('click') // reopen + await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click') // 3rd click = new start + expect(wrapper.emitted('update:modelValue')).toHaveLength(1) // still only the first range + await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}]) + }) + + it('previews the range on hover while selecting', async () => { + const wrapper = mountRange() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') // start + await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') // hover + const between = wrapper.get('[data-test="day"][data-iso="2026-05-20"]') + expect(between.attributes('data-range-role')).toBe('in-range') + }) + + it('does not preview before selecting', async () => { + const wrapper = mountRange() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') + const day = wrapper.get('[data-test="day"][data-iso="2026-05-20"]') + expect(day.attributes('data-range-role')).toBe('none') + }) + + it('marks start, end and in-range roles for a committed range', async () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range') + }) + + it('cancels an in-progress selection on outside click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19']) // only start chosen + document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + // reopening: no pending start (role none for the previously clicked day) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none') + }) + + it('emits null on clear', async () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) + }) + + it('disables days outside min/max', async () => { + const wrapper = mountRange({min: '2026-05-10', max: '2026-05-20'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]') + expect((outside.element as HTMLButtonElement).disabled).toBe(true) + await outside.trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('sets aria-invalid on error', () => { + const wrapper = mountRange({error: 'Période requise'}) + expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true') + expect(wrapper.text()).toContain('Période requise') + }) + + it('does not open when disabled', async () => { + const wrapper = mountRange({disabled: true}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) +}) +``` + +- [ ] **Step 3 : Lancer, vérifier le succès** — `npx vitest run app/components/malio/date/DateRange.test.ts` → PASS (~18). Ajuster l'implémentation si besoin (tests = contrat). + +- [ ] **Step 4 : Lint + suite date complète** + +Run: `npx eslint app/components/malio/date/ && npx vitest run app/components/malio/date/` +Expected: 0 erreur lint ; tous verts (dateFormat, useMonthMatrix, useCalendarPopover, useCalendarView, dateRange, Date, DateRange). + +- [ ] **Step 5 : Commit** + +```bash +git add app/components/malio/date/DateRange.vue app/components/malio/date/DateRange.test.ts +git commit -m "feat : composant MalioDateRange (sélection période) (#MUI-33)" +``` + +--- + +## Task 7 : Story + playground + +**Files:** +- Create: `app/story/date/dateRange.story.vue` +- Create: `.playground/pages/composant/date/dateRange.vue` + +- [ ] **Step 1 : Créer la story** + +```vue + + + + +``` + +- [ ] **Step 2 : Créer la page playground** + +```vue + + + + +``` + +- [ ] **Step 3 : Vérification visuelle** — `npm run dev` → menu "Date" → page DateRange : sélection 2 clics, hover preview, demi-barres aux bornes, reset 3e clic, bornes. Et `npm run story:dev`. + +- [ ] **Step 4 : Lint** — `npx eslint app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue` → 0 erreur + +- [ ] **Step 5 : Commit** + +```bash +git add app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue +git commit -m "feat : story et page playground de MalioDateRange (#MUI-33)" +``` + +--- + +## Self-Review (effectuée à l'écriture) + +**Couverture spec :** shell `CalendarField` ✓ (T3), `useCalendarView` ✓ (T2), `dateRange.ts` ✓ (T1), `MonthGrid` range+hover+data-range-role+demi-barre ✓ (T5), refacto `Date` ✓ (T4, Date.test.ts vert), `DateRange` API + machine à états (1er/2e clic, auto-inversion, single-day, 3e clic reset, hover preview, close annule, clear) ✓ (T6), min/max ✓ (T5/T6), displayValue vide pendant sélection ✓ (T6), story+playground ✓ (T7). Reportés (2 mois, ajustement borne proche, clavier) : non planifiés, conforme. + +**Placeholders :** aucun ; tout le code est complet. + +**Cohérence des types :** `DateRangeValue {start,end}` défini en T1, réutilisé en T6. `DayRangeRole` T1 → T5. `resolveRangeBounds(start,end,preview)` / `dayRangeRole(iso,bounds)` cohérents T1↔T5. `useCalendarView(viewMode)` renvoie `{currentMonth,currentYear,goToPrev,goToNext,selectMonth,syncToIso}` T2 → consommés tels quels en T3. `CalendarField` props `displayValue`/`syncTo` + events `clear`/`close` + slot `{currentMonth,currentYear,close}` T3 → consommés en T4 (Date) et T6 (DateRange). `MonthGrid` mode plage activé par `rangeStart !== undefined` ; `Date` ne passe pas `rangeStart` (reste single) — cohérent T4↔T5. + +**Écart assumé :** `MonthGrid` gagne `data-range-role` (testabilité + debug), documenté dans la spec.