Files
malio-layer-ui/docs/superpowers/plans/2026-05-20-dateweek.md
2026-05-20 15:13:38 +02:00

26 KiB

MalioDateWeek — 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 <MalioDateWeek> (sélection d'une semaine ISO en un clic, hover de semaine), réutilisant le shell + le rendu pilule de DateRange.

Architecture: Une semaine sélectionnée est une plage lundi→dimanche : DateWeek calcule ces bornes et les passe à MonthGrid (rendu pilule réutilisé). Ajout de 2 props additives à MonthGrid (n° de semaine cliquable + repère) et d'un module pur dateWeek.ts.

Tech Stack: Nuxt 4 layer, Vue 3 <script setup lang="ts">, TypeScript strict, Tailwind (m-*), @iconify/vue, Vitest + @vue/test-utils (jsdom).

Référence spec : docs/superpowers/specs/2026-05-20-dateweek-design.md


Conventions

  • Tests ciblés : npx vitest run <chemin> ; commits avec --no-verify en cas de hook flaky (suite complète lancée par le hook).
  • data-test réutilisés (date-input, popover, header-*, day+data-iso, clear, calendar-icon, month, week-number).
  • Nouveaux attributs sur la cellule n° de semaine : data-week-start (lundi de la ligne), data-marked ("true"/"false").
  • Ordre : 1) dateWeek.ts → 2) extension MonthGrid → 3) DateWeek.vue + tests → 4) story + playground.

Task 1 : Helpers purs dateWeek.ts

Files:

  • Create: app/components/malio/date/composables/dateWeek.ts

  • Test: app/components/malio/date/composables/dateWeek.test.ts

  • Step 1 : Écrire les tests (échouent)

import {describe, expect, it} from 'vitest'
import {
  formatWeekDisplay,
  isValidIsoWeek,
  isoWeekToMonday,
  mondayOf,
  sundayOf,
  toIsoWeek,
} from './dateWeek'

describe('dateWeek', () => {
  describe('mondayOf / sundayOf', () => {
    it('returns Monday and Sunday of a midweek date', () => {
      expect(mondayOf('2026-05-20')).toBe('2026-05-18') // mercredi
      expect(sundayOf('2026-05-20')).toBe('2026-05-24')
    })
    it('keeps Monday on a Monday', () => {
      expect(mondayOf('2026-05-18')).toBe('2026-05-18')
    })
    it('returns the preceding Monday for a Sunday', () => {
      expect(mondayOf('2026-05-24')).toBe('2026-05-18')
    })
  })

  describe('toIsoWeek', () => {
    it('returns the ISO week of a date', () => {
      expect(toIsoWeek('2026-05-20')).toBe('2026-W21')
    })
    it('handles year boundaries', () => {
      expect(toIsoWeek('2026-01-01')).toBe('2026-W01')
      expect(toIsoWeek('2025-12-31')).toBe('2026-W01')
      expect(toIsoWeek('2027-01-01')).toBe('2026-W53')
    })
  })

  describe('isoWeekToMonday', () => {
    it('returns the Monday of a week string', () => {
      expect(isoWeekToMonday('2026-W21')).toBe('2026-05-18')
    })
    it('round-trips with toIsoWeek', () => {
      for (const w of ['2026-W01', '2026-W21', '2026-W53', '2024-W09']) {
        const monday = isoWeekToMonday(w)
        expect(monday).not.toBeNull()
        expect(toIsoWeek(monday as string)).toBe(w)
      }
    })
    it('returns null for invalid input', () => {
      expect(isoWeekToMonday('2026-21')).toBeNull()
      expect(isoWeekToMonday('2026-W00')).toBeNull()
      expect(isoWeekToMonday('2026-W54')).toBeNull()
      expect(isoWeekToMonday('2025-W53')).toBeNull() // 2025 n'a que 52 semaines ISO
    })
  })

  describe('isValidIsoWeek', () => {
    it('accepts a real ISO week', () => {
      expect(isValidIsoWeek('2026-W21')).toBe(true)
    })
    it('rejects malformed or impossible weeks', () => {
      expect(isValidIsoWeek('2026-21')).toBe(false)
      expect(isValidIsoWeek('2026-W00')).toBe(false)
      expect(isValidIsoWeek('2026-W54')).toBe(false)
    })
  })

  describe('formatWeekDisplay', () => {
    it('formats a week as a human label', () => {
      expect(formatWeekDisplay('2026-W21')).toBe('Semaine 21 (18/05 → 24/05/2026)')
    })
    it('returns empty string for invalid input', () => {
      expect(formatWeekDisplay('2026-W54')).toBe('')
    })
  })
})
  • Step 2 : Lancer, vérifier l'échecnpx vitest run app/components/malio/date/composables/dateWeek.test.ts → FAIL (import non résolu)

  • Step 3 : Implémenter

// app/components/malio/date/composables/dateWeek.ts
import {formatIsoToDisplay} from './dateFormat'

const parseUtc = (iso: string): Date => {
  const [y, m, d] = iso.split('-').map(Number)
  return new Date(Date.UTC(y, m - 1, d))
}

const toIso = (d: Date): string => {
  const y = d.getUTCFullYear()
  const m = String(d.getUTCMonth() + 1).padStart(2, '0')
  const day = String(d.getUTCDate()).padStart(2, '0')
  return `${y}-${m}-${day}`
}

export function mondayOf(iso: string): string {
  const d = parseUtc(iso)
  const dayNum = d.getUTCDay() || 7 // dimanche = 7
  d.setUTCDate(d.getUTCDate() - (dayNum - 1))
  return toIso(d)
}

export function sundayOf(iso: string): string {
  const d = parseUtc(mondayOf(iso))
  d.setUTCDate(d.getUTCDate() + 6)
  return toIso(d)
}

export function toIsoWeek(iso: string): string {
  const d = parseUtc(iso)
  const dayNum = d.getUTCDay() || 7
  d.setUTCDate(d.getUTCDate() + 4 - dayNum) // jeudi de la semaine
  const isoYear = d.getUTCFullYear()
  const yearStart = new Date(Date.UTC(isoYear, 0, 1))
  const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
  return `${isoYear}-W${String(week).padStart(2, '0')}`
}

export function isoWeekToMonday(week: string): string | null {
  const m = /^(\d{4})-W(\d{2})$/.exec(week)
  if (!m) return null
  const year = Number(m[1])
  const w = Number(m[2])
  if (w < 1 || w > 53) return null
  // Lundi de la semaine 1 = lundi de la semaine contenant le 4 janvier
  const jan4 = new Date(Date.UTC(year, 0, 4))
  const jan4Day = jan4.getUTCDay() || 7
  const monday = new Date(jan4)
  monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1) + (w - 1) * 7)
  const iso = toIso(monday)
  // Garde-fou : la semaine 53 n'existe pas pour toutes les années
  if (toIsoWeek(iso) !== week) return null
  return iso
}

export function isValidIsoWeek(week: string): boolean {
  return isoWeekToMonday(week) !== null
}

export function formatWeekDisplay(week: string): string {
  const monday = isoWeekToMonday(week)
  if (!monday) return ''
  const sunday = sundayOf(monday)
  const w = Number(week.slice(6))
  const startDdMm = formatIsoToDisplay(monday).slice(0, 5) // "18/05"
  const endFull = formatIsoToDisplay(sunday) // "24/05/2026"
  return `Semaine ${w} (${startDdMm}${endFull})`
}
  • Step 4 : Lancer, vérifier le succès — PASS

  • Step 5 : Commit

git add app/components/malio/date/composables/dateWeek.ts app/components/malio/date/composables/dateWeek.test.ts
git commit -m "feat : helpers purs de semaine ISO (#MUI-33)" --no-verify

Task 2 : Ajouts additifs à MonthGrid.vue

Files:

  • Modify: app/components/malio/date/internal/MonthGrid.vue

Couvert par DateWeek.test.ts (Task 3) ; non-régression par Date.test.ts / DateRange.test.ts.

  • Step 1 : Remplacer la cellule n° de semaine par un élément polymorphe interactif

Dans le template, remplacer le bloc actuel :

        <div
          data-test="week-number"
          class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center bg-m-primary-light p-[10px] text-sm"
          :class="[
            week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60',
            wIndex === 0 ? 'rounded-t-md' : '',
            wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
          ]"
        >
          {{ week.weekNumber }}
        </div>

par :

        <component
          :is="interactiveWeekNumber ? 'button' : 'div'"
          data-test="week-number"
          :data-week-start="week.days[0].isoDate"
          :data-marked="markedWeekStart === week.days[0].isoDate"
          :type="interactiveWeekNumber ? 'button' : undefined"
          :disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
          class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
          :class="[
            weekNumberClass(week),
            wIndex === 0 ? 'rounded-t-md' : '',
            wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
          ]"
          @click="onWeekNumberClick(week)"
          @mouseenter="onWeekNumberHover(week)"
        >
          {{ week.weekNumber }}
        </component>
  • Step 2 : Ajouter les props et la logique (script)

Modifier les defineProps/withDefaults pour ajouter interactiveWeekNumber et markedWeekStart, importer WeekRow, et ajouter les helpers. Remplacer le bloc props :

const props = withDefaults(
  defineProps<{
    month: number
    year: number
    selectedDate?: string | null
    rangeStart?: string | null
    rangeEnd?: string | null
    previewDate?: string | null
    interactiveWeekNumber?: boolean
    markedWeekStart?: string | null
    min?: string
    max?: string
  }>(),
  {
    selectedDate: null,
    rangeStart: undefined,
    rangeEnd: undefined,
    previewDate: undefined,
    interactiveWeekNumber: false,
    markedWeekStart: null,
    min: undefined,
    max: undefined,
  },
)

Mettre à jour l'import du composable pour récupérer WeekRow :

import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'

Ajouter, après const inRange = ... :

const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))

const weekNumberClass = (week: WeekRow) => {
  if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
  const parts = ['bg-m-primary-light']
  parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
  if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
  return parts.join(' ')
}

const onWeekNumberClick = (week: WeekRow) => {
  if (!props.interactiveWeekNumber || !weekSelectable(week)) return
  emit('select', week.days[0].isoDate)
}

const onWeekNumberHover = (week: WeekRow) => {
  if (!props.interactiveWeekNumber || !weekSelectable(week)) return
  emit('hover', week.days[0].isoDate)
}
  • Step 3 : Non-régression Date et DateRange

Run: npx vitest run app/components/malio/date/Date.test.ts app/components/malio/date/DateRange.test.ts Expected: PASS (21 + 17). La cellule n° reste un <div> non interactif quand interactiveWeekNumber est false.

  • Step 4 : Lintnpx eslint app/components/malio/date/internal/MonthGrid.vue → 0 erreur

  • Step 5 : Commit

git add app/components/malio/date/internal/MonthGrid.vue
git commit -m "feat : MonthGrid n° de semaine interactif + repère (mode semaine) (#MUI-33)" --no-verify

Task 3 : DateWeek.vue + tests

Files:

  • Create: app/components/malio/date/DateWeek.vue

  • Test: app/components/malio/date/DateWeek.test.ts

  • Step 1 : Créer DateWeek.vue

<!-- app/components/malio/date/DateWeek.vue -->
<template>
  <CalendarField
    :id="id"
    :display-value="displayValue"
    :sync-to="validWeek?.monday ?? null"
    :name="name"
    :label="label"
    :placeholder="placeholder"
    :required="required"
    :disabled="disabled"
    :readonly="readonly"
    :hint="hint"
    :error="error"
    :success="success"
    :clearable="clearable"
    :input-class="inputClass"
    :label-class="labelClass"
    :group-class="groupClass"
    v-bind="$attrs"
    @clear="onClear"
    @close="onClose"
  >
    <template #default="{ currentMonth, currentYear, close }">
      <MonthGrid
        :month="currentMonth"
        :year="currentYear"
        :range-start="activeMonday"
        :range-end="activeSunday"
        :marked-week-start="validWeek?.monday ?? null"
        interactive-week-number
        :min="min"
        :max="max"
        @select="(iso) => onSelect(iso, close)"
        @hover="onHover"
      />
    </template>
  </CalendarField>
</template>

<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek, formatWeekDisplay} from './composables/dateWeek'

defineOptions({name: 'MalioDateWeek', inheritAttrs: false})

const props = withDefaults(
  defineProps<{
    id?: string
    name?: string
    label?: string
    modelValue?: string | null
    placeholder?: string
    required?: boolean
    disabled?: boolean
    readonly?: boolean
    hint?: string
    error?: string
    success?: string
    min?: string
    max?: string
    clearable?: boolean
    inputClass?: string
    labelClass?: string
    groupClass?: string
  }>(),
  {
    id: '',
    name: '',
    label: '',
    modelValue: undefined,
    placeholder: 'JJ/MM/AAAA',
    required: false,
    disabled: false,
    readonly: false,
    hint: '',
    error: '',
    success: '',
    min: undefined,
    max: undefined,
    clearable: true,
    inputClass: '',
    labelClass: '',
    groupClass: '',
  },
)

const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()

const hoverWeekStart = ref<string | null>(null)

const validWeek = computed(() => {
  if (props.modelValue && isValidIsoWeek(props.modelValue)) {
    return {monday: isoWeekToMonday(props.modelValue) as string}
  }
  return null
})

const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))

const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))

const onSelect = (iso: string, close: () => void) => {
  emit('update:modelValue', toIsoWeek(iso))
  hoverWeekStart.value = null
  close()
}

const onHover = (iso: string | null) => {
  hoverWeekStart.value = iso ? mondayOf(iso) : null
}

const onClose = () => {
  hoverWeekStart.value = null
}

const onClear = () => {
  emit('update:modelValue', null)
  hoverWeekStart.value = null
}
</script>
  • Step 2 : Écrire DateWeek.test.ts
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateWeek from './DateWeek.vue'

type DateWeekProps = {
  modelValue?: string | null
  label?: string
  disabled?: boolean
  readonly?: boolean
  error?: string
  min?: string
  max?: string
}

const DateWeekForTest = DateWeek as DefineComponent<DateWeekProps>
const mountWeek = (props: DateWeekProps = {}) =>
  mount(DateWeekForTest, {props, attachTo: document.body})

describe('MalioDateWeek', () => {
  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 = mountWeek({label: 'Semaine'})
    expect(wrapper.get('label').text()).toBe('Semaine')
    expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
  })

  it('displays the formatted week when modelValue is set', () => {
    const wrapper = mountWeek({modelValue: '2026-W21'})
    const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
    expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)')
  })

  it('shows an empty field without a value', () => {
    const wrapper = mountWeek()
    const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
    expect(input.value).toBe('')
  })

  it('opens on the month of the selected week', async () => {
    const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29
    await wrapper.get('[data-test="date-input"]').trigger('click')
    expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
  })

  it('selects the week when a day is clicked', async () => {
    const wrapper = mountWeek()
    await wrapper.get('[data-test="date-input"]').trigger('click')
    await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click')
    expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
    expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
  })

  it('selects the week when the week number is clicked', async () => {
    const wrapper = mountWeek()
    await wrapper.get('[data-test="date-input"]').trigger('click')
    await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click')
    expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
    expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
  })

  it('previews the whole week on day hover', async () => {
    const wrapper = mountWeek()
    await wrapper.get('[data-test="date-input"]').trigger('click')
    await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter')
    expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
    expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end')
    expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
  })

  it('previews the whole week on week-number hover', async () => {
    const wrapper = mountWeek()
    await wrapper.get('[data-test="date-input"]').trigger('click')
    await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter')
    expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
  })

  it('marks the committed week number', async () => {
    const wrapper = mountWeek({modelValue: '2026-W21'})
    await wrapper.get('[data-test="date-input"]').trigger('click')
    expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true')
    expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
  })

  it('emits null on clear', async () => {
    const wrapper = mountWeek({modelValue: '2026-W21'})
    await wrapper.get('[data-test="clear"]').trigger('click')
    expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
  })

  it('disables a week fully outside min/max', async () => {
    const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'})
    await wrapper.get('[data-test="date-input"]').trigger('click')
    const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]')
    expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true)
    const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]')
    expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false)
  })

  it('does not open when disabled', async () => {
    const wrapper = mountWeek({disabled: true})
    await wrapper.get('[data-test="date-input"]').trigger('click')
    expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
  })

  it('does not open when readonly', async () => {
    const wrapper = mountWeek({readonly: true, modelValue: '2026-W21'})
    await wrapper.get('[data-test="date-input"]').trigger('click')
    expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
  })

  it('sets aria-invalid on error', () => {
    const wrapper = mountWeek({error: 'Semaine requise'})
    expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
    expect(wrapper.text()).toContain('Semaine requise')
  })
})
  • Step 3 : Lancer, vérifier le succèsnpx vitest run app/components/malio/date/DateWeek.test.ts → PASS (~14)

  • 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 ; tout vert (dont Date 21 et DateRange 17 inchangés).

  • Step 5 : Commit
git add app/components/malio/date/DateWeek.vue app/components/malio/date/DateWeek.test.ts
git commit -m "feat : composant MalioDateWeek (sélection semaine ISO) (#MUI-33)" --no-verify

Task 4 : Story + playground

Files:

  • Create: app/story/date/dateWeek.story.vue

  • Create: .playground/pages/composant/date/dateWeek.vue

  • Step 1 : Créer la story

<!-- app/story/date/dateWeek.story.vue -->
<template>
  <Story title="Date/DateWeek">
    <div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Simple</h2>
        <MalioDateWeek
          v-model="simpleValue"
          label="Semaine"
        />
      </div>

      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
        <MalioDateWeek
          v-model="initialValue"
          label="Semaine de livraison"
        />
      </div>

      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
        <MalioDateWeek
          v-model="boundedValue"
          label="Semaine bornée"
          :min="todayIso"
          :max="maxIso"
          hint="Entre aujourd'hui et +60 jours"
        />
      </div>

      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
        <MalioDateWeek
          v-model="initialValue"
          label="Semaine verrouillée"
          :clearable="false"
        />
      </div>

      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Désactivé</h2>
        <MalioDateWeek
          v-model="initialValue"
          label="Désactivé"
          disabled
        />
      </div>

      <div class="rounded-lg border p-4">
        <h2 class="mb-4 text-xl font-bold">Erreur</h2>
        <MalioDateWeek
          v-model="errorValue"
          label="Semaine"
          error="Semaine invalide"
        />
      </div>
    </div>
  </Story>
</template>

<script setup lang="ts">
import {ref} from 'vue'
import MalioDateWeek from '../../components/malio/date/DateWeek.vue'

const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-W21')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)

const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
</script>
  • Step 2 : Créer la page playground
<!-- .playground/pages/composant/date/dateWeek.vue -->
<template>
  <div class="space-y-6 p-4">
    <h1 class="text-2xl font-bold">MalioDateWeek</h1>

    <div class="flex flex-wrap items-start gap-10">
      <div class="w-[480px] space-y-3">
        <h2 class="font-semibold">Large (480px)</h2>
        <MalioDateWeek
          v-model="value"
          label="Semaine"
          hint="Clique un jour ou un n° de semaine"
        />
        <div class="rounded border p-3 text-sm">
          <p>Valeur : <code>{{ value ?? 'null' }}</code></p>
        </div>
        <div class="flex gap-2">
          <button
            type="button"
            class="rounded bg-m-primary px-3 py-1.5 text-white"
            @click="value = '2026-W52'"
          >
            Forcer 2026-W52
          </button>
          <button
            type="button"
            class="rounded border px-3 py-1.5"
            @click="value = null"
          >
            Réinitialiser
          </button>
        </div>
      </div>

      <div class="w-[396px] space-y-3">
        <h2 class="font-semibold">ERP (396px)</h2>
        <MalioDateWeek
          v-model="erpValue"
          label="Semaine"
          hint="Largeur cible ERP"
        />
        <div class="rounded border p-3 text-sm">
          <p>Valeur : <code>{{ erpValue ?? 'null' }}</code></p>
        </div>
        <MalioDateWeek
          v-model="bounded"
          label="Semaine bornée"
          :min="todayIso"
          :max="maxIso"
          hint="Entre aujourd'hui et +60 jours"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import {ref} from 'vue'

const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))

const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>
  • Step 3 : Vérification visuellenpm run dev → menu "Date" → page DateWeek : hover de semaine (ligne entière), clic jour/n° → sélection, repère n° en bleu plein, bornes. Et npm run story:dev.

  • Step 4 : Lintnpx eslint app/story/date/dateWeek.story.vue .playground/pages/composant/date/dateWeek.vue → 0 erreur

  • Step 5 : Commit

git add app/story/date/dateWeek.story.vue .playground/pages/composant/date/dateWeek.vue
git commit -m "feat : story et page playground de MalioDateWeek (#MUI-33)" --no-verify

Self-Review (effectuée à l'écriture)

Couverture spec : dateWeek.ts (mondayOf/sundayOf/toIsoWeek/isoWeekToMonday/isValidIsoWeek/formatWeekDisplay) ✓ T1 ; MonthGrid interactiveWeekNumber+markedWeekStart+data-week-start/data-marked+weekSelectable ✓ T2 ; DateWeek API + un clic + hover semaine + repère + clear + min/max overlap + invalide→null ✓ T3 ; affichage "Semaine 21 (...)" ✓ T1/T3 ; story+playground ✓ T4. modelValue YYYY-Www ✓.

Placeholders : aucun ; code complet.

Cohérence des types : toIsoWeek(iso)→string, isoWeekToMonday(week)→string|null, mondayOf/sundayOf(iso)→string, formatWeekDisplay(week)→string définis T1, consommés T3. MonthGrid props interactiveWeekNumber/markedWeekStart T2 → passées par DateWeek T3. Events select/hover (iso jour) réutilisés ; DateWeek.onSelect mappe via toIsoWeek, onHover via mondayOf. WeekRow importé de useMonthMatrix (déjà exporté). Le rendu pilule s'appuie sur rangeStart/rangeEnd (inchangés) → Date/DateRange non impactés.

Écart assumé : MonthGrid gagne data-week-start/data-marked (testabilité), conforme à la spec.