From d0709c6a3715d1c8efc1325d04fea75cc69f4d34 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:23:49 +0200 Subject: [PATCH] refactor(vacay): share the calendar logging logic between desktop and phone The log mode and its half and comp modifiers, the entry, shared and company holiday maps, the trip dots, the day click rules, the default person and the day count format lived in both the desktop calendar and the phone screen. They now live in useVacayCalendarLogic and vacayFormat; the phone keeps reloading trip dots only on a new window shape. --- client/src/components/Vacay/VacayCalendar.tsx | 107 +-------- client/src/components/Vacay/VacayPersons.tsx | 7 +- client/src/components/Vacay/VacayStats.tsx | 5 +- .../Vacay/useVacayCalendarLogic.test.ts | 203 +++++++++++++++++ .../components/Vacay/useVacayCalendarLogic.ts | 211 ++++++++++++++++++ client/src/components/Vacay/vacayFormat.ts | 3 + client/src/mobile/screens/vacay/MVacay.tsx | 4 +- client/src/mobile/screens/vacay/useMVacay.ts | 116 ++-------- .../unit/mobile/vacay/useMVacay.test.tsx | 4 +- 9 files changed, 453 insertions(+), 207 deletions(-) create mode 100644 client/src/components/Vacay/useVacayCalendarLogic.test.ts create mode 100644 client/src/components/Vacay/useVacayCalendarLogic.ts create mode 100644 client/src/components/Vacay/vacayFormat.ts 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(); });