diff --git a/app/components/malio/date/composables/dateWeek.test.ts b/app/components/malio/date/composables/dateWeek.test.ts new file mode 100644 index 0000000..0d52e5b --- /dev/null +++ b/app/components/malio/date/composables/dateWeek.test.ts @@ -0,0 +1,74 @@ +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('') + }) + }) +}) diff --git a/app/components/malio/date/composables/dateWeek.ts b/app/components/malio/date/composables/dateWeek.ts new file mode 100644 index 0000000..ba17ed0 --- /dev/null +++ b/app/components/malio/date/composables/dateWeek.ts @@ -0,0 +1,67 @@ +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})` +}