mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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<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.
|
||||
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<Set<string>>(new Set())
|
||||
const [tip, setTip] = useState<HoverTip | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;void (async () => {
|
||||
try {
|
||||
const data = await tripsApi.list()
|
||||
const dates = new Set<string>()
|
||||
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<string, VacayEntry[]> = {}
|
||||
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<string, SharedDayMark[]> = {}
|
||||
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<number[]>(() => (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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> = {}): 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>): VacayEntry {
|
||||
return { date: '2026-03-02', user_id: 1, person_color: '#3b82f6', ...over } as VacayEntry;
|
||||
}
|
||||
|
||||
type Options = Parameters<typeof useVacayCalendarLogic>[0];
|
||||
|
||||
function logic(over: Partial<Options> = {}) {
|
||||
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<VacayPlan>) });
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<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.
|
||||
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<Set<string>>(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<string>();
|
||||
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<string, VacayEntry[]> = {};
|
||||
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<string, SharedDayMark[]> = {};
|
||||
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<number[]>(
|
||||
() => (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]);
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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
|
||||
|
||||
@@ -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<MVacayMode>('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<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}`
|
||||
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<string>()
|
||||
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<number[]>(
|
||||
() => (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<string, { color: string }[]> = {}
|
||||
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<DayVisualContext>(() => ({
|
||||
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).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user