diff --git a/client/src/components/Vacay/VacayCalendar.test.tsx b/client/src/components/Vacay/VacayCalendar.test.tsx index e37a35e17..aa5638973 100644 --- a/client/src/components/Vacay/VacayCalendar.test.tsx +++ b/client/src/components/Vacay/VacayCalendar.test.tsx @@ -97,10 +97,10 @@ describe('VacayCalendar', () => { render() - // Only the vacation mode button should be in the toolbar + // Vacation + half-day buttons stay; only the company button is hidden. const buttons = screen.getAllByRole('button') const toolbarButtons = buttons.filter(b => !b.textContent?.startsWith('click-')) - expect(toolbarButtons).toHaveLength(1) + expect(toolbarButtons).toHaveLength(2) }) it('FE-COMP-VACAYCALENDAR-005: switching to company mode highlights company button', async () => { @@ -120,7 +120,7 @@ describe('VacayCalendar', () => { const buttons = screen.getAllByRole('button') const toolbarButtons = buttons.filter(b => !b.textContent?.startsWith('click-')) - // toolbarButtons[0] = vacation mode, toolbarButtons[1] = company mode + // toolbarButtons[0] = vacation, [1] = company mode, [2] = half-day toggle const companyBtn = toolbarButtons[1] await user.click(companyBtn) @@ -149,7 +149,33 @@ describe('VacayCalendar', () => { // Click the first month card cell button (month 0 → date '2025-01-01') await user.click(screen.getByText('click-0')) - expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42) + expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 1) + }) + + it('FE-COMP-VACAYCALENDAR-006b: half-day mode logs the day as 0.5', async () => { + const user = userEvent.setup() + const toggleEntry = vi.fn().mockResolvedValue(undefined) + + seedStore(useVacayStore, { + selectedYear: 2025, + entries: [], + companyHolidays: [], + holidays: {}, + plan: { ...basePlan, block_weekends: false, company_holidays_enabled: false }, + users: [], + selectedUserId: 42, + toggleEntry, + }) + + render() + + // toolbarButtons[0] = vacation, [1] = half day + const buttons = screen.getAllByRole('button') + const toolbarButtons = buttons.filter(b => !b.textContent?.startsWith('click-')) + await user.click(toolbarButtons[1]) + await user.click(screen.getByText('click-0')) + + expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 0.5) }) it('FE-COMP-VACAYCALENDAR-007: cell click on public holiday toggles vacation entry', async () => { @@ -172,7 +198,7 @@ describe('VacayCalendar', () => { // Month 0, button emits '2025-01-01' which is a holiday — should still toggle vacation await user.click(screen.getByText('click-0')) - expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', undefined) + expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', undefined, 1) }) it('FE-COMP-VACAYCALENDAR-008: cell click in company mode calls toggleCompanyHoliday', async () => { diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index c4b08d585..344f79727 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -4,13 +4,22 @@ import { useTranslation } from '../../i18n' import { isWeekend } from './holidays' import { tripsApi } from '../../api/client' import VacayMonthCard from './VacayMonthCard' +import type { VacayEntry } from '../../types' import { Building2, MousePointer2 } from 'lucide-react' +type VacayMode = 'vacation' | 'company' +type HoverTip = { date: string; top: number; left: number } + export default function VacayCalendar() { - const { t } = useTranslation() + const { t, locale } = useTranslation() const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore() - const [companyMode, setCompanyMode] = useState(false) + 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) + const companyMode = mode === 'company' const [tripDates, setTripDates] = useState>(new Set()) + const [tip, setTip] = useState(null) useEffect(() => { let cancelled = false @@ -42,7 +51,7 @@ export default function VacayCalendar() { }, [companyHolidays]) const entryMap = useMemo(() => { - const map = {} + const map: Record = {} entries.forEach(e => { if (!map[e.date]) map[e.date] = [] map[e.date].push(e) @@ -51,21 +60,31 @@ export default function VacayCalendar() { }, [entries]) const blockWeekends = plan?.block_weekends !== false - const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6] + 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 (companyMode) { + if (mode === 'company') { if (!companyHolidaysEnabled) return await toggleCompanyHoliday(dateStr) return } if (blockWeekends && isWeekend(dateStr, weekendDays)) return if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return - await toggleEntry(dateStr, selectedUserId || undefined) - }, [companyMode, toggleEntry, toggleCompanyHoliday, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId]) + await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1) + }, [mode, halfDay, toggleEntry, toggleCompanyHoliday, companyHolidaySet, blockWeekends, weekendDays, companyHolidaysEnabled, selectedUserId]) + + // Only half-day cells report a hover, so the tooltip appears exactly when there's + // a half day to explain. Fixed-positioned at the root so no card clips it. + const handleCellHover = useCallback((dateStr: string | null, el: HTMLElement | null) => { + if (!dateStr || !el) { setTip(null); return } + const r = el.getBoundingClientRect() + setTip({ date: dateStr, top: r.top, left: r.left + r.width / 2 }) + }, []) const selectedUser = users.find(u => u.id === selectedUserId) + const tipEntries = tip ? entryMap[tip.date] : undefined + const tipDate = tip ? new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'long' }).format(new Date(tip.date + 'T00:00:00')) : '' return (
@@ -80,6 +99,7 @@ export default function VacayCalendar() { companyHolidaysEnabled={companyHolidaysEnabled} entryMap={entryMap} onCellClick={handleCellClick} + onCellHover={handleCellHover} companyMode={companyMode} blockWeekends={blockWeekends} weekendDays={weekendDays} @@ -89,13 +109,39 @@ export default function VacayCalendar() { ))}
+ {/* Custom half-day tooltip — who is off on this date and how much. Rendered + fixed at the root (not inside a month card) so backdrop-filter stacking + contexts can't clip or occlude it. */} + {tip && tipEntries && tipEntries.length > 0 && ( +
+
+
{tipDate}
+ {tipEntries.map((e, i) => { + const isHalf = (e.fraction ?? 1) === 0.5 + return ( +
+ + {e.person_name} + + {isHalf ? t('vacay.modeHalf') : t('vacay.fullDay')} + +
+ ) + })} +
+
+ )} + {/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
{companyHolidaysEnabled && ( )} + + {/* Divider — the half-day switch is a modifier, not a mode. */} + + +
diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index e37a9d769..78c6ddcaf 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -12,6 +12,7 @@ interface VacayMonthCardProps { companyHolidaysEnabled?: boolean entryMap: Record onCellClick: (date: string) => void + onCellHover?: (date: string | null, el: HTMLElement | null) => void companyMode: boolean blockWeekends: boolean weekendDays?: number[] @@ -21,7 +22,7 @@ interface VacayMonthCardProps { export default function VacayMonthCard({ year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, - onCellClick, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1 + onCellClick, onCellHover, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1 }: VacayMonthCardProps) { const { t, locale } = useTranslation() @@ -101,6 +102,11 @@ export default function VacayMonthCard({ const isToday = dateStr === todayStr const plain = !hasEntries && !holiday && !isCompany + // The fill always shows WHO is off (person colour, split for several) — half + // days keep that fill and get a small corner ½ badge instead, so a half day + // never collides with the two-person diagonal split (#552). + const anyHalf = dayEntries.some(e => (e.fraction ?? 1) === 0.5) + // Cell fill — people win, then company, then holiday (keeps each calendar's own colour). let background = 'transparent' if (dayEntries.length === 1) background = fill(dayEntries[0].person_color) @@ -133,8 +139,14 @@ export default function VacayMonthCard({ cursor: isBlocked ? 'default' : 'pointer', }} onClick={() => onCellClick(dateStr)} - onMouseEnter={e => { if (!isBlocked && plain) e.currentTarget.style.background = 'var(--vg-surf2)' }} - onMouseLeave={e => { if (!isBlocked && plain) e.currentTarget.style.background = background }} + onMouseEnter={e => { + if (!isBlocked && plain) e.currentTarget.style.background = 'var(--vg-surf2)' + if (anyHalf) onCellHover?.(dateStr, e.currentTarget) + }} + onMouseLeave={e => { + if (!isBlocked && plain) e.currentTarget.style.background = background + if (anyHalf) onCellHover?.(null, null) + }} > {/* 3+ people: quadrant overlay at full colour (1 & 2 use the cell background). */} {dayEntries.length === 3 && ( @@ -157,6 +169,12 @@ export default function VacayMonthCard({ )} + {/* Half day (#552): a small orange corner dot, mirroring the blue trip dot. + The hover tooltip spells out who is on a half day. */} + {anyHalf && ( + + )} + (Number.isInteger(n) ? String(n) : n.toFixed(1)) + export default function VacayStats() { const { t } = useTranslation() @@ -84,7 +88,7 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP {s.person_name} {isMe && } - {s.used}/{s.total_available} + {fmtDays(s.used)}/{s.total_available}
{t('vacay.used')}
-
{s.used}
+
{fmtDays(s.used)}
{/* Remaining */}
{t('vacay.remaining')}
-
{s.remaining}
+
{fmtDays(s.remaining)}
{s.carried_over > 0 && ( diff --git a/client/src/mobile/screens/vacay/MVacay.tsx b/client/src/mobile/screens/vacay/MVacay.tsx index bf016409c..a797ccb54 100644 --- a/client/src/mobile/screens/vacay/MVacay.tsx +++ b/client/src/mobile/screens/vacay/MVacay.tsx @@ -241,6 +241,20 @@ export default function MVacay() { {t('vacay.modeCompany')} )} + {/* Divider — the half-day switch modifies the selected person's logging, it isn't a mode. */} + + )} diff --git a/client/src/mobile/screens/vacay/MVacayMonth.tsx b/client/src/mobile/screens/vacay/MVacayMonth.tsx index 03c950fed..063dc3578 100644 --- a/client/src/mobile/screens/vacay/MVacayMonth.tsx +++ b/client/src/mobile/screens/vacay/MVacayMonth.tsx @@ -49,11 +49,20 @@ export default function MVacayMonth({ )} + {visual.half && ( + + )} ) })} diff --git a/client/src/mobile/screens/vacay/useMVacay.ts b/client/src/mobile/screens/vacay/useMVacay.ts index 119ed60a0..84f35801a 100644 --- a/client/src/mobile/screens/vacay/useMVacay.ts +++ b/client/src/mobile/screens/vacay/useMVacay.ts @@ -37,6 +37,8 @@ export function useMVacay() { const [view, setView] = useState('grid') const [month, setMonth] = useState(() => new Date().getMonth()) 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) const [sheet, setSheet] = useState(null) const [tripDates, setTripDates] = useState>(new Set()) @@ -130,8 +132,8 @@ export function useMVacay() { } if (blockWeekends && isWeekend(dateStr, weekendDays)) return if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return - await toggleEntry(dateStr, selectedUserId || undefined) - }, [view, mode, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId]) + await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1) + }, [view, mode, halfDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId]) // Entitlement stepper: never below what is already used this year // (carried-over days cover the difference when used > entitlement). @@ -172,7 +174,7 @@ export function useMVacay() { loading, plan, selectedYear, users, isFused, currentUser, incomingInvites, acceptInvite, declineInvite, - view, month, mode, sheet, setSheet, setMode, setMonth, + view, month, mode, halfDay, setHalfDay, sheet, setSheet, setMode, setMonth, tripDates, tripDotColor, blockWeekends, companyHolidaysEnabled, holidaysEnabled, weekStart, weekendDays, dayCtx, monthNamesShort, monthNameLong, diff --git a/client/src/mobile/screens/vacay/vacayDayModel.ts b/client/src/mobile/screens/vacay/vacayDayModel.ts index b4d3514ce..1a5b381ef 100644 --- a/client/src/mobile/screens/vacay/vacayDayModel.ts +++ b/client/src/mobile/screens/vacay/vacayDayModel.ts @@ -8,6 +8,8 @@ export interface DayVisual { background: string numColor: string boxShadow?: string + // At least one person logged this day as a half day (#552) — the cell shows a ½ badge. + half?: boolean } export interface DayVisualContext { @@ -88,7 +90,12 @@ export function dayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualCont const entries = ctx.entryMap[dateStr] if (entries && entries.length > 0) { const tints = entries.map(e => personTint(e.person_color || FALLBACK_PERSON_COLOR)) - return { background: tints.length === 1 ? tints[0] : splitBackground(tints), numColor: '#101013' } + // The fill still shows WHO is off; half days (#552) keep it and add a corner + // dot, so a half day never looks like a two-person split. Only set `half` when + // true so full days keep their exact { background, numColor } shape. + const visual: DayVisual = { background: tints.length === 1 ? tints[0] : splitBackground(tints), numColor: '#101013' } + if (entries.some(e => (e.fraction ?? 1) === 0.5)) visual.half = true + return visual } const holiday = ctx.holidays[dateStr] if (holiday) { diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index 019d6e540..380110bad 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -65,7 +65,7 @@ interface VacayApi { addYear: (year: number) => Promise removeYear: (year: number) => Promise getEntries: (year: number) => Promise - toggleEntry: (date: string, targetUserId?: number) => Promise + toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1) => Promise toggleCompanyHoliday: (date: string) => Promise getStats: (year: number) => Promise updateStats: (year: number, days: number, targetUserId?: number) => Promise @@ -90,7 +90,7 @@ const api: VacayApi = { addYear: (year) => ax.post('/addons/vacay/years', { year } satisfies VacayAddYearRequest).then((r: AxiosResponse) => r.data), removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then((r: AxiosResponse) => r.data), getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then((r: AxiosResponse) => r.data), - toggleEntry: (date, targetUserId) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId } satisfies VacayToggleEntryRequest).then((r: AxiosResponse) => r.data), + toggleEntry: (date, targetUserId, fraction) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId, fraction } satisfies VacayToggleEntryRequest).then((r: AxiosResponse) => r.data), toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date } satisfies VacayCompanyHolidayRequest).then((r: AxiosResponse) => r.data), getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then((r: AxiosResponse) => r.data), updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId } satisfies VacayUpdateStatsRequest).then((r: AxiosResponse) => r.data), @@ -131,7 +131,7 @@ interface VacayState { addYear: (year: number) => Promise removeYear: (year: number) => Promise loadEntries: (year?: number) => Promise - toggleEntry: (date: string, targetUserId?: number) => Promise + toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1) => Promise toggleCompanyHoliday: (date: string) => Promise loadStats: (year?: number) => Promise updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise @@ -244,23 +244,26 @@ export const useVacayStore = create((set, get) => ({ set({ entries: data.entries, companyHolidays: data.companyHolidays }) }, - toggleEntry: async (date: string, targetUserId?: number) => { - // Optimistic: flip the day locally so the cell reacts instantly, then - // reconcile with the server (stats are server-computed). Roll back on error. + toggleEntry: async (date: string, targetUserId?: number, fraction: 0.5 | 1 = 1) => { + // Optimistic: mirror the server's toggle locally so the cell reacts instantly, + // then reconcile (stats are server-computed). Same fraction clears the day, a + // different one converts it (full ↔ half); an empty day gets the new entry. const userId = targetUserId ?? useAuthStore.getState().user?.id const prevEntries = get().entries const prevStats = get().stats if (userId != null) { - const has = prevEntries.some(e => e.date === date && e.user_id === userId) - if (has) { + const existing = prevEntries.find(e => e.date === date && e.user_id === userId) + if (existing && (existing.fraction ?? 1) === fraction) { set({ entries: prevEntries.filter(e => !(e.date === date && e.user_id === userId)) }) + } else if (existing) { + set({ entries: prevEntries.map(e => (e.date === date && e.user_id === userId ? { ...e, fraction } : e)) }) } else { const u = get().users.find(x => x.id === userId) - set({ entries: [...prevEntries, { date, user_id: userId, person_color: u?.color || undefined, person_name: u?.username }] }) + set({ entries: [...prevEntries, { date, user_id: userId, fraction, person_color: u?.color || undefined, person_name: u?.username }] }) } } try { - await api.toggleEntry(date, targetUserId) + await api.toggleEntry(date, targetUserId, fraction) await get().loadEntries() await get().loadStats() } catch (e) { diff --git a/client/src/types.ts b/client/src/types.ts index 26f0c4217..4451e1542 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -300,6 +300,9 @@ export interface VacayEntry { plan_id?: number person_color?: string person_name?: string + // Portion of a vacation day this entry counts as: 1 = full day, 0.5 = half + // day (#552). Absent on legacy entries, which are treated as full days. + fraction?: number } // Vacay per-user stats row as returned by getStats diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index bdf76d0b4..b33a45c61 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3701,6 +3701,14 @@ function runMigrations(db: Database.Database): void { `); db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_regions_user ON hidden_regions (user_id);'); }, + // Half vacation days (#552): a vacay entry can now count as a full day (1) or + // a half day (0.5) toward the entitlement. Existing entries default to a full + // day, so the entitlement maths are unchanged for everyone already using vacay. + // Guarded so re-running the migration tail (e.g. the crosswalk test) is a no-op. + () => { + const hasFraction = db.prepare("SELECT 1 FROM pragma_table_info('vacay_entries') WHERE name = 'fraction'").get(); + if (!hasFraction) db.exec('ALTER TABLE vacay_entries ADD COLUMN fraction REAL NOT NULL DEFAULT 1'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/mcp/tools/vacay.ts b/server/src/mcp/tools/vacay.ts index 01529c307..f85316c85 100644 --- a/server/src/mcp/tools/vacay.ts +++ b/server/src/mcp/tools/vacay.ts @@ -253,7 +253,7 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st async ({ date }) => { if (isDemoUser(userId)) return demoDenied(); const planId = getActivePlanId(userId); - const result = toggleEntry(userId, planId, date, undefined); + const result = toggleEntry(userId, planId, date, 1, undefined); return ok(result); } ); diff --git a/server/src/nest/plugins/host/create-rpc-host.ts b/server/src/nest/plugins/host/create-rpc-host.ts index 48cb31883..6e3633cde 100644 --- a/server/src/nest/plugins/host/create-rpc-host.ts +++ b/server/src/nest/plugins/host/create-rpc-host.ts @@ -695,7 +695,7 @@ export function createRealRpcHost(id: string, granted: ReadonlySet, rout }, // --- Vacay write: the plan is the ACTING USER's active plan (resolved host-side); // the service broadcasts to plan users itself. --- - vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleEntrySvc(userId, getActivePlanId(userId), date, undefined); }, + vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleEntrySvc(userId, getActivePlanId(userId), date, 1, undefined); }, vacayToggleCompanyHoliday: (userId, date, note) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleCompanyHolidaySvc(getActivePlanId(userId), date, note, undefined); diff --git a/server/src/nest/vacay/vacay.controller.ts b/server/src/nest/vacay/vacay.controller.ts index 949d32ce0..6ef6512a9 100644 --- a/server/src/nest/vacay/vacay.controller.ts +++ b/server/src/nest/vacay/vacay.controller.ts @@ -182,7 +182,7 @@ export class VacayController { @HttpCode(200) toggleEntry( @CurrentUser() user: User, - @Body() body: { date?: string; target_user_id?: number | string }, + @Body() body: { date?: string; target_user_id?: number | string; fraction?: number }, @Headers('x-socket-id') socketId?: string, ) { if (!body.date) { @@ -197,7 +197,7 @@ export class VacayController { } userId = tid; } - return this.vacay.toggleEntry(userId, planId, body.date, socketId); + return this.vacay.toggleEntry(userId, planId, body.date, body.fraction, socketId); } @Post('entries/company-holiday') diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index dd2fc81d2..a82143058 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -86,8 +86,8 @@ export class VacayService { return svc.getEntries(planId, year); } - toggleEntry(userId: number, planId: number, date: string, socketId: string | undefined) { - return svc.toggleEntry(userId, planId, date, socketId); + toggleEntry(userId: number, planId: number, date: string, fraction: unknown, socketId: string | undefined) { + return svc.toggleEntry(userId, planId, date, fraction, socketId); } toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined) { diff --git a/server/src/services/vacayService.ts b/server/src/services/vacayService.ts index a894e73b9..c27f560b9 100644 --- a/server/src/services/vacayService.ts +++ b/server/src/services/vacayService.ts @@ -60,6 +60,27 @@ export interface VacayHolidayCalendar { const holidayCache = new Map(); const CACHE_TTL = 24 * 60 * 60 * 1000; +// --------------------------------------------------------------------------- +// Entitlement helpers +// --------------------------------------------------------------------------- + +/** + * Vacation days a user has used in a year — the SUM of entry fractions, so a + * half day (#552) counts as 0.5 and a full day as 1. Entries predating the + * feature have fraction = 1, so this matches the old COUNT(*) for them. + */ +function usedDays(userId: number, planId: number, yearPrefix: string): number { + const row = db.prepare( + 'SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?' + ).get(userId, planId, yearPrefix) as { used: number }; + return row.used; +} + +/** Coerce an arbitrary input to a supported entry fraction: half (0.5) or full (1). */ +function normalizeFraction(value: unknown): number { + return Number(value) === 0.5 ? 0.5 : 1; +} + // --------------------------------------------------------------------------- // Color palette for auto-assign // --------------------------------------------------------------------------- @@ -251,7 +272,7 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: const yr = years[i].year; const nextYr = years[i + 1].year; for (const u of users) { - const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${yr}-%`) as { count: number }).count; + const used = usedDays(u.id, planId, `${yr}-%`); const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr) as VacayUserYear | undefined; const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0); const carry = Math.max(0, total - used); @@ -505,7 +526,7 @@ export function addYear(planId: number, year: number, socketId: string | undefin if (carryOverEnabled) { const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1) as VacayUserYear | undefined; if (prevConfig) { - const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year - 1}-%`) as { count: number }).count; + const used = usedDays(u.id, planId, `${year - 1}-%`); const total = prevConfig.vacation_days + prevConfig.carried_over; carriedOver = Math.max(0, total - used); } @@ -536,7 +557,7 @@ export function deleteYear(planId: number, year: number, socketId: string | unde if (carryOverEnabled && prevYear) { const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, prevYear.year) as VacayUserYear | undefined; if (prevConfig) { - const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${prevYear.year}-%`) as { count: number }).count; + const used = usedDays(u.id, planId, `${prevYear.year}-%`); const total = prevConfig.vacation_days + prevConfig.carried_over; carry = Math.max(0, total - used); } @@ -565,17 +586,24 @@ export function getEntries(planId: number, year: string) { return { entries, companyHolidays }; } -export function toggleEntry(userId: number, planId: number, date: string, socketId: string | undefined): { action: string } { - const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number } | undefined; +export function toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, socketId?: string): { action: string; fraction?: number } { + const frac = normalizeFraction(fraction); + const existing = db.prepare('SELECT id, fraction FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number; fraction: number } | undefined; if (existing) { - db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id); + // Clicking the same kind of day again clears it; clicking the other kind + // (full over a half, or vice versa) converts it in place. + if (existing.fraction === frac) { + db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id); + notifyPlanUsers(planId, socketId); + return { action: 'removed' }; + } + db.prepare('UPDATE vacay_entries SET fraction = ? WHERE id = ?').run(frac, existing.id); notifyPlanUsers(planId, socketId); - return { action: 'removed' }; - } else { - db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, ''); - notifyPlanUsers(planId, socketId); - return { action: 'added' }; + return { action: 'updated', fraction: frac }; } + db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction) VALUES (?, ?, ?, ?, ?)').run(planId, userId, date, '', frac); + notifyPlanUsers(planId, socketId); + return { action: 'added', fraction: frac }; } export function toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } { @@ -602,7 +630,7 @@ export function getStats(planId: number, year: number) { const users = getPlanUsers(planId); return users.map(u => { - const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year}-%`) as { count: number }).count; + const used = usedDays(u.id, planId, `${year}-%`); const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year) as VacayUserYear | undefined; const vacationDays = config ? config.vacation_days : 30; const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0; diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index c93d31f3c..064e7f2df 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -83,7 +83,7 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => { .set('Cookie', sessionCookie(1)).set('X-Socket-Id', 'sock-7').send({ date: '2026-07-01' }); expect(res.status).toBe(200); expect(res.body).toEqual({ action: 'added' }); - expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 'sock-7'); + expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'sock-7'); }); it('400 on entries/toggle without a date', async () => { diff --git a/server/tests/unit/nest/vacay.controller.test.ts b/server/tests/unit/nest/vacay.controller.test.ts index 5a481f419..36478d648 100644 --- a/server/tests/unit/nest/vacay.controller.test.ts +++ b/server/tests/unit/nest/vacay.controller.test.ts @@ -137,7 +137,13 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () it('toggles for the caller', () => { const toggleEntry = vi.fn().mockReturnValue({ action: 'added' }); expect(makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' }); - expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 'sock'); + expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'sock'); + }); + + it('forwards the half-day fraction (#552)', () => { + const toggleEntry = vi.fn().mockReturnValue({ action: 'added', fraction: 0.5 }); + makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', fraction: 0.5 }, 'sock'); + expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, 'sock'); }); }); diff --git a/server/tests/unit/services/vacayService.test.ts b/server/tests/unit/services/vacayService.test.ts index cf35a69e6..de766a6a3 100644 --- a/server/tests/unit/services/vacayService.test.ts +++ b/server/tests/unit/services/vacayService.test.ts @@ -527,6 +527,44 @@ describe('toggleEntry', () => { .get(user.id, plan.id, '2025-08-02'); expect(row).toBeUndefined(); }); + + it('VACAY-SVC-033a: logs a half day when fraction is 0.5 (#552)', () => { + const { user, plan } = setupUserWithPlan(); + + const result = toggleEntry(user.id, plan.id, '2025-08-05', 0.5); + + expect(result).toMatchObject({ action: 'added', fraction: 0.5 }); + const row = testDb + .prepare('SELECT fraction FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') + .get(user.id, plan.id, '2025-08-05') as { fraction: number }; + expect(row.fraction).toBe(0.5); + }); + + it('VACAY-SVC-033b: converts a full day into a half day in place (action: updated)', () => { + const { user, plan } = setupUserWithPlan(); + + toggleEntry(user.id, plan.id, '2025-08-06', 1); + const result = toggleEntry(user.id, plan.id, '2025-08-06', 0.5); + + expect(result).toMatchObject({ action: 'updated', fraction: 0.5 }); + const row = testDb + .prepare('SELECT fraction FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') + .get(user.id, plan.id, '2025-08-06') as { fraction: number }; + expect(row.fraction).toBe(0.5); + }); + + it('VACAY-SVC-033c: toggling the same half day again clears it (action: removed)', () => { + const { user, plan } = setupUserWithPlan(); + + toggleEntry(user.id, plan.id, '2025-08-07', 0.5); + const result = toggleEntry(user.id, plan.id, '2025-08-07', 0.5); + + expect(result.action).toBe('removed'); + const row = testDb + .prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') + .get(user.id, plan.id, '2025-08-07'); + expect(row).toBeUndefined(); + }); }); // ── toggleCompanyHoliday ────────────────────────────────────────────────────── @@ -701,6 +739,19 @@ describe('getStats', () => { expect(stats[0].used).toBe(2); expect(stats[0].remaining).toBe(28); }); + + it('VACAY-SVC-045a: half days count as 0.5 toward the used total (#552)', () => { + const { user, plan } = setupUserWithPlan(); + const yr = new Date().getFullYear(); + + toggleEntry(user.id, plan.id, `${yr}-09-12`, 1); // full day + toggleEntry(user.id, plan.id, `${yr}-09-13`, 0.5); // half day + + const stats = getStats(plan.id, yr); + + expect(stats[0].used).toBe(1.5); + expect(stats[0].remaining).toBe(28.5); + }); }); // ── applyHolidayCalendars ───────────────────────────────────────────────────── diff --git a/shared/src/i18n/ar/vacay.ts b/shared/src/i18n/ar/vacay.ts index 172d564f5..1140cccd6 100644 --- a/shared/src/i18n/ar/vacay.ts +++ b/shared/src/i18n/ar/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'نهاية الأسبوع', 'vacay.modeVacation': 'إجازة', 'vacay.modeCompany': 'عطلة شركة', + 'vacay.modeHalf': 'نصف يوم', + 'vacay.modeHalfHint': 'تسجيل أنصاف أيام الإجازة', + 'vacay.fullDay': 'يوم كامل', 'vacay.entitlement': 'الاستحقاق', 'vacay.entitlementDays': 'الأيام', 'vacay.used': 'المستخدم', diff --git a/shared/src/i18n/br/vacay.ts b/shared/src/i18n/br/vacay.ts index 5986a5b46..ba06a7a29 100644 --- a/shared/src/i18n/br/vacay.ts +++ b/shared/src/i18n/br/vacay.ts @@ -28,6 +28,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Fim de semana', 'vacay.modeVacation': 'Férias', 'vacay.modeCompany': 'Feriado da empresa', + 'vacay.modeHalf': 'Meio dia', + 'vacay.modeHalfHint': 'Registrar meio dia de férias', + 'vacay.fullDay': 'Dia inteiro', 'vacay.entitlement': 'Direito', 'vacay.entitlementDays': 'Dias', 'vacay.used': 'Usados', diff --git a/shared/src/i18n/ca/vacay.ts b/shared/src/i18n/ca/vacay.ts index 2395b19a6..f3c0f68d4 100644 --- a/shared/src/i18n/ca/vacay.ts +++ b/shared/src/i18n/ca/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Cap de setmana', 'vacay.modeVacation': 'Vacances', 'vacay.modeCompany': "Festiu de l'empresa", + 'vacay.modeHalf': 'Mitja jornada', + 'vacay.modeHalfHint': 'Registra mitges jornades de vacances', + 'vacay.fullDay': 'Jornada completa', 'vacay.entitlement': 'Dret', 'vacay.entitlementDays': 'Dies', 'vacay.used': 'Utilitzats', diff --git a/shared/src/i18n/cs/vacay.ts b/shared/src/i18n/cs/vacay.ts index ee642028e..7197bf63c 100644 --- a/shared/src/i18n/cs/vacay.ts +++ b/shared/src/i18n/cs/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Víkend', 'vacay.modeVacation': 'Dovolená', 'vacay.modeCompany': 'Firemní volno', + 'vacay.modeHalf': 'Půlden', + 'vacay.modeHalfHint': 'Zapsat půldenní dovolenou', + 'vacay.fullDay': 'Celý den', 'vacay.entitlement': 'Nárok', 'vacay.entitlementDays': 'Dní', 'vacay.used': 'Vyčerpáno', diff --git a/shared/src/i18n/de/vacay.ts b/shared/src/i18n/de/vacay.ts index 4219d3a6c..c4681a4b0 100644 --- a/shared/src/i18n/de/vacay.ts +++ b/shared/src/i18n/de/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Wochenende', 'vacay.modeVacation': 'Urlaub', 'vacay.modeCompany': 'Betriebsferien', + 'vacay.modeHalf': 'Halbtag', + 'vacay.modeHalfHint': 'Halbe Urlaubstage eintragen', + 'vacay.fullDay': 'Ganzer Tag', 'vacay.entitlement': 'Urlaubsanspruch', 'vacay.entitlementDays': 'Tage', 'vacay.used': 'Weg', diff --git a/shared/src/i18n/en/vacay.ts b/shared/src/i18n/en/vacay.ts index 4c0b03450..142499b62 100644 --- a/shared/src/i18n/en/vacay.ts +++ b/shared/src/i18n/en/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Weekend', 'vacay.modeVacation': 'Vacation', 'vacay.modeCompany': 'Company Holiday', + 'vacay.modeHalf': 'Half day', + 'vacay.modeHalfHint': 'Log half vacation days', + 'vacay.fullDay': 'Full day', 'vacay.entitlement': 'Entitlement', 'vacay.entitlementDays': 'Days', 'vacay.used': 'Used', diff --git a/shared/src/i18n/es/vacay.ts b/shared/src/i18n/es/vacay.ts index 8e12d9f47..d19aa9d7a 100644 --- a/shared/src/i18n/es/vacay.ts +++ b/shared/src/i18n/es/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Fin de semana', 'vacay.modeVacation': 'Vacaciones', 'vacay.modeCompany': 'Festivo de empresa', + 'vacay.modeHalf': 'Medio día', + 'vacay.modeHalfHint': 'Registrar medios días de vacaciones', + 'vacay.fullDay': 'Día completo', 'vacay.entitlement': 'Derecho', 'vacay.entitlementDays': 'Días', 'vacay.used': 'Usados', diff --git a/shared/src/i18n/fr/vacay.ts b/shared/src/i18n/fr/vacay.ts index 15ba0ff64..f46b7e74b 100644 --- a/shared/src/i18n/fr/vacay.ts +++ b/shared/src/i18n/fr/vacay.ts @@ -28,6 +28,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Week-end', 'vacay.modeVacation': 'Vacances', 'vacay.modeCompany': "Jour férié d'entreprise", + 'vacay.modeHalf': 'Demi-journée', + 'vacay.modeHalfHint': 'Enregistrer des demi-journées de congé', + 'vacay.fullDay': 'Journée complète', 'vacay.entitlement': 'Droits', 'vacay.entitlementDays': 'Jours', 'vacay.used': 'Utilisés', diff --git a/shared/src/i18n/gr/vacay.ts b/shared/src/i18n/gr/vacay.ts index c5c67ee0d..482625f07 100644 --- a/shared/src/i18n/gr/vacay.ts +++ b/shared/src/i18n/gr/vacay.ts @@ -28,6 +28,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Σαββατοκύριακο', 'vacay.modeVacation': 'Διακοπές', 'vacay.modeCompany': 'Αργία Εταιρείας', + 'vacay.modeHalf': 'Μισή μέρα', + 'vacay.modeHalfHint': 'Καταχώριση μισής ημέρας άδειας', + 'vacay.fullDay': 'Ολόκληρη μέρα', 'vacay.entitlement': 'Δικαίωμα', 'vacay.entitlementDays': 'Ημέρες', 'vacay.used': 'Χρησιμοποιημένες', diff --git a/shared/src/i18n/hu/vacay.ts b/shared/src/i18n/hu/vacay.ts index 263c3a452..01960bbb4 100644 --- a/shared/src/i18n/hu/vacay.ts +++ b/shared/src/i18n/hu/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Hétvége', 'vacay.modeVacation': 'Szabadság', 'vacay.modeCompany': 'Céges szabadnap', + 'vacay.modeHalf': 'Fél nap', + 'vacay.modeHalfHint': 'Fél szabadnapok rögzítése', + 'vacay.fullDay': 'Egész nap', 'vacay.entitlement': 'Szabadságkeret', 'vacay.entitlementDays': 'nap', 'vacay.used': 'Felhasznált', diff --git a/shared/src/i18n/id/vacay.ts b/shared/src/i18n/id/vacay.ts index 3d230fcca..3e8195719 100644 --- a/shared/src/i18n/id/vacay.ts +++ b/shared/src/i18n/id/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Akhir Pekan', 'vacay.modeVacation': 'Cuti', 'vacay.modeCompany': 'Hari Libur Perusahaan', + 'vacay.modeHalf': 'Setengah hari', + 'vacay.modeHalfHint': 'Catat setengah hari cuti', + 'vacay.fullDay': 'Sehari penuh', 'vacay.entitlement': 'Jatah Cuti', 'vacay.entitlementDays': 'Hari', 'vacay.used': 'Terpakai', diff --git a/shared/src/i18n/it/vacay.ts b/shared/src/i18n/it/vacay.ts index c2af62412..244854027 100644 --- a/shared/src/i18n/it/vacay.ts +++ b/shared/src/i18n/it/vacay.ts @@ -28,6 +28,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Weekend', 'vacay.modeVacation': 'Ferie', 'vacay.modeCompany': 'Ferie aziendali', + 'vacay.modeHalf': 'Mezza giornata', + 'vacay.modeHalfHint': 'Registra mezze giornate di ferie', + 'vacay.fullDay': 'Giornata intera', 'vacay.entitlement': 'Disponibilità', 'vacay.entitlementDays': 'Giorni', 'vacay.used': 'Usati', diff --git a/shared/src/i18n/ja/vacay.ts b/shared/src/i18n/ja/vacay.ts index abdf5730b..205b3f2df 100644 --- a/shared/src/i18n/ja/vacay.ts +++ b/shared/src/i18n/ja/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': '週末', 'vacay.modeVacation': '休暇', 'vacay.modeCompany': '会社休日', + 'vacay.modeHalf': '半休', + 'vacay.modeHalfHint': '半休を登録', + 'vacay.fullDay': '全休', 'vacay.entitlement': '付与日数', 'vacay.entitlementDays': '日', 'vacay.used': '使用済み', diff --git a/shared/src/i18n/ko/vacay.ts b/shared/src/i18n/ko/vacay.ts index 03cc5cd9c..5d1c33712 100644 --- a/shared/src/i18n/ko/vacay.ts +++ b/shared/src/i18n/ko/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': '주말', 'vacay.modeVacation': '휴가', 'vacay.modeCompany': '회사 휴일', + 'vacay.modeHalf': '반차', + 'vacay.modeHalfHint': '반차 등록', + 'vacay.fullDay': '종일', 'vacay.entitlement': '휴가 일수', 'vacay.entitlementDays': '일', 'vacay.used': '사용', diff --git a/shared/src/i18n/nl/vacay.ts b/shared/src/i18n/nl/vacay.ts index c6c04cb84..54ca3be29 100644 --- a/shared/src/i18n/nl/vacay.ts +++ b/shared/src/i18n/nl/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Weekend', 'vacay.modeVacation': 'Vakantie', 'vacay.modeCompany': 'Bedrijfsvakantie', + 'vacay.modeHalf': 'Halve dag', + 'vacay.modeHalfHint': 'Halve vakantiedagen invoeren', + 'vacay.fullDay': 'Hele dag', 'vacay.entitlement': 'Recht', 'vacay.entitlementDays': 'Dagen', 'vacay.used': 'Gebruikt', diff --git a/shared/src/i18n/pl/vacay.ts b/shared/src/i18n/pl/vacay.ts index e82708d40..40f256dec 100644 --- a/shared/src/i18n/pl/vacay.ts +++ b/shared/src/i18n/pl/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Weekendowy', 'vacay.modeVacation': 'Urlop', 'vacay.modeCompany': 'Urlop firmowy', + 'vacay.modeHalf': 'Pół dnia', + 'vacay.modeHalfHint': 'Zapisz pół dnia urlopu', + 'vacay.fullDay': 'Cały dzień', 'vacay.entitlement': 'Wymiar', 'vacay.entitlementDays': 'Dni', 'vacay.used': 'Wykorzystane', diff --git a/shared/src/i18n/ru/vacay.ts b/shared/src/i18n/ru/vacay.ts index 7ebd31518..78a76c442 100644 --- a/shared/src/i18n/ru/vacay.ts +++ b/shared/src/i18n/ru/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Выходные', 'vacay.modeVacation': 'Отпуск', 'vacay.modeCompany': 'Корпоративный выходной', + 'vacay.modeHalf': 'Полдня', + 'vacay.modeHalfHint': 'Отмечать полдня отпуска', + 'vacay.fullDay': 'Полный день', 'vacay.entitlement': 'Право на отпуск', 'vacay.entitlementDays': 'Дни', 'vacay.used': 'Использовано', diff --git a/shared/src/i18n/sv/vacay.ts b/shared/src/i18n/sv/vacay.ts index b28c31821..ab86617f2 100644 --- a/shared/src/i18n/sv/vacay.ts +++ b/shared/src/i18n/sv/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Helgen', 'vacay.modeVacation': 'Semester', 'vacay.modeCompany': 'Företagshelg', + 'vacay.modeHalf': 'Halvdag', + 'vacay.modeHalfHint': 'Registrera halva semesterdagar', + 'vacay.fullDay': 'Heldag', 'vacay.entitlement': 'Rättighet', 'vacay.entitlementDays': 'Dagar', 'vacay.used': 'Använt', diff --git a/shared/src/i18n/tr/vacay.ts b/shared/src/i18n/tr/vacay.ts index f1b169589..30f917a33 100644 --- a/shared/src/i18n/tr/vacay.ts +++ b/shared/src/i18n/tr/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Hafta sonu', 'vacay.modeVacation': 'Tatil', 'vacay.modeCompany': 'Şirket Tatili', + 'vacay.modeHalf': 'Yarım gün', + 'vacay.modeHalfHint': 'Yarım izin günü ekle', + 'vacay.fullDay': 'Tam gün', 'vacay.entitlement': 'Yetki', 'vacay.entitlementDays': 'Günler', 'vacay.used': 'Kullanılmış', diff --git a/shared/src/i18n/uk/vacay.ts b/shared/src/i18n/uk/vacay.ts index 68f6470f8..ff61b11cf 100644 --- a/shared/src/i18n/uk/vacay.ts +++ b/shared/src/i18n/uk/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Вихідні', 'vacay.modeVacation': 'Відпустка', 'vacay.modeCompany': 'Корпоративний вихідний', + 'vacay.modeHalf': 'Пів дня', + 'vacay.modeHalfHint': 'Позначати пів дня відпустки', + 'vacay.fullDay': 'Повний день', 'vacay.entitlement': 'Право на відпустку', 'vacay.entitlementDays': 'Дні', 'vacay.used': 'Використано', diff --git a/shared/src/i18n/vi/vacay.ts b/shared/src/i18n/vi/vacay.ts index bac1cd6ef..9ffbb6dc6 100644 --- a/shared/src/i18n/vi/vacay.ts +++ b/shared/src/i18n/vi/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': 'Ngày cuối tuần', 'vacay.modeVacation': 'Kì nghỉ', 'vacay.modeCompany': 'Kỳ nghỉ của công ty', + 'vacay.modeHalf': 'Nửa ngày', + 'vacay.modeHalfHint': 'Ghi nửa ngày nghỉ', + 'vacay.fullDay': 'Cả ngày', 'vacay.entitlement': 'Quyền lợi', 'vacay.entitlementDays': 'Ngày', 'vacay.used': 'Đã dùng', diff --git a/shared/src/i18n/zh-TW/vacay.ts b/shared/src/i18n/zh-TW/vacay.ts index c2eda2bae..b173019b4 100644 --- a/shared/src/i18n/zh-TW/vacay.ts +++ b/shared/src/i18n/zh-TW/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': '週末', 'vacay.modeVacation': '休假', 'vacay.modeCompany': '公司假日', + 'vacay.modeHalf': '半天', + 'vacay.modeHalfHint': '記錄半天假期', + 'vacay.fullDay': '全天', 'vacay.entitlement': '年假額度', 'vacay.entitlementDays': '天', 'vacay.used': '已用', diff --git a/shared/src/i18n/zh/vacay.ts b/shared/src/i18n/zh/vacay.ts index 804927f2b..93c1eec52 100644 --- a/shared/src/i18n/zh/vacay.ts +++ b/shared/src/i18n/zh/vacay.ts @@ -27,6 +27,9 @@ const vacay: TranslationStrings = { 'vacay.weekend': '周末', 'vacay.modeVacation': '休假', 'vacay.modeCompany': '公司假日', + 'vacay.modeHalf': '半天', + 'vacay.modeHalfHint': '记录半天假期', + 'vacay.fullDay': '全天', 'vacay.entitlement': '年假额度', 'vacay.entitlementDays': '天', 'vacay.used': '已用', diff --git a/shared/src/vacay/vacay.schema.ts b/shared/src/vacay/vacay.schema.ts index 0d3fb7f3b..55d1d8b94 100644 --- a/shared/src/vacay/vacay.schema.ts +++ b/shared/src/vacay/vacay.schema.ts @@ -48,6 +48,8 @@ export type VacayAddYearRequest = z.infer; export const vacayToggleEntryRequestSchema = z.object({ date: z.string().min(1), target_user_id: z.union([z.number(), z.string()]).optional(), + // Half vacation days (#552): 0.5 logs a half day, 1 (or omitted) a full day. + fraction: z.union([z.literal(0.5), z.literal(1)]).optional(), }); export type VacayToggleEntryRequest = z.infer;