feat(vacay): half vacation days (#552) (#1631)

* feat(vacay): support half vacation days (#552)

Log a vacation day as a half day (0.5) as well as a full day and count it
as such against the entitlement. A "Half day" mode joins the calendar
toolbar on desktop and mobile; half days render as a diagonal half-fill in
the day cell, and the used/remaining totals carry the fraction.

Closes #552

* refactor(vacay): half-day as a per-person toggle with a corner badge

The diagonal half-fill collided with the two-person split — one person's
half day looked like two people off. Drop the diagonal: the cell fill keeps
showing who is off, a half day gets a small corner ½ badge, and a hover
tooltip lists who is off on that date and how much (only when a half day is
present). Logging moves from a third mode to a Half-day toggle that applies
to the selected person, on desktop and mobile.

* feat(vacay): mark half days with an orange corner dot

Swap the ½ badge for a small orange dot in the bottom-right corner, mirroring
the blue trip dot — quieter in the grid, and the hover tooltip still spells
out who is on a half day.

* fix(vacay): make the fraction migration idempotent

The crosswalk test replays the migration tail (rewind + re-run), which expects
every later migration to be idempotent. Guard the vacay_entries ADD COLUMN
behind a pragma_table_info check so the second run is a no-op. Also only set
dayVisual's `half` flag when true, so full days keep their exact
{ background, numColor } shape for the mobile day-model unit test.

* style(vacay): nudge the mobile day dots slightly inward

The blue trip dot and orange half-day dot sat right in the cell corner on
mobile; move both a touch toward the centre so they read cleaner.
This commit is contained in:
Maurice
2026-07-20 23:38:47 +02:00
committed by GitHub
parent a3cb783df7
commit 7528706d00
43 changed files with 372 additions and 56 deletions
@@ -97,10 +97,10 @@ describe('VacayCalendar', () => {
render(<VacayCalendar />)
// 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(<VacayCalendar />)
// 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 () => {
+76 -10
View File
@@ -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<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)
const companyMode = mode === 'company'
const [tripDates, setTripDates] = useState<Set<string>>(new Set())
const [tip, setTip] = useState<HoverTip | null>(null)
useEffect(() => {
let cancelled = false
@@ -42,7 +51,7 @@ export default function VacayCalendar() {
}, [companyHolidays])
const entryMap = useMemo(() => {
const map = {}
const map: Record<string, VacayEntry[]> = {}
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<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 (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 (
<div>
@@ -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() {
))}
</div>
{/* 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 && (
<div
className="vg-card rounded-xl"
style={{ position: 'fixed', top: tip.top - 9, left: tip.left, transform: 'translate(-50%, -100%)', zIndex: 80, pointerEvents: 'none' }}
>
<div style={{ padding: '8px 11px', minWidth: 132 }}>
<div className="capitalize" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.02em', color: 'var(--vg-ink3)', marginBottom: 5 }}>{tipDate}</div>
{tipEntries.map((e, i) => {
const isHalf = (e.fraction ?? 1) === 0.5
return (
<div key={i} className="flex items-center gap-2" style={{ marginTop: i ? 4 : 0 }}>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: e.person_color || '#6366f1' }} />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--vg-ink)' }}>{e.person_name}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 12, fontSize: 11, fontWeight: 700, color: isHalf ? 'var(--vg-ink)' : 'var(--vg-ink3)' }}>
{isHalf ? t('vacay.modeHalf') : t('vacay.fullDay')}
</span>
</div>
)
})}
</div>
</div>
)}
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
<div className="vg-card flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-full">
<button
onClick={() => setCompanyMode(false)}
onClick={() => setMode('vacation')}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-full text-[11px] sm:text-xs font-semibold transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={!companyMode
style={mode === 'vacation'
? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' }
: { background: 'transparent', color: 'var(--vg-ink2)' }}>
<MousePointer2 size={13} />
@@ -104,7 +150,7 @@ export default function VacayCalendar() {
</button>
{companyHolidaysEnabled && (
<button
onClick={() => setCompanyMode(true)}
onClick={() => setMode('company')}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-full text-[11px] sm:text-xs font-semibold transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={companyMode
? { background: '#d97706', color: '#fff' }
@@ -113,6 +159,26 @@ export default function VacayCalendar() {
{t('vacay.modeCompany')}
</button>
)}
{/* Divider — the half-day switch is a modifier, not a mode. */}
<span className="w-px self-stretch my-0.5" style={{ background: 'var(--vg-line)' }} aria-hidden />
<button
onClick={() => setHalfDay(v => !v)}
title={t('vacay.modeHalfHint')}
aria-pressed={halfDay}
className="flex items-center gap-1.5 pl-2 pr-2.5 sm:pl-2.5 sm:pr-3 py-1.5 rounded-full text-[11px] sm:text-xs font-semibold transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={halfDay
? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' }
: { background: 'transparent', color: 'var(--vg-ink3)' }}>
<span className="flex items-center justify-center rounded-full shrink-0 transition-colors"
style={{
width: 15, height: 15, fontSize: 10, fontWeight: 800, lineHeight: 1,
background: halfDay ? 'var(--vg-bg)' : 'color-mix(in srgb, var(--vg-ink3) 22%, transparent)',
color: halfDay ? 'var(--vg-ink)' : 'var(--vg-ink2)',
}} aria-hidden>½</span>
{t('vacay.modeHalf')}
</button>
</div>
</div>
</div>
+21 -3
View File
@@ -12,6 +12,7 @@ interface VacayMonthCardProps {
companyHolidaysEnabled?: boolean
entryMap: Record<string, VacayEntry[]>
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({
<span className="absolute top-1 right-1 w-[5px] h-[5px] rounded-full z-[2] bg-[#3b82f6]" style={{ boxShadow: '0 0 0 1.5px var(--vg-surf)' }} />
)}
{/* 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 && (
<span className="absolute bottom-1 right-1 w-[5px] h-[5px] rounded-full z-[3] bg-[#f97316]" style={{ boxShadow: '0 0 0 1.5px var(--vg-surf)' }} aria-hidden />
)}
<span className="relative z-[1]" style={{
fontFamily: 'var(--font-subtext)',
fontSize: 12,
+7 -3
View File
@@ -7,6 +7,10 @@ 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))
export default function VacayStats() {
const { t } = useTranslation()
@@ -84,7 +88,7 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
{s.person_name}
</span>
{isMe && <VacayBadge label={t('vacay.you')} />}
<span className="tabular-nums ml-auto" style={{ fontFamily: 'var(--font-subtext)', fontSize: 10.5, color: 'var(--vg-ink3)' }}>{s.used}/{s.total_available}</span>
<span className="tabular-nums ml-auto" style={{ fontFamily: 'var(--font-subtext)', fontSize: 10.5, color: 'var(--vg-ink3)' }}>{fmtDays(s.used)}/{s.total_available}</span>
</div>
<div className="overflow-hidden" style={{ height: 6, borderRadius: 99, background: 'var(--vg-surf2)', marginBottom: 7 }}>
<div
@@ -119,12 +123,12 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
{/* Used */}
<div style={{ padding: '6px 9px', borderRadius: 10, background: 'var(--vg-surf2)' }}>
<div style={{ fontSize: 10, marginBottom: 2, color: 'var(--vg-ink3)', height: 13, lineHeight: '13px' }}>{t('vacay.used')}</div>
<div style={{ ...tileValue, color: 'var(--vg-ink)' }}>{s.used}</div>
<div style={{ ...tileValue, color: 'var(--vg-ink)' }}>{fmtDays(s.used)}</div>
</div>
{/* Remaining */}
<div style={{ padding: '6px 9px', borderRadius: 10, background: 'var(--vg-surf2)' }}>
<div style={{ fontSize: 10, marginBottom: 2, color: 'var(--vg-ink3)', height: 13, lineHeight: '13px' }}>{t('vacay.remaining')}</div>
<div style={{ ...tileValue, color: remainingColor }}>{s.remaining}</div>
<div style={{ ...tileValue, color: remainingColor }}>{fmtDays(s.remaining)}</div>
</div>
</div>
{s.carried_over > 0 && (
@@ -241,6 +241,20 @@ export default function MVacay() {
{t('vacay.modeCompany')}
</button>
)}
{/* Divider — the half-day switch modifies the selected person's logging, it isn't a mode. */}
<span className="mx-[1px] h-5 w-px self-center bg-[color:var(--m-shbr)]" aria-hidden />
<button
type="button"
onClick={() => v.setHalfDay(h => !h)}
aria-pressed={v.halfDay}
aria-label={t('vacay.modeHalf')}
title={t('vacay.modeHalfHint')}
className={`flex h-9 w-9 flex-none items-center justify-center rounded-full text-[0.9375rem] font-extrabold leading-none ${
v.halfDay ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
½
</button>
</div>
</div>
)}
@@ -49,11 +49,20 @@ export default function MVacayMonth({
<span
aria-hidden
className={`absolute rounded-full ${
mini ? 'right-[1px] top-[1px] h-[3px] w-[3px]' : 'right-[2px] top-[2px] h-[5px] w-[5px]'
mini ? 'right-[2px] top-[2px] h-[3px] w-[3px]' : 'right-[4px] top-[4px] h-[5px] w-[5px]'
}`}
style={{ background: tripDotColor }}
/>
)}
{visual.half && (
<span
aria-hidden
className={`absolute rounded-full ${
mini ? 'right-[2px] bottom-[2px] h-[3px] w-[3px]' : 'right-[4px] bottom-[4px] h-[5px] w-[5px]'
}`}
style={{ background: '#f97316' }}
/>
)}
</button>
)
})}
+5 -3
View File
@@ -37,6 +37,8 @@ export function useMVacay() {
const [view, setView] = useState<MVacayView>('grid')
const [month, setMonth] = useState(() => new Date().getMonth())
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)
const [sheet, setSheet] = useState<MVacaySheet>(null)
const [tripDates, setTripDates] = useState<Set<string>>(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,
@@ -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) {
+13 -10
View File
@@ -65,7 +65,7 @@ interface VacayApi {
addYear: (year: number) => Promise<VacayYearsResponse>
removeYear: (year: number) => Promise<VacayYearsResponse>
getEntries: (year: number) => Promise<VacayEntriesResponse>
toggleEntry: (date: string, targetUserId?: number) => Promise<unknown>
toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1) => Promise<unknown>
toggleCompanyHoliday: (date: string) => Promise<unknown>
getStats: (year: number) => Promise<VacayStatsResponse>
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
@@ -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<void>
removeYear: (year: number) => Promise<void>
loadEntries: (year?: number) => Promise<void>
toggleEntry: (date: string, targetUserId?: number) => Promise<void>
toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1) => Promise<void>
toggleCompanyHoliday: (date: string) => Promise<void>
loadStats: (year?: number) => Promise<void>
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
@@ -244,23 +244,26 @@ export const useVacayStore = create<VacayState>((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) {
+3
View File
@@ -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
+8
View File
@@ -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) {
+1 -1
View File
@@ -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);
}
);
@@ -695,7 +695,7 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>, 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);
+2 -2
View File
@@ -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')
+2 -2
View File
@@ -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) {
+40 -12
View File
@@ -60,6 +60,27 @@ export interface VacayHolidayCalendar {
const holidayCache = new Map<string, { data: unknown; time: number }>();
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;
+1 -1
View File
@@ -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 () => {
@@ -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');
});
});
@@ -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 ─────────────────────────────────────────────────────
+3
View File
@@ -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': 'المستخدم',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': 'Χρησιμοποιημένες',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': '使用済み',
+3
View File
@@ -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': '사용',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': 'Использовано',
+3
View File
@@ -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',
+3
View File
@@ -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ış',
+3
View File
@@ -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': 'Використано',
+3
View File
@@ -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',
+3
View File
@@ -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': '已用',
+3
View File
@@ -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': '已用',
+2
View File
@@ -48,6 +48,8 @@ export type VacayAddYearRequest = z.infer<typeof vacayAddYearRequestSchema>;
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<typeof vacayToggleEntryRequestSchema>;