From d984e094733bd05af14bb4190aaf6022ce92bbdc Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 25 Jul 2026 11:50:37 +0200 Subject: [PATCH] feat(vacay): pick a leave year and roll the grid over with it (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar, fiscal (month + day) or from the hire date, per user — it sits with the plan settings but belongs to the person, so fused members each keep their own and the grid follows whoever is looking at it. Both grids render twelve months from the window start with year rollover instead of a fixed Jan–Dec, and the mobile month navigation moves through slots of that window, so stepping past December lands in January of the same period rather than jumping back to the start of the calendar year. The month effect keys on the window's shape rather than the settings object, which the store replaces with an equal copy on every refresh — otherwise any sync would throw away the month the user had open. Known edge for a start day past the 1st: the cards are month-aligned, so the first days of the start month are drawn but count toward the previous period. Counting stays day-exact. --- client/src/components/Vacay/VacayCalendar.tsx | 47 ++++--- client/src/components/Vacay/VacaySettings.tsx | 121 +++++++++++++++++- client/src/mobile/screens/vacay/MVacay.tsx | 50 +++++--- .../screens/vacay/MVacaySettingsSheet.tsx | 114 ++++++++++++++++- client/src/mobile/screens/vacay/useMVacay.ts | 55 ++++++-- 5 files changed, 334 insertions(+), 53 deletions(-) diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index 2938cf066..d3c25d57f 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -2,10 +2,11 @@ import { useMemo, useState, useCallback, useEffect } from 'react' import { useVacayStore } from '../../store/vacayStore' import { useTranslation } from '../../i18n' import { isWeekend } from './holidays' +import { inGridWindow, windowMonths } from '../../vacay/yearWindow' import { tripsApi } from '../../api/client' import VacayMonthCard from './VacayMonthCard' import type { VacayEntry } from '../../types' -import { Building2, MousePointer2, Clock } from 'lucide-react' +import { Building2, MousePointer2 } from 'lucide-react' type VacayMode = 'vacation' | 'company' type HoverTip = { date: string; top: number; left: number } @@ -13,7 +14,7 @@ export type SharedDayMark = { color: string; name: string; fraction?: number; co export default function VacayCalendar() { const { t, locale } = useTranslation() - const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays, sharedCalendars } = useVacayStore() + const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays, sharedCalendars, yearSettings } = useVacayStore() const [mode, setMode] = useState('vacation') // Half-day is a per-person modifier on the vacation action, not a mode: with it // on, clicking a day logs (or converts) it as a 0.5 day for the selected person. @@ -36,17 +37,17 @@ export default function VacayCalendar() { const start = new Date(trip.start_date + 'T00:00:00') const end = new Date(trip.end_date + 'T00:00:00') for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { - const y = d.getFullYear() - if (y === selectedYear) { - dates.add(`${y}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`) - } + // Keep the days the grid shows, which is the leave-year window (#737) + // rather than the calendar year once the window is shifted. + const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + if (inGridWindow(date, selectedYear, yearSettings)) dates.add(date) } } if (!cancelled) setTripDates(dates) } catch { /* ignore */ } })() return () => { cancelled = true } - }, [selectedYear]) + }, [selectedYear, yearSettings]) const companyHolidaySet = useMemo(() => { const s = new Set() @@ -106,6 +107,9 @@ export default function VacayCalendar() { }, []) const selectedUser = users.find(u => u.id === selectedUserId) + // The toolbar modifiers preview what a click will put on the day, so the comp + // hatch carries the selected person's colour just like the grid segment does. + const markerColor = selectedUser?.color || '#6366f1' const tipEntries = tip ? entryMap[tip.date] : undefined const tipShared = tip ? sharedMap[tip.date] : undefined const tipHolidayRaw = tip ? holidays[tip.date] : undefined @@ -122,11 +126,13 @@ export default function VacayCalendar() { return (
- {Array.from({ length: 12 }, (_, i) => ( + {/* Twelve months from the window start (#737), rolling over the calendar + year when the leave year is shifted — Jul 2026 – Jun 2027 and so on. */} + {windowMonths(selectedYear, yearSettings).map(({ year, month }) => ( - + {/* A hatched disc in the person's colour — the same diagonal fill a comp + day gets in the grid (#1074), so the toolbar previews the marker. */} + {t('vacay.modeComp')} @@ -246,12 +259,10 @@ export default function VacayCalendar() { style={halfDay ? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' } : { background: 'transparent', color: 'var(--vg-ink3)' }}> - ½ + {/* The same orange dot a half day carries in the grid (#552) — the + toolbar showing the marker you are about to place reads quicker + than a ½ glyph that appears nowhere on the calendar. */} + {t('vacay.modeHalf')}
diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx index b45cb2a67..645ceded2 100644 --- a/client/src/components/Vacay/VacaySettings.tsx +++ b/client/src/components/Vacay/VacaySettings.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays, GraduationCap } from 'lucide-react' +import { useState, useEffect, useMemo } from 'react' +import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays, CalendarRange, GraduationCap } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' import { getIntlLanguage, useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' @@ -7,7 +7,8 @@ import CustomSelect from '../shared/CustomSelect' import apiClient from '../../api/client' import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions' import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' -import type { VacayHolidayCalendar } from '../../types' +import { windowMonths } from '../../vacay/yearWindow' +import type { VacayHolidayCalendar, VacayYearSettings } from '../../types' interface VacaySettingsProps { onClose: () => void @@ -133,6 +134,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { value={plan.carry_over_enabled} onChange={() => toggle('carry_over_enabled')} /> + + {/* Vacation year (#737) — personal, unlike the plan settings above it */} +
{/* ── Column 2 · holidays ── */} @@ -292,6 +296,117 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { ) } +// ── Leave-year type (#737) ──────────────────────────────────────────────────── +/** + * Per-user leave year: calendar (Jan–Dec), fiscal (a fixed month/day) or + * anniversary (the hire date's month/day). This is the one setting in this panel + * that belongs to the person rather than the plan, so fused members can each keep + * their own — the grid then follows whoever is looking at it. + */ +function YearTypePicker() { + const { t, locale } = useTranslation() + const { yearSettings, updateYearSettings, selectedYear } = useVacayStore() + const type = yearSettings.year_type + + const monthNames = useMemo(() => { + const fmt = new Intl.DateTimeFormat(locale, { month: 'long' }) + return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2026, i, 1))) + }, [locale]) + + // February caps at 28 so a window start never lands on a date some years lack. + const maxDay = daysInMonth(yearSettings.year_start_month) + + const save = (patch: Partial) => { + const next = { ...yearSettings, ...patch } + updateYearSettings({ + year_type: next.year_type, + year_start_month: next.year_start_month, + year_start_day: Math.min(next.year_start_day, daysInMonth(next.year_start_month)), + hire_date: next.hire_date, + }) + } + + const months = windowMonths(selectedYear, yearSettings) + const shortMonth = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' }) + const windowLabel = `${shortMonth.format(new Date(months[0].year, months[0].month, 1))} – ${shortMonth.format(new Date(months[11].year, months[11].month, 1))}` + + return ( +
+
+ +
+ {t('vacay.yearType')} +

{t('vacay.yearTypeHint')}

+
+
+
+ {([ + { value: 'calendar', label: t('vacay.yearTypeCalendar') }, + { value: 'fiscal', label: t('vacay.yearTypeFiscal') }, + { value: 'anniversary', label: t('vacay.yearTypeAnniversary') }, + ] as const).map(({ value, label }) => ( + + ))} +
+ + {type === 'fiscal' && ( +
+
+ save({ year_start_month: parseInt(String(v), 10) })} + options={monthNames.map((label, i) => ({ value: String(i + 1), label }))} + placeholder={t('vacay.yearStartMonth')} + /> +
+
+ save({ year_start_day: parseInt(String(v), 10) })} + options={Array.from({ length: maxDay }, (_, i) => ({ value: String(i + 1), label: String(i + 1) }))} + placeholder={t('vacay.yearStartDay')} + /> +
+
+ )} + + {type === 'anniversary' && ( +
+ save({ hire_date: e.target.value || null })} + aria-label={t('vacay.hireDate')} + style={{ width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '8px 14px', borderRadius: 10, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} + /> + {!yearSettings.hire_date && ( +

{t('vacay.hireDateHint')}

+ )} +
+ )} + +

+ {t('vacay.yearWindow', { year: selectedYear, window: windowLabel })} +

+
+ ) +} + +/** Days a month can always offer — February caps at 28 so no window start is skipped in a common year. */ +function daysInMonth(month: number): number { + if (month === 2) return 28 + return [4, 6, 9, 11].includes(month) ? 30 : 31 +} + interface SettingToggleProps { icon: LucideIcon label: string diff --git a/client/src/mobile/screens/vacay/MVacay.tsx b/client/src/mobile/screens/vacay/MVacay.tsx index 907642ce3..ccff1b1e9 100644 --- a/client/src/mobile/screens/vacay/MVacay.tsx +++ b/client/src/mobile/screens/vacay/MVacay.tsx @@ -1,4 +1,4 @@ -import { Building2, ChevronLeft, ChevronRight, Clock, Eye, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react' +import { Building2, ChevronLeft, ChevronRight, Eye, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import MSheet from '../../components/MSheet' import MIconBtn from '../../components/MIconBtn' @@ -84,7 +84,13 @@ export default function MVacay() { {stat.carried_over > 0 && ( - +{stat.carried_over} {t('vacay.carriedOver', { year: v.selectedYear - 1 })} + +{stat.carried_over} {v.isShiftedYear ? t('vacay.carriedOverPrevPeriod') : t('vacay.carriedOver', { year: v.selectedYear - 1 })} + + )} + {/* Comp / flex days (#1074) sit outside used/left — they cost nothing. */} + {(stat.comp_used ?? 0) > 0 && ( + + {t('vacay.compUsedCount', { count: stat.comp_used })} )} @@ -166,12 +172,14 @@ export default function MVacay() { {/* Year grid */} {!edit && (
- {Array.from({ length: 12 }, (_, m) => ( -
-
{v.monthNamesShort[m]}
+ {/* Twelve months from the window start (#737) — Jan–Dec on a calendar + year, Jul–Jun once the leave year is shifted. */} + {v.months.map(({ year, month }, slot) => ( +
+
{v.monthNamesShort[slot]}
- {v.monthNamesShort.map((name, m) => ( + {v.monthNamesShort.map((name, slot) => (
- + {/* Hatched disc in the person's colour — the marker a comp day gets + in the grid (#1074), rather than an abstract clock. */} +
diff --git a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx index 47ef59cf2..6b4cc3a43 100644 --- a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx +++ b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { ArrowRightLeft, Building2, Calendar, CalendarX, ChevronDown, Globe, GraduationCap, Plus, Trash2, Unlink, X } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronDown, Globe, GraduationCap, Plus, Trash2, Unlink, X } from 'lucide-react' import MSheet from '../../components/MSheet' import MIconBtn from '../../components/MIconBtn' import MToggle from '../../components/MToggle' @@ -9,8 +9,9 @@ import { useToast } from '../../../components/shared/Toast' import apiClient from '../../../api/client' import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions' import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../../vacay/schoolHolidayCountries' +import { windowMonths } from '../../../vacay/yearWindow' import { FALLBACK_PERSON_COLOR } from './vacayDayModel' -import type { VacayHolidayCalendar } from '../../../types' +import type { VacayHolidayCalendar, VacayYearSettings } from '../../../types' const CALENDAR_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af'] @@ -153,6 +154,10 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
+ {/* Vacation year (#737) — personal, unlike the plan settings around it */} + +
+ {/* Company holidays */}
@@ -279,6 +284,109 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe ) } +/** + * Leave-year type (#737): calendar (Jan–Dec), fiscal (fixed month/day) or + * anniversary (the hire date's month/day). Unlike everything else in this sheet + * it is a per-user setting, so fused members each keep their own. + */ +function YearTypeRows() { + const { t, locale } = useTranslation() + const { yearSettings, updateYearSettings, selectedYear } = useVacayStore() + const type = yearSettings.year_type + + const monthOptions: Option[] = useMemo(() => { + const fmt = new Intl.DateTimeFormat(locale, { month: 'long' }) + return Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: fmt.format(new Date(2026, i, 1)) })) + }, [locale]) + + const save = (patch: Partial) => { + const next = { ...yearSettings, ...patch } + updateYearSettings({ + year_type: next.year_type, + year_start_month: next.year_start_month, + year_start_day: Math.min(next.year_start_day, monthDayCap(next.year_start_month)), + hire_date: next.hire_date, + }) + } + + const months = windowMonths(selectedYear, yearSettings) + const fmtShort = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' }) + const windowLabel = `${fmtShort.format(new Date(months[0].year, months[0].month, 1))} – ${fmtShort.format(new Date(months[11].year, months[11].month, 1))}` + + return ( + <> +
+ +
+
{t('vacay.yearType')}
+
{t('vacay.yearTypeHint')}
+
+
+
+ {([ + { value: 'calendar', label: t('vacay.yearTypeCalendar') }, + { value: 'fiscal', label: t('vacay.yearTypeFiscal') }, + { value: 'anniversary', label: t('vacay.yearTypeAnniversary') }, + ] as const).map(({ value, label }) => ( + + ))} +
+ + {type === 'fiscal' && ( +
+ + save({ year_start_month: parseInt(v, 10) })} + /> + + + ({ value: String(i + 1), label: String(i + 1) }))} + onChange={v => save({ year_start_day: parseInt(v, 10) })} + /> + +
+ )} + + {type === 'anniversary' && ( +
+ save({ hire_date: e.target.value || null })} + aria-label={t('vacay.hireDate')} + className="mt-[7px] w-full rounded-[10px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] px-[11px] py-[9px] font-[inherit] text-[0.78125rem] font-semibold text-m-ink outline-none" + /> +
+ )} + +
+ {t('vacay.yearWindow', { year: selectedYear, window: windowLabel })} +
+ + ) +} + +/** Days a month always has — February caps at 28 so a start date never goes missing in a common year. */ +function monthDayCap(month: number): number { + if (month === 2) return 28 + return [4, 6, 9, 11].includes(month) ? 30 : 31 +} + /** Styled native select in the sheet's row look (label + chevron). */ function SelectRow({ value, placeholder, options, onChange }: { value: string diff --git a/client/src/mobile/screens/vacay/useMVacay.ts b/client/src/mobile/screens/vacay/useMVacay.ts index 07b980385..f88c97562 100644 --- a/client/src/mobile/screens/vacay/useMVacay.ts +++ b/client/src/mobile/screens/vacay/useMVacay.ts @@ -7,6 +7,7 @@ import { useTranslation } from '../../../i18n' import { useToast } from '../../../components/shared/Toast' import { tripsApi } from '../../../api/client' import { isWeekend } from '../../../components/Vacay/holidays' +import { currentPeriodYear, inGridWindow, windowMonths } from '../../../vacay/yearWindow' import { FALLBACK_PERSON_COLOR, localDateStr, type DayVisualContext } from './vacayDayModel' import { getApiErrorMessage, type Trip } from '../../../types' @@ -33,12 +34,14 @@ export function useMVacay() { entries, companyHolidays, stats, users, holidays, selectedUserId, setSelectedUserId, isFused, toggleEntry, toggleCompanyHoliday, updateVacationDays, - incomingShares, sharedCalendars, setShareHidden, + incomingShares, sharedCalendars, setShareHidden, yearSettings, } = useVacayStore() const currentUser = useAuthStore(s => s.user) const [view, setView] = useState('grid') - const [month, setMonth] = useState(() => new Date().getMonth()) + // Index into the window's twelve months (#737), not a calendar month — with a + // fiscal year starting in July, slot 0 is July and slot 11 is the following June. + const [monthSlot, setMonthSlot] = useState(0) const [mode, setMode] = useState('vacation') // Half-day modifier: when on, taps log the selected person's day as 0.5 (#552). const [halfDay, setHalfDay] = useState(false) @@ -47,6 +50,11 @@ export function useMVacay() { const [sheet, setSheet] = useState(null) const [tripDates, setTripDates] = useState>(new Set()) + // The leave-year window's shape as a primitive. loadAll() hands back a fresh + // (deep-equal) settings object every refresh, so effects key on this instead of + // the object and stop re-firing when nothing about the window actually changed. + const windowShape = `${yearSettings.year_type}|${yearSettings.year_start_month}|${yearSettings.year_start_day}|${yearSettings.hire_date}` + // Default the active person to the current user (same as the persons panel). useEffect(() => { if (!selectedUserId && currentUser) setSelectedUserId(currentUser.id) @@ -64,16 +72,18 @@ export function useMVacay() { const start = new Date(trip.start_date + 'T00:00:00') const end = new Date(trip.end_date + 'T00:00:00') for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { - if (d.getFullYear() === selectedYear) { - dates.add(localDateStr(d.getFullYear(), d.getMonth(), d.getDate())) - } + // The grid covers the leave-year window (#737), which is only the + // calendar year while the window is unshifted. + const date = localDateStr(d.getFullYear(), d.getMonth(), d.getDate()) + if (inGridWindow(date, selectedYear, yearSettings)) dates.add(date) } } if (!cancelled) setTripDates(dates) } catch { /* ignore */ } })() return () => { cancelled = true } - }, [selectedYear]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedYear, windowShape]) const blockWeekends = plan?.block_weekends !== false const companyHolidaysEnabled = plan?.company_holidays_enabled !== false @@ -118,14 +128,30 @@ export function useMVacay() { todayStr, entryMap, companyHolidaySet, companyHolidaysEnabled, holidays, weekendDays, sharedMap, }), [todayStr, entryMap, companyHolidaySet, companyHolidaysEnabled, holidays, weekendDays, sharedMap]) + // The twelve months the window spans, in display order — Jan–Dec for a calendar + // year, Jul–Jun for a fiscal one starting in July (#737). + const months = useMemo(() => windowMonths(selectedYear, yearSettings), [selectedYear, yearSettings]) + const activeMonth = months[monthSlot] ?? months[0] + + // Land on the month the user is actually in. Keyed on the window *shape*, not on + // the settings object: loadAll() replaces that object with a deep-equal copy on + // every refresh, and re-running here would throw away the month the user picked. + useEffect(() => { + const now = new Date() + const idx = windowMonths(currentPeriodYear(yearSettings, now), yearSettings) + .findIndex(m => m.year === now.getFullYear() && m.month === now.getMonth()) + setMonthSlot(idx >= 0 ? idx : 0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowShape]) + const monthNamesShort = useMemo(() => { const fmt = new Intl.DateTimeFormat(locale, { month: 'short' }) - return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2026, i, 1))) - }, [locale]) + return months.map(({ year, month }) => fmt.format(new Date(year, month, 1))) + }, [locale, months]) const monthNameLong = useMemo( - () => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(selectedYear, month, 1)), - [locale, selectedYear, month], + () => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(activeMonth.year, activeMonth.month, 1)), + [locale, activeMonth], ) const selectedUser = users.find(u => u.id === selectedUserId) @@ -188,8 +214,10 @@ export function useMVacay() { else { await handleAddNextYear(); setSelectedYear(selectedYear + 1) } }, [years, selectedYear, setSelectedYear, handleAddNextYear]) - const prevMonth = useCallback(() => setMonth(m => (m + 11) % 12), []) - const nextMonth = useCallback(() => setMonth(m => (m + 1) % 12), []) + // Wrapping happens on the slot, so a shifted window rolls Dec → Jan inside the + // period instead of jumping back to January of the calendar year. + const prevMonth = useCallback(() => setMonthSlot(s => (s + 11) % 12), []) + const nextMonth = useCallback(() => setMonthSlot(s => (s + 1) % 12), []) const toggleView = useCallback(() => setView(v => (v === 'grid' ? 'edit' : 'grid')), []) const goBack = useCallback(() => navigate('/dashboard'), [navigate]) @@ -200,7 +228,8 @@ export function useMVacay() { users, isFused, currentUser, incomingInvites, acceptInvite, declineInvite, incomingShares, toggleShareHidden, - view, month, mode, halfDay, setHalfDay, compDay, setCompDay, sheet, setSheet, setMode, setMonth, + view, months, monthSlot, activeMonth, isShiftedYear: yearSettings.year_type !== 'calendar', + mode, halfDay, setHalfDay, compDay, setCompDay, sheet, setSheet, setMode, setMonthSlot, tripDates, tripDotColor, blockWeekends, companyHolidaysEnabled, holidaysEnabled, weekStart, weekendDays, dayCtx, monthNamesShort, monthNameLong,