781 lines
26 KiB
Markdown
781 lines
26 KiB
Markdown
# 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)**
|
|
|
|
```ts
|
|
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'échec** — `npx vitest run app/components/malio/date/composables/dateWeek.test.ts` → FAIL (import non résolu)
|
|
|
|
- [ ] **Step 3 : Implémenter**
|
|
|
|
```ts
|
|
// 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**
|
|
|
|
```bash
|
|
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 :
|
|
|
|
```vue
|
|
<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 :
|
|
|
|
```vue
|
|
<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 :
|
|
|
|
```ts
|
|
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` :
|
|
|
|
```ts
|
|
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
|
|
```
|
|
|
|
Ajouter, après `const inRange = ...` :
|
|
|
|
```ts
|
|
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 : Lint** — `npx eslint app/components/malio/date/internal/MonthGrid.vue` → 0 erreur
|
|
|
|
- [ ] **Step 5 : Commit**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```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`**
|
|
|
|
```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ès** — `npx 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<!-- 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**
|
|
|
|
```vue
|
|
<!-- .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 visuelle** — `npm 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 : Lint** — `npx eslint app/story/date/dateWeek.story.vue .playground/pages/composant/date/dateWeek.vue` → 0 erreur
|
|
|
|
- [ ] **Step 5 : Commit**
|
|
|
|
```bash
|
|
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.
|