mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
* 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:
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 && (
|
||||
|
||||
Reference in New Issue
Block a user