feat(vacay): pick a leave year and roll the grid over with it (#737)

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.
This commit is contained in:
Maurice
2026-07-25 11:50:37 +02:00
committed by Maurice
parent e1c5daeccb
commit d984e09473
5 changed files with 334 additions and 53 deletions
+29 -18
View File
@@ -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<VacayMode>('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<string>()
@@ -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 (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-[18px]" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
{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 }) => (
<VacayMonthCard
key={i}
year={selectedYear}
month={i}
key={`${year}-${month}`}
year={year}
month={month}
holidays={holidays}
companyHolidaySet={companyHolidaySet}
companyHolidaysEnabled={companyHolidaysEnabled}
@@ -234,7 +240,14 @@ export default function VacayCalendar() {
style={compDay
? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' }
: { background: 'transparent', color: 'var(--vg-ink3)' }}>
<Clock size={13} />
{/* 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. */}
<span className="rounded-full shrink-0"
style={{
width: 12, height: 12,
background: `repeating-linear-gradient(45deg, ${markerColor} 0 2px, transparent 2px 4px)`,
boxShadow: `inset 0 0 0 1px ${markerColor}`,
}} aria-hidden />
{t('vacay.modeComp')}
</button>
@@ -246,12 +259,10 @@ export default function VacayCalendar() {
style={halfDay
? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' }
: { background: 'transparent', color: 'var(--vg-ink3)' }}>
<span className="flex items-center justify-center rounded-full shrink-0 transition-colors"
style={{
width: 15, height: 15, fontSize: 10, fontWeight: 800, lineHeight: 1,
background: halfDay ? 'var(--vg-bg)' : 'color-mix(in srgb, var(--vg-ink3) 22%, transparent)',
color: halfDay ? 'var(--vg-ink)' : 'var(--vg-ink2)',
}} aria-hidden>½</span>
{/* 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. */}
<span className="rounded-full shrink-0 bg-[#f97316]" style={{ width: 12, height: 12 }} aria-hidden />
{t('vacay.modeHalf')}
</button>
</div>
+118 -3
View File
@@ -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 */}
<YearTypePicker />
</div>
{/* ── 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<VacayYearSettings>) => {
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 (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<CalendarRange size={16} className="text-content-muted" style={{ flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<span className="text-sm font-medium text-content">{t('vacay.yearType')}</span>
<p className="text-xs mt-0.5 text-content-faint">{t('vacay.yearTypeHint')}</p>
</div>
</div>
<div style={{ paddingLeft: 36, marginTop: 8 }} className="flex flex-wrap gap-1.5">
{([
{ value: 'calendar', label: t('vacay.yearTypeCalendar') },
{ value: 'fiscal', label: t('vacay.yearTypeFiscal') },
{ value: 'anniversary', label: t('vacay.yearTypeAnniversary') },
] as const).map(({ value, label }) => (
<button key={value} onClick={() => save({ year_type: value })}
style={{
padding: '4px 10px', borderRadius: 8, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
background: type === value ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: type === value ? 'var(--text-primary)' : 'var(--border-primary)',
color: type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{label}
</button>
))}
</div>
{type === 'fiscal' && (
<div style={{ paddingLeft: 36, marginTop: 8 }} className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<CustomSelect
value={String(yearSettings.year_start_month)}
onChange={v => save({ year_start_month: parseInt(String(v), 10) })}
options={monthNames.map((label, i) => ({ value: String(i + 1), label }))}
placeholder={t('vacay.yearStartMonth')}
/>
</div>
<div className="w-[92px] shrink-0">
<CustomSelect
value={String(yearSettings.year_start_day)}
onChange={v => 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')}
/>
</div>
</div>
)}
{type === 'anniversary' && (
<div style={{ paddingLeft: 36, marginTop: 8 }}>
<input
type="date"
value={yearSettings.hire_date || ''}
onChange={e => 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 && (
<p className="text-[11px] mt-1 text-content-faint">{t('vacay.hireDateHint')}</p>
)}
</div>
)}
<p className="text-[11px] mt-2 text-content-faint" style={{ paddingLeft: 36 }}>
{t('vacay.yearWindow', { year: selectedYear, window: windowLabel })}
</p>
</div>
)
}
/** 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
+34 -16
View File
@@ -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() {
</span>
{stat.carried_over > 0 && (
<span className="font-geist text-[0.5625rem] font-bold text-[color:var(--m-st-pending)]">
+{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 })}
</span>
)}
{/* Comp / flex days (#1074) sit outside used/left — they cost nothing. */}
{(stat.comp_used ?? 0) > 0 && (
<span className="font-geist text-[0.5625rem] font-bold text-m-muted">
{t('vacay.compUsedCount', { count: stat.comp_used })}
</span>
)}
<span className="font-geist text-[0.5625rem] font-extrabold" style={{ color: leftColor }}>
@@ -166,12 +172,14 @@ export default function MVacay() {
{/* Year grid */}
{!edit && (
<div className="grid grid-cols-2 gap-[10px]">
{Array.from({ length: 12 }, (_, m) => (
<div key={m} className="rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-2">
<div className="mb-1 text-[0.75rem] font-extrabold capitalize">{v.monthNamesShort[m]}</div>
{/* 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) => (
<div key={`${year}-${month}`} className="rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-2">
<div className="mb-1 text-[0.75rem] font-extrabold capitalize">{v.monthNamesShort[slot]}</div>
<MVacayMonth
year={v.selectedYear}
month={m}
year={year}
month={month}
variant="mini"
weekStart={v.weekStart}
ctx={v.dayCtx}
@@ -189,13 +197,13 @@ export default function MVacay() {
<>
<div className="mb-[10px] rounded-2xl border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-2">
<div className="grid grid-cols-6 gap-[5px]">
{v.monthNamesShort.map((name, m) => (
{v.monthNamesShort.map((name, slot) => (
<button
key={m}
key={slot}
type="button"
onClick={() => v.setMonth(m)}
onClick={() => v.setMonthSlot(slot)}
className={`whitespace-nowrap rounded-[11px] py-[7px] text-center text-[0.71875rem] font-bold capitalize ${
m === v.month
slot === v.monthSlot
? 'bg-m-act text-m-actfg'
: 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
}`}
@@ -221,8 +229,8 @@ export default function MVacay() {
))}
</div>
<MVacayMonth
year={v.selectedYear}
month={v.month}
year={v.activeMonth.year}
month={v.activeMonth.month}
variant="full"
weekStart={v.weekStart}
ctx={v.dayCtx}
@@ -273,7 +281,16 @@ export default function MVacay() {
v.compDay ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
<Clock size={16} strokeWidth={2.2} />
{/* Hatched disc in the person's colour — the marker a comp day gets
in the grid (#1074), rather than an abstract clock. */}
<span
className="rounded-full"
style={{
width: 15, height: 15,
background: `repeating-linear-gradient(45deg, ${v.selectedColor} 0 2px, transparent 2px 4px)`,
boxShadow: `inset 0 0 0 1.5px ${v.selectedColor}`,
}}
/>
</button>
<button
type="button"
@@ -281,11 +298,12 @@ export default function MVacay() {
aria-pressed={v.halfDay}
aria-label={t('vacay.modeHalf')}
title={t('vacay.modeHalfHint')}
className={`flex h-9 w-9 flex-none items-center justify-center rounded-full text-[0.9375rem] font-extrabold leading-none ${
className={`flex h-9 w-9 flex-none items-center justify-center rounded-full ${
v.halfDay ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
½
{/* The orange corner dot a half day carries in the grid (#552). */}
<span className="rounded-full bg-[#f97316]" style={{ width: 15, height: 15 }} />
</button>
</div>
</div>
@@ -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
</div>
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
{/* Vacation year (#737) — personal, unlike the plan settings around it */}
<YearTypeRows />
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
{/* Company holidays */}
<div className="flex items-start gap-[11px] py-3">
<Building2 size={17} strokeWidth={2} className="mt-[1px] flex-none text-m-muted" />
@@ -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<VacayYearSettings>) => {
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 (
<>
<div className="flex items-start gap-[11px] py-3">
<CalendarRange size={17} strokeWidth={2} className="mt-[1px] flex-none text-m-muted" />
<div className="flex-1">
<div className="text-[0.84375rem] font-bold">{t('vacay.yearType')}</div>
<div className="font-geist text-[0.65625rem] text-m-muted">{t('vacay.yearTypeHint')}</div>
</div>
</div>
<div className="ml-7 flex gap-1">
{([
{ value: 'calendar', label: t('vacay.yearTypeCalendar') },
{ value: 'fiscal', label: t('vacay.yearTypeFiscal') },
{ value: 'anniversary', label: t('vacay.yearTypeAnniversary') },
] as const).map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => save({ year_type: value })}
className={`flex-1 rounded-[10px] px-1 py-[7px] text-center text-[0.65625rem] font-semibold ${
type === value ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
}`}
>
{label}
</button>
))}
</div>
{type === 'fiscal' && (
<div className="ml-7 flex gap-2">
<span className="min-w-0 flex-1">
<SelectRow
value={String(yearSettings.year_start_month)}
placeholder={t('vacay.yearStartMonth')}
options={monthOptions}
onChange={v => save({ year_start_month: parseInt(v, 10) })}
/>
</span>
<span className="w-[86px] flex-none">
<SelectRow
value={String(yearSettings.year_start_day)}
placeholder={t('vacay.yearStartDay')}
options={Array.from({ length: monthDayCap(yearSettings.year_start_month) }, (_, i) => ({ value: String(i + 1), label: String(i + 1) }))}
onChange={v => save({ year_start_day: parseInt(v, 10) })}
/>
</span>
</div>
)}
{type === 'anniversary' && (
<div className="ml-7">
<input
type="date"
value={yearSettings.hire_date || ''}
onChange={e => 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"
/>
</div>
)}
<div className="ml-7 mt-[6px] font-geist text-[0.625rem] text-m-faint">
{t('vacay.yearWindow', { year: selectedYear, window: windowLabel })}
</div>
</>
)
}
/** 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
+42 -13
View File
@@ -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<MVacayView>('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<MVacayMode>('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<MVacaySheet>(null)
const [tripDates, setTripDates] = useState<Set<string>>(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,