diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index 12a78bb7e..ab66740fd 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -1,112 +1,24 @@ -import { useMemo, useState, useCallback, useEffect } from 'react' +import { useState, useCallback } from 'react' import { useVacayStore } from '../../store/vacayStore' -import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' -import { isWeekend } from './holidays' -import { inGridWindow, windowMonths } from '../../vacay/yearWindow' -import { tripsApi } from '../../api/client' +import { windowMonths } from '../../vacay/yearWindow' import VacayMonthCard from './VacayMonthCard' -import type { VacayEntry } from '../../types' import { Building2, MousePointer2 } from 'lucide-react' -import { companyHolidaySets, leaveFractionFor } from './companyHolidays' +import { useVacayCalendarLogic } from './useVacayCalendarLogic' -type VacayMode = 'vacation' | 'company' type HoverTip = { date: string; top: number; left: number } -export type SharedDayMark = { color: string; name: string; fraction?: number; company?: boolean; kind?: 'vacation' | 'comp' } export default function VacayCalendar() { const { t, locale } = useTranslation() - const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays, sharedCalendars, yearSettings } = useVacayStore() - const currentUserId = useAuthStore(s => s.user?.id) - 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. - const [halfDay, setHalfDay] = useState(false) - // Comp/Flex day is a second per-person modifier (#1074), orthogonal to half-day: - // with it on, clicking a day logs it as kind='comp' (does not cost the entitlement). - const [compDay, setCompDay] = useState(false) + const { selectedYear, plan, holidays, yearSettings } = useVacayStore() + const { + mode, setMode, halfDay, setHalfDay, compDay, setCompDay, tripDates, entryMap, sharedMap, + companyHolidaySet, companyHalfSet, blockWeekends, weekendDays, companyHolidaysEnabled, selectedUser, + logDay: handleCellClick, + } = useVacayCalendarLogic({ selectedYear, plan, tripsFollowWindowShape: false }) const companyMode = mode === 'company' - const [tripDates, setTripDates] = useState>(new Set()) const [tip, setTip] = useState(null) - useEffect(() => { - let cancelled = false - ;void (async () => { - try { - const data = await tripsApi.list() - const dates = new Set() - for (const trip of data.trips || []) { - if (!trip.start_date || !trip.end_date) continue - 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)) { - // 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, yearSettings]) - - // Whole company holidays close a day to leave, half ones leave half of it (#2439). - const { full: companyHolidaySet, half: companyHalfSet } = useMemo(() => companyHolidaySets(companyHolidays), [companyHolidays]) - - const entryMap = useMemo(() => { - const map: Record = {} - entries.forEach(e => { - if (!map[e.date]) map[e.date] = [] - map[e.date].push(e) - }) - return map - }, [entries]) - - // Shared read-only calendars (#444/#667) render as colored rings, not fills, - // so they never mix into the members' split logic — merged stays merged, - // shared stays a distinct overlay. - const sharedMap = useMemo(() => { - const map: Record = {} - sharedCalendars.filter(c => !c.hidden).forEach(cal => { - cal.entries.forEach(e => { - if (!map[e.date]) map[e.date] = [] - map[e.date].push({ color: cal.color, name: cal.owner_name, fraction: e.fraction, kind: e.kind }) - }) - cal.companyHolidays.forEach(h => { - if (!map[h.date]) map[h.date] = [] - map[h.date].push({ color: cal.color, name: cal.owner_name, company: true }) - }) - }) - return map - }, [sharedCalendars]) - - const blockWeekends = plan?.block_weekends !== false - const weekendDays = useMemo(() => (plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]), [plan?.weekend_days]) - const companyHolidaysEnabled = plan?.company_holidays_enabled !== false - - const handleCellClick = useCallback(async (dateStr: string) => { - if (mode === 'company') { - if (!companyHolidaysEnabled) return - await toggleCompanyHoliday(dateStr, halfDay ? 0.5 : 1) - return - } - if (blockWeekends && isWeekend(dateStr, weekendDays)) { - // A day already logged when the weekend config changed under it (#1897) keeps - // counting against the entitlement, so clearing it stays possible — with the - // entry's own fraction/kind, since the server only allows the delete on a - // blocked day, not a conversion. Logging a new one stays blocked. - const own = entryMap[dateStr]?.find(e => e.user_id === (selectedUserId ?? currentUserId)) - if (!own) return - await toggleEntry(dateStr, selectedUserId || undefined, (own.fraction ?? 1) === 0.5 ? 0.5 : 1, own.kind ?? 'vacation') - return - } - if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return - const fraction = companyHolidaysEnabled ? leaveFractionFor(dateStr, companyHalfSet, halfDay ? 0.5 : 1) : (halfDay ? 0.5 : 1) - await toggleEntry(dateStr, selectedUserId || undefined, fraction, compDay ? 'comp' : 'vacation') - }, [mode, halfDay, compDay, toggleEntry, toggleCompanyHoliday, companyHolidaySet, companyHalfSet, blockWeekends, weekendDays, companyHolidaysEnabled, selectedUserId, currentUserId, entryMap]) - // Cells with a half day or a shared overlay report a hover, so the tooltip // appears exactly when there's something to explain. Fixed-positioned at the // root so no card clips it. @@ -116,7 +28,6 @@ export default function VacayCalendar() { setTip({ date: dateStr, top: r.top, left: r.left + r.width / 2 }) }, []) - 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' diff --git a/client/src/components/Vacay/VacayPersons.tsx b/client/src/components/Vacay/VacayPersons.tsx index 2a3c24693..e7719285c 100644 --- a/client/src/components/Vacay/VacayPersons.tsx +++ b/client/src/components/Vacay/VacayPersons.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useId, type HTMLAttributes } from 'react' +import { useState, useId, type HTMLAttributes } from 'react' import { UserPlus, Check, Loader2, Clock, Palette } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' import { useAuthStore } from '../../store/authStore' @@ -9,6 +9,7 @@ import CustomSelect from '../shared/CustomSelect' import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import apiClient from '../../api/client' import VacayBadge from './VacayBadge' +import { useDefaultVacayPerson } from './useVacayCalendarLogic' const PRESET_COLORS = [ '#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', @@ -23,9 +24,7 @@ export default function VacayPersons() { const { user: currentUser } = useAuthStore() // Default selectedUserId to current user - useEffect(() => { - if (!selectedUserId && currentUser) setSelectedUserId(currentUser.id) - }, [currentUser, selectedUserId]) + useDefaultVacayPerson() const [showInvite, setShowInvite] = useState(false) const [showColorPicker, setShowColorPicker] = useState(false) const [colorEditUserId, setColorEditUserId] = useState(null) diff --git a/client/src/components/Vacay/VacayStats.tsx b/client/src/components/Vacay/VacayStats.tsx index 7ceb75621..99e07663f 100644 --- a/client/src/components/Vacay/VacayStats.tsx +++ b/client/src/components/Vacay/VacayStats.tsx @@ -7,10 +7,7 @@ import { windowMonths } from '../../vacay/yearWindow' import type { VacayStat, TranslationFn } from '../../types' import { NumericInput } from '../shared/NumericInput' import VacayBadge from './VacayBadge' - -// Used/remaining can be fractional once half days (#552) are in play; entry -// fractions are exact multiples of 0.5, so one decimal is enough and never drifts. -const fmtDays = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(1)) +import { fmtDays } from './vacayFormat' // The sidebar card can be folded away; the choice is a personal view preference, // so it lives in localStorage rather than on the plan. diff --git a/client/src/components/Vacay/useVacayCalendarLogic.test.ts b/client/src/components/Vacay/useVacayCalendarLogic.test.ts new file mode 100644 index 000000000..140d5f71d --- /dev/null +++ b/client/src/components/Vacay/useVacayCalendarLogic.test.ts @@ -0,0 +1,203 @@ +// FE-COMP-VACAYCAL-001 to -013: the calendar logging logic the desktop grid and the phone screen share. +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { tripsApi } from '../../api/client'; +import { useAuthStore } from '../../store/authStore'; +import { useVacayStore } from '../../store/vacayStore'; +import type { VacayEntry, VacayPlan, VacayYearSettings } from '../../types'; +import { useDefaultVacayPerson, useVacayCalendarLogic, vacayWindowShape } from './useVacayCalendarLogic'; +import { fmtDays } from './vacayFormat'; + +const CALENDAR: VacayYearSettings = { year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null }; + +const toggleEntry = vi.fn(async () => {}); +const toggleCompanyHoliday = vi.fn(async () => {}); + +function plan(over: Partial = {}): VacayPlan { + return { + id: 1, + holidays_enabled: false, + holidays_region: null, + holiday_calendars: [], + block_weekends: true, + carry_over_enabled: false, + company_holidays_enabled: true, + ...over, + } as VacayPlan; +} + +function entry(over: Partial): VacayEntry { + return { date: '2026-03-02', user_id: 1, person_color: '#3b82f6', ...over } as VacayEntry; +} + +type Options = Parameters[0]; + +function logic(over: Partial = {}) { + const props: Options = { selectedYear: 2026, plan: plan(), tripsFollowWindowShape: false, ...over }; + return renderHook((p: Options) => useVacayCalendarLogic(p), { initialProps: props }); +} + +beforeEach(() => { + toggleEntry.mockClear(); + toggleCompanyHoliday.mockClear(); + vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: [] } as never); + useAuthStore.setState({ user: { id: 1, username: 'alice' } as never }); + useVacayStore.setState({ + selectedUserId: 1, + entries: [], + companyHolidays: [], + users: [{ id: 1, username: 'alice', color: '#3b82f6' }], + sharedCalendars: [], + yearSettings: CALENDAR, + toggleEntry, + toggleCompanyHoliday, + } as never); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('useVacayCalendarLogic', () => { + it('FE-COMP-VACAYCAL-001: a weekday logs a full vacation day for the selected person', async () => { + const { result } = logic(); + await act(() => result.current.logDay('2026-03-04')); + expect(toggleEntry).toHaveBeenCalledWith('2026-03-04', 1, 1, 'vacation'); + }); + + it('FE-COMP-VACAYCAL-002: the half-day and comp modifiers shape the logged day', async () => { + const { result } = logic(); + act(() => { + result.current.setHalfDay(true); + result.current.setCompDay(true); + }); + await act(() => result.current.logDay('2026-03-04')); + expect(toggleEntry).toHaveBeenCalledWith('2026-03-04', 1, 0.5, 'comp'); + }); + + it('FE-COMP-VACAYCAL-003: company mode toggles a company holiday, or nothing while they are off', async () => { + const { result, rerender } = logic(); + act(() => result.current.setMode('company')); + act(() => result.current.setHalfDay(true)); + await act(() => result.current.logDay('2026-03-04')); + expect(toggleCompanyHoliday).toHaveBeenCalledWith('2026-03-04', 0.5); + + rerender({ selectedYear: 2026, plan: plan({ company_holidays_enabled: false }), tripsFollowWindowShape: false }); + await act(() => result.current.logDay('2026-03-05')); + expect(toggleCompanyHoliday).toHaveBeenCalledTimes(1); + expect(result.current.companyHolidaysEnabled).toBe(false); + }); + + it('FE-COMP-VACAYCAL-004: a blocked weekend only clears a day already logged, with its own fraction and kind', async () => { + const { result } = logic(); + await act(() => result.current.logDay('2026-03-07')); + expect(toggleEntry).not.toHaveBeenCalled(); + + useVacayStore.setState({ entries: [entry({ date: '2026-03-07', fraction: 0.5, kind: 'comp' })] } as never); + await waitFor(() => expect(result.current.entryMap['2026-03-07']).toHaveLength(1)); + await act(() => result.current.logDay('2026-03-07')); + expect(toggleEntry).toHaveBeenCalledWith('2026-03-07', 1, 0.5, 'comp'); + }); + + it('FE-COMP-VACAYCAL-005: weekends log normally when the plan does not block them, with its own weekend days', async () => { + const { result } = logic({ plan: plan({ block_weekends: false, weekend_days: '5' } as Partial) }); + expect(result.current.weekendDays).toEqual([5]); + expect(result.current.blockWeekends).toBe(false); + await act(() => result.current.logDay('2026-03-07')); + expect(toggleEntry).toHaveBeenCalledWith('2026-03-07', 1, 1, 'vacation'); + }); + + it('FE-COMP-VACAYCAL-006: a whole company holiday is closed, a half one leaves half a day', async () => { + useVacayStore.setState({ + companyHolidays: [ + { date: '2026-03-04', fraction: 1 }, + { date: '2026-03-05', fraction: 0.5 }, + ], + } as never); + const { result } = logic(); + await act(() => result.current.logDay('2026-03-04')); + expect(toggleEntry).not.toHaveBeenCalled(); + await act(() => result.current.logDay('2026-03-05')); + expect(toggleEntry).toHaveBeenCalledWith('2026-03-05', 1, 0.5, 'vacation'); + }); + + it('FE-COMP-VACAYCAL-007: company holidays switched off do not block a day', async () => { + useVacayStore.setState({ companyHolidays: [{ date: '2026-03-04', fraction: 1 }] } as never); + const { result } = logic({ plan: plan({ company_holidays_enabled: false }) }); + await act(() => result.current.logDay('2026-03-04')); + expect(toggleEntry).toHaveBeenCalledWith('2026-03-04', 1, 1, 'vacation'); + }); + + it('FE-COMP-VACAYCAL-008: the entry map groups by day and the shared map skips hidden calendars', () => { + useVacayStore.setState({ + entries: [entry({ user_id: 1 }), entry({ user_id: 2 })], + sharedCalendars: [ + { + color: '#f00', + owner_name: 'Carol', + hidden: false, + entries: [{ date: '2026-03-02', fraction: 0.5, kind: 'comp' }], + companyHolidays: [{ date: '2026-03-03' }], + }, + { color: '#0f0', owner_name: 'Dan', hidden: true, entries: [{ date: '2026-03-02' }], companyHolidays: [] }, + ], + } as never); + const { result } = logic(); + expect(result.current.entryMap['2026-03-02']).toHaveLength(2); + expect(result.current.sharedMap).toEqual({ + '2026-03-02': [{ color: '#f00', name: 'Carol', fraction: 0.5, kind: 'comp' }], + '2026-03-03': [{ color: '#f00', name: 'Carol', company: true }], + }); + expect(result.current.selectedUser).toEqual({ id: 1, username: 'alice', color: '#3b82f6' }); + }); + + it('FE-COMP-VACAYCAL-009: trip dots cover the days of dated trips inside the year window', async () => { + vi.spyOn(tripsApi, 'list').mockResolvedValue({ + trips: [ + { start_date: '2025-12-30', end_date: '2026-01-02' }, + { start_date: null, end_date: null }, + ], + } as never); + const { result } = logic(); + await waitFor(() => expect(result.current.tripDates.size).toBe(2)); + expect([...result.current.tripDates]).toEqual(['2026-01-01', '2026-01-02']); + }); + + it('FE-COMP-VACAYCAL-010: the desktop mode reloads trips on every new settings object', async () => { + const list = vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: [] } as never); + logic(); + await waitFor(() => expect(list).toHaveBeenCalledTimes(1)); + act(() => useVacayStore.setState({ yearSettings: { ...CALENDAR } })); + await waitFor(() => expect(list).toHaveBeenCalledTimes(2)); + }); + + it('FE-COMP-VACAYCAL-011: the phone mode reloads trips only when the window shape changes', async () => { + const list = vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: [] } as never); + logic({ tripsFollowWindowShape: true }); + await waitFor(() => expect(list).toHaveBeenCalledTimes(1)); + act(() => useVacayStore.setState({ yearSettings: { ...CALENDAR } })); + await Promise.resolve(); + expect(list).toHaveBeenCalledTimes(1); + act(() => useVacayStore.setState({ yearSettings: { ...CALENDAR, year_type: 'fiscal', year_start_month: 7 } })); + await waitFor(() => expect(list).toHaveBeenCalledTimes(2)); + }); +}); + +describe('useDefaultVacayPerson', () => { + it('FE-COMP-VACAYCAL-012: picks the signed-in user only while nobody is selected', () => { + useVacayStore.setState({ selectedUserId: null }); + renderHook(() => useDefaultVacayPerson()); + expect(useVacayStore.getState().selectedUserId).toBe(1); + + useVacayStore.setState({ selectedUserId: 2 }); + renderHook(() => useDefaultVacayPerson()); + expect(useVacayStore.getState().selectedUserId).toBe(2); + }); +}); + +describe('vacay helpers', () => { + it('FE-COMP-VACAYCAL-013: the window shape and the day format', () => { + expect(vacayWindowShape({ year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null })).toBe( + 'fiscal|7|1|null' + ); + expect(fmtDays(3)).toBe('3'); + expect(fmtDays(2.5)).toBe('2.5'); + }); +}); diff --git a/client/src/components/Vacay/useVacayCalendarLogic.ts b/client/src/components/Vacay/useVacayCalendarLogic.ts new file mode 100644 index 000000000..210d57f4c --- /dev/null +++ b/client/src/components/Vacay/useVacayCalendarLogic.ts @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { tripsApi } from '../../api/client'; +import { useAuthStore } from '../../store/authStore'; +import { useVacayStore } from '../../store/vacayStore'; +import type { VacayEntry, VacayPlan, VacayYearSettings } from '../../types'; +import { inGridWindow } from '../../vacay/yearWindow'; +import { companyHolidaySets, leaveFractionFor } from './companyHolidays'; +import { isWeekend } from './holidays'; + +export type VacayMode = 'vacation' | 'company'; +type SharedDayMark = { + color: string; + name: string; + fraction?: number; + company?: boolean; + kind?: 'vacation' | 'comp'; +}; + +interface VacayCalendarLogicOptions { + selectedYear: number; + plan: VacayPlan | null | undefined; + /** + * Reload the trip dots only when the leave-year window changes shape (the phone + * screen). Otherwise they reload whenever the settings object is replaced. + */ + tripsFollowWindowShape: boolean; +} + +/** The leave-year window's shape as a primitive, stable across deep-equal reloads. */ +export function vacayWindowShape(s: VacayYearSettings): string { + return `${s.year_type}|${s.year_start_month}|${s.year_start_day}|${s.hire_date}`; +} + +function localDay(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** + * Logging logic of the vacay calendar, shared by the desktop grid and the phone + * screen: the log mode with its half-day and comp-day modifiers, the per-day maps + * the cells render from, the trip dots, and what a click on a day logs. + */ +export function useVacayCalendarLogic({ selectedYear, plan, tripsFollowWindowShape }: VacayCalendarLogicOptions) { + const selectedUserId = useVacayStore((s) => s.selectedUserId); + const entries = useVacayStore((s) => s.entries); + const companyHolidays = useVacayStore((s) => s.companyHolidays); + const toggleEntry = useVacayStore((s) => s.toggleEntry); + const toggleCompanyHoliday = useVacayStore((s) => s.toggleCompanyHoliday); + const users = useVacayStore((s) => s.users); + const sharedCalendars = useVacayStore((s) => s.sharedCalendars); + const yearSettings = useVacayStore((s) => s.yearSettings); + const currentUserId = useAuthStore((s) => s.user?.id); + 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. + const [halfDay, setHalfDay] = useState(false); + // Comp/Flex day is a second per-person modifier (#1074), orthogonal to half-day: + // with it on, clicking a day logs it as kind='comp' (does not cost the entitlement). + const [compDay, setCompDay] = useState(false); + const [tripDates, setTripDates] = useState>(new Set()); + + const tripsKey = tripsFollowWindowShape ? vacayWindowShape(yearSettings) : yearSettings; + useEffect(() => { + const settings = useVacayStore.getState().yearSettings; + let cancelled = false; + void (async () => { + try { + const data = await tripsApi.list(); + const dates = new Set(); + for (const trip of data.trips || []) { + if (!trip.start_date || !trip.end_date) continue; + 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)) { + // 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 = localDay(d); + if (inGridWindow(date, selectedYear, settings)) dates.add(date); + } + } + if (!cancelled) setTripDates(dates); + } catch { + /* ignore */ + } + })(); + return () => { + cancelled = true; + }; + }, [selectedYear, tripsKey]); + + // Whole company holidays close a day to leave, half ones leave half of it (#2439). + const { full: companyHolidaySet, half: companyHalfSet } = useMemo( + () => companyHolidaySets(companyHolidays), + [companyHolidays] + ); + + const entryMap = useMemo(() => { + const map: Record = {}; + entries.forEach((e) => { + if (!map[e.date]) map[e.date] = []; + map[e.date].push(e); + }); + return map; + }, [entries]); + + // Shared read-only calendars (#444/#667) render as colored rings, not fills, + // so they never mix into the members' split logic: merged stays merged, + // shared stays a distinct overlay. Hidden ones stay out. + const sharedMap = useMemo(() => { + const map: Record = {}; + sharedCalendars + .filter((c) => !c.hidden) + .forEach((cal) => { + cal.entries.forEach((e) => { + if (!map[e.date]) map[e.date] = []; + map[e.date].push({ color: cal.color, name: cal.owner_name, fraction: e.fraction, kind: e.kind }); + }); + cal.companyHolidays.forEach((h) => { + if (!map[h.date]) map[h.date] = []; + map[h.date].push({ color: cal.color, name: cal.owner_name, company: true }); + }); + }); + return map; + }, [sharedCalendars]); + + const blockWeekends = plan?.block_weekends !== false; + const weekendDays = useMemo( + () => (plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]), + [plan?.weekend_days] + ); + const companyHolidaysEnabled = plan?.company_holidays_enabled !== false; + + const logDay = useCallback( + async (dateStr: string) => { + if (mode === 'company') { + if (!companyHolidaysEnabled) return; + await toggleCompanyHoliday(dateStr, halfDay ? 0.5 : 1); + return; + } + if (blockWeekends && isWeekend(dateStr, weekendDays)) { + // A day already logged when the weekend config changed under it (#1897) keeps + // counting against the entitlement, so clearing it stays possible, with the + // entry's own fraction/kind, since the server only allows the delete on a + // blocked day, not a conversion. Logging a new one stays blocked. + const own = entryMap[dateStr]?.find((e) => e.user_id === (selectedUserId ?? currentUserId)); + if (!own) return; + await toggleEntry( + dateStr, + selectedUserId || undefined, + (own.fraction ?? 1) === 0.5 ? 0.5 : 1, + own.kind ?? 'vacation' + ); + return; + } + if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return; + const fraction = companyHolidaysEnabled + ? leaveFractionFor(dateStr, companyHalfSet, halfDay ? 0.5 : 1) + : halfDay + ? 0.5 + : 1; + await toggleEntry(dateStr, selectedUserId || undefined, fraction, compDay ? 'comp' : 'vacation'); + }, + [ + mode, + halfDay, + compDay, + toggleEntry, + toggleCompanyHoliday, + companyHolidaySet, + companyHalfSet, + blockWeekends, + weekendDays, + companyHolidaysEnabled, + selectedUserId, + currentUserId, + entryMap, + ] + ); + + const selectedUser = users.find((u) => u.id === selectedUserId); + + return { + mode, + setMode, + halfDay, + setHalfDay, + compDay, + setCompDay, + tripDates, + entryMap, + sharedMap, + companyHolidaySet, + companyHalfSet, + blockWeekends, + weekendDays, + companyHolidaysEnabled, + selectedUser, + logDay, + }; +} + +/** Points the vacay views at the signed-in user until someone else is picked. */ +export function useDefaultVacayPerson() { + const currentUser = useAuthStore((s) => s.user); + const selectedUserId = useVacayStore((s) => s.selectedUserId); + const setSelectedUserId = useVacayStore((s) => s.setSelectedUserId); + useEffect(() => { + if (!selectedUserId && currentUser) setSelectedUserId(currentUser.id); + }, [currentUser, selectedUserId, setSelectedUserId]); +} diff --git a/client/src/components/Vacay/vacayFormat.ts b/client/src/components/Vacay/vacayFormat.ts new file mode 100644 index 000000000..fd9b2f0b8 --- /dev/null +++ b/client/src/components/Vacay/vacayFormat.ts @@ -0,0 +1,3 @@ +// Used/remaining can be fractional once half days (#552) are in play; entry +// fractions are exact multiples of 0.5, so one decimal is enough and never drifts. +export const fmtDays = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(1)); diff --git a/client/src/mobile/screens/vacay/MVacay.tsx b/client/src/mobile/screens/vacay/MVacay.tsx index 0772ac22c..455d4d9e1 100644 --- a/client/src/mobile/screens/vacay/MVacay.tsx +++ b/client/src/mobile/screens/vacay/MVacay.tsx @@ -10,9 +10,7 @@ import MVacayInviteSheet from './MVacayInviteSheet' import MVacaySettingsSheet from './MVacaySettingsSheet' import MVacayShareSheet from './MVacayShareSheet' import { FALLBACK_PERSON_COLOR } from './vacayDayModel' - -/** Half days (#552) make the balance fractional; one decimal is exact and never drifts. */ -const fmtDays = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(1)) +import { fmtDays } from '../../../components/Vacay/vacayFormat' const WEEKDAY_KEYS_MONDAY = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const diff --git a/client/src/mobile/screens/vacay/useMVacay.ts b/client/src/mobile/screens/vacay/useMVacay.ts index 7d5460f60..988753408 100644 --- a/client/src/mobile/screens/vacay/useMVacay.ts +++ b/client/src/mobile/screens/vacay/useMVacay.ts @@ -5,15 +5,15 @@ import { useVacayStore } from '../../../store/vacayStore' import { useAuthStore } from '../../../store/authStore' 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 { currentPeriodYear, windowMonths } from '../../../vacay/yearWindow' import { FALLBACK_PERSON_COLOR, localDateStr, type DayVisualContext } from './vacayDayModel' -import { getApiErrorMessage, type Trip } from '../../../types' -import { companyHolidaySets, leaveFractionFor } from '../../../components/Vacay/companyHolidays' +import { getApiErrorMessage } from '../../../types' +import { + useDefaultVacayPerson, useVacayCalendarLogic, vacayWindowShape, type VacayMode, +} from '../../../components/Vacay/useVacayCalendarLogic' export type MVacayView = 'grid' | 'edit' -export type MVacayMode = 'vacation' | 'company' +export type MVacayMode = VacayMode export type MVacaySheet = 'invite' | 'settings' | 'share' | null /** @@ -32,10 +32,10 @@ export function useMVacay() { handleAddNextYear, handleAddPrevYear, } = useVacay() const { - entries, companyHolidays, stats, users, holidays, + stats, users, holidays, selectedUserId, setSelectedUserId, isFused, - toggleEntry, toggleCompanyHoliday, updateVacationDays, - incomingShares, sharedCalendars, setShareHidden, yearSettings, + updateVacationDays, + incomingShares, setShareHidden, yearSettings, } = useVacayStore() const currentUser = useAuthStore(s => s.user) @@ -43,89 +43,30 @@ export function useMVacay() { // 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) - // Comp/Flex day modifier (#1074): when on, taps log the day as kind='comp' (free). - const [compDay, setCompDay] = useState(false) + // Log mode plus the half-day (#552) and comp/flex (#1074) tap modifiers, the + // per-day maps and the trip-overlap dots come from the shared calendar logic. + const { + mode, setMode, halfDay, setHalfDay, compDay, setCompDay, tripDates, entryMap, sharedMap, + companyHolidaySet, companyHalfSet, blockWeekends, weekendDays, companyHolidaysEnabled, logDay, + } = useVacayCalendarLogic({ selectedYear, plan, tripsFollowWindowShape: true }) 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}` + const windowShape = vacayWindowShape(yearSettings) // Default the active person to the current user (same as the persons panel). - useEffect(() => { - if (!selectedUserId && currentUser) setSelectedUserId(currentUser.id) - }, [currentUser, selectedUserId, setSelectedUserId]) + useDefaultVacayPerson() - // Trip-overlap dots: collect every day of the year covered by an own trip. - useEffect(() => { - let cancelled = false - ;void (async () => { - try { - const data = await tripsApi.list() - const dates = new Set() - for (const trip of (data.trips || []) as Trip[]) { - if (!trip.start_date || !trip.end_date) continue - 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)) { - // 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 } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedYear, windowShape]) - - const blockWeekends = plan?.block_weekends !== false - const companyHolidaysEnabled = plan?.company_holidays_enabled !== false const holidaysEnabled = plan?.holidays_enabled === true const weekStart = plan?.week_start ?? 1 - const weekendDays = useMemo( - () => (plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]), - [plan?.weekend_days], - ) - - // Whole company holidays close a day to leave, half ones leave half of it (#2439). - const { full: companyHolidaySet, half: companyHalfSet } = useMemo(() => companyHolidaySets(companyHolidays), [companyHolidays]) - - const entryMap = useMemo(() => { - const map: DayVisualContext['entryMap'] = {} - entries.forEach(e => { - if (!map[e.date]) map[e.date] = [] - map[e.date].push(e) - }) - return map - }, [entries]) const todayStr = useMemo(() => { const d = new Date() return localDateStr(d.getFullYear(), d.getMonth(), d.getDate()) }, []) - // Shared read-only calendars (#444/#667) overlay as rings; hidden ones stay out. - const sharedMap = useMemo(() => { - const map: Record = {} - sharedCalendars.filter(c => !c.hidden).forEach(cal => { - const push = (date: string) => { - if (!map[date]) map[date] = [] - map[date].push({ color: cal.color }) - } - cal.entries.forEach(e => push(e.date)) - cal.companyHolidays.forEach(h => push(h.date)) - }) - return map - }, [sharedCalendars]) - const dayCtx = useMemo(() => ({ todayStr, entryMap, companyHolidaySet, companyHalfSet, companyHolidaysEnabled, holidays, weekendDays, sharedMap, }), [todayStr, entryMap, companyHolidaySet, companyHalfSet, companyHolidaysEnabled, holidays, weekendDays, sharedMap]) @@ -172,7 +113,7 @@ export function useMVacay() { setSelectedUserId(id) setMode('vacation') } - }, [isFused, currentUser?.id, setSelectedUserId]) + }, [isFused, currentUser?.id, setSelectedUserId, setMode]) // Zoom the year overview into one month's editor (#1811). The overview used to // swallow every tap, leaving the pen FAB as the only way in; it is a way in @@ -193,25 +134,8 @@ export function useMVacay() { if (slot >= 0) openMonthSlot(slot) return } - if (mode === 'company') { - if (!companyHolidaysEnabled) return - await toggleCompanyHoliday(dateStr, halfDay ? 0.5 : 1) - return - } - if (blockWeekends && isWeekend(dateStr, weekendDays)) { - // A day already logged when the weekend config changed under it (#1897) keeps - // counting against the entitlement, so clearing it stays possible — with the - // entry's own fraction/kind, since the server only allows the delete on a - // blocked day, not a conversion. Logging a new one stays blocked. - const own = entryMap[dateStr]?.find(e => e.user_id === (selectedUserId ?? currentUser?.id)) - if (!own) return - await toggleEntry(dateStr, selectedUserId || undefined, (own.fraction ?? 1) === 0.5 ? 0.5 : 1, own.kind ?? 'vacation') - return - } - if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return - const fraction = companyHolidaysEnabled ? leaveFractionFor(dateStr, companyHalfSet, halfDay ? 0.5 : 1) : (halfDay ? 0.5 : 1) - await toggleEntry(dateStr, selectedUserId || undefined, fraction, compDay ? 'comp' : 'vacation') - }, [view, months, openMonthSlot, mode, halfDay, compDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, companyHalfSet, toggleEntry, toggleCompanyHoliday, selectedUserId, currentUser?.id, entryMap]) + await logDay(dateStr) + }, [view, months, openMonthSlot, logDay]) // Entitlement stepper: never below what is already used this year // (carried-over days cover the difference when used > entitlement). diff --git a/client/tests/unit/mobile/vacay/useMVacay.test.tsx b/client/tests/unit/mobile/vacay/useMVacay.test.tsx index 773dd5466..8e166b177 100644 --- a/client/tests/unit/mobile/vacay/useMVacay.test.tsx +++ b/client/tests/unit/mobile/vacay/useMVacay.test.tsx @@ -201,8 +201,8 @@ describe('useMVacay', () => { }); const { result } = await mount(); - expect(result.current.dayCtx.sharedMap?.['2026-07-01']).toEqual([{ color: '#f59e0b' }]); - expect(result.current.dayCtx.sharedMap?.['2026-07-02']).toEqual([{ color: '#f59e0b' }]); + expect(result.current.dayCtx.sharedMap?.['2026-07-01']).toEqual([expect.objectContaining({ color: '#f59e0b' })]); + expect(result.current.dayCtx.sharedMap?.['2026-07-02']).toEqual([expect.objectContaining({ color: '#f59e0b' })]); expect(result.current.dayCtx.sharedMap?.['2026-07-03']).toBeUndefined(); });