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 && (