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,