feat(vacay): show comp days in the stats and fold the card away (#1074)

The server has returned comp_used since the leave type landed, but nothing
showed it — comp days simply vanished from the sidebar. They now sit beside
the tiles rather than inside the used/left arithmetic, which is the point of
them.

Carry-over is labelled from the window the server counted that row over, not
from the viewer's own setting, so a fused plan with mixed leave years does not
tell one member their days came "from 2025" when they came from a period that
started in July.

The card also folds now. It is the longest thing in the sidebar once several
people are fused in, and the state belongs to the person looking at it, so it
lives in localStorage rather than on the plan.
This commit is contained in:
Maurice
2026-07-25 11:50:50 +02:00
committed by Maurice
parent d984e09473
commit f1d520bbc2
2 changed files with 187 additions and 14 deletions
@@ -34,6 +34,110 @@ beforeEach(() => {
})
})
describe('VacayStats — folding the sidebar card', () => {
beforeEach(() => localStorage.clear())
it('FE-COMP-VACAYSTATS-030: starts expanded and folds the stat cards away on click', async () => {
const user = userEvent.setup()
seedStore(useVacayStore, { stats: [buildStat()] })
render(<VacayStats />)
expect(screen.getByText('Alice')).toBeInTheDocument()
await user.click(screen.getByRole('button', { expanded: true }))
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
// The heading stays, so the card can be opened again.
expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument()
})
it('FE-COMP-VACAYSTATS-031: remembers the folded state across reloads', async () => {
const user = userEvent.setup()
seedStore(useVacayStore, { stats: [buildStat()] })
const { unmount } = render(<VacayStats />)
await user.click(screen.getByRole('button', { expanded: true }))
unmount()
render(<VacayStats />)
expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument()
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
})
it('FE-COMP-VACAYSTATS-032: reopens and persists that too', async () => {
const user = userEvent.setup()
localStorage.setItem('vacay-stats-collapsed', '1')
seedStore(useVacayStore, { stats: [buildStat()] })
const { unmount } = render(<VacayStats />)
await user.click(screen.getByRole('button', { expanded: false }))
expect(screen.getByText('Alice')).toBeInTheDocument()
unmount()
render(<VacayStats />)
expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument()
})
})
describe('VacayStats — comp days (#1074) and the leave-year window (#737)', () => {
it('FE-COMP-VACAYSTATS-020: shows a comp/flex badge when comp days were logged', () => {
seedStore(useVacayStore, { stats: [buildStat({ comp_used: 2 })] })
render(<VacayStats />)
expect(screen.getByText('2 comp / flex')).toBeInTheDocument()
})
it('FE-COMP-VACAYSTATS-021: renders a fractional comp total with one decimal', () => {
seedStore(useVacayStore, { stats: [buildStat({ comp_used: 1.5 })] })
render(<VacayStats />)
expect(screen.getByText('1.5 comp / flex')).toBeInTheDocument()
})
it('FE-COMP-VACAYSTATS-022: hides the badge when no comp days were logged', () => {
seedStore(useVacayStore, { stats: [buildStat({ comp_used: 0 })] })
render(<VacayStats />)
expect(screen.queryByText(/comp \/ flex/)).not.toBeInTheDocument()
})
it('FE-COMP-VACAYSTATS-023: names the carry-over source by year on a calendar leave year', () => {
seedStore(useVacayStore, { stats: [buildStat({ carried_over: 3 })], selectedYear: 2025 })
render(<VacayStats />)
expect(screen.getByText('+3 from 2024')).toBeInTheDocument()
})
it('FE-COMP-VACAYSTATS-024: says "previous period" instead once the leave year is shifted', () => {
seedStore(useVacayStore, {
stats: [buildStat({ carried_over: 3 })],
selectedYear: 2026,
yearSettings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null },
})
render(<VacayStats />)
expect(screen.getByText('+3 from previous period')).toBeInTheDocument()
expect(screen.queryByText('+3 from 2025')).not.toBeInTheDocument()
})
it('FE-COMP-VACAYSTATS-025: spells the active window out under the heading when it is shifted', () => {
seedStore(useVacayStore, {
stats: [buildStat()],
selectedYear: 2026,
yearSettings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null },
})
render(<VacayStats />)
expect(screen.getByText(/Jul 2026\s*–\s*Jun 2027/)).toBeInTheDocument()
})
})
describe('VacayStats', () => {
it('FE-COMP-VACAYSTATS-001: Shows empty state when no stats', () => {
render(<VacayStats />)
+83 -14
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { Pencil } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { ChevronDown, Clock, Pencil } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { windowMonths } from '../../vacay/yearWindow'
import type { VacayStat, TranslationFn } from '../../types'
import { NumericInput } from '../shared/NumericInput'
import VacayBadge from './VacayBadge'
@@ -11,23 +12,65 @@ import VacayBadge from './VacayBadge'
// 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))
// The sidebar card can be folded away; the choice is a personal view preference,
// so it lives in localStorage rather than on the plan.
const COLLAPSED_KEY = 'vacay-stats-collapsed'
export default function VacayStats() {
const { t } = useTranslation()
const { stats, selectedYear, loadStats, updateVacationDays, isFused } = useVacayStore()
const { t, locale } = useTranslation()
const { stats, selectedYear, loadStats, updateVacationDays, isFused, yearSettings } = useVacayStore()
const { user: currentUser } = useAuthStore()
const isShiftedYear = yearSettings.year_type !== 'calendar'
const [collapsed, setCollapsed] = useState(() => {
try { return localStorage.getItem(COLLAPSED_KEY) === '1' } catch { return false }
})
const toggleCollapsed = () => {
setCollapsed(prev => {
const next = !prev
try { localStorage.setItem(COLLAPSED_KEY, next ? '1' : '0') } catch { /* storage unavailable */ }
return next
})
}
useEffect(() => { loadStats(selectedYear) }, [selectedYear])
// A shifted leave year (#737) doesn't line up with the year number, so spell the
// period out — "2026" alone would read as Jan–Dec.
const windowLabel = useMemo(() => {
if (!isShiftedYear) return null
const months = windowMonths(selectedYear, yearSettings)
const fmt = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' })
return `${fmt.format(new Date(months[0].year, months[0].month, 1))} – ${fmt.format(new Date(months[11].year, months[11].month, 1))}`
}, [isShiftedYear, selectedYear, yearSettings, locale])
return (
<div className="vg-card rounded-[22px]" style={{ padding: '14px 18px' }}>
<div className="mb-2.5">
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.14em', color: 'var(--vg-ink3)' }}>
{t('vacay.entitlement')} {selectedYear}
{/* The whole header is the fold handle — the sidebar gets long once several
people are fused in, and this card is the one you stop needing. */}
<button
onClick={toggleCollapsed}
aria-expanded={!collapsed}
className={`w-full flex items-start gap-2 text-left ${collapsed ? '' : 'mb-2.5'}`}
style={{ background: 'transparent', border: 0, padding: 0, cursor: 'pointer', font: 'inherit' }}
>
<span className="min-w-0 flex-1">
<span className="block" style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.14em', color: 'var(--vg-ink3)' }}>
{t('vacay.entitlement')} {selectedYear}
</span>
{windowLabel && (
<span className="block mt-0.5" style={{ fontSize: 10.5, color: 'var(--vg-ink3)' }}>{windowLabel}</span>
)}
</span>
</div>
<ChevronDown
size={14}
className="shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ color: 'var(--vg-ink3)', marginTop: 1, transform: collapsed ? 'rotate(-90deg)' : 'none' }}
/>
</button>
{stats.length === 0 ? (
{!collapsed && (stats.length === 0 ? (
<p className="text-[11px] text-center py-3" style={{ color: 'var(--vg-ink3)' }}>{t('vacay.noData')}</p>
) : (
<div className="flex flex-col gap-2">
@@ -38,12 +81,13 @@ export default function VacayStats() {
isMe={s.user_id === currentUser?.id}
canEdit={s.user_id === currentUser?.id || isFused}
selectedYear={selectedYear}
isShiftedYear={isShiftedYear}
onSave={updateVacationDays}
t={t}
/>
))}
</div>
)}
))}
</div>
)
}
@@ -53,11 +97,12 @@ interface StatCardProps {
isMe: boolean
canEdit: boolean
selectedYear: number
isShiftedYear: boolean
onSave: (userId: number, year: number, days: number) => Promise<void>
t: TranslationFn
}
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardProps) {
function StatCard({ stat: s, isMe, canEdit, selectedYear, isShiftedYear, onSave, t }: StatCardProps) {
const [editing, setEditing] = useState(false)
// Holds the entitlement-day value while editing: a number on load, a string
// once the user types into the number input.
@@ -78,6 +123,18 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
}
const remainingColor = s.remaining < 0 ? '#ef4444' : s.remaining <= 3 ? '#f59e0b' : '#22c55e'
const compUsed = s.comp_used ?? 0
// Each member can be on their own leave year (#737), so the row is labelled from
// the window the server counted THIS row over. Only when that is missing (an
// older server) does it fall back to the viewer's own setting.
const rowShifted = s.window_start
? !s.window_start.endsWith('-01-01')
: isShiftedYear
// With a shifted leave year the carry-over comes from the previous period, which
// is not the previous calendar year — name it that way instead.
const carriedOverLabel = rowShifted
? t('vacay.carriedOverPrevPeriod')
: t('vacay.carriedOver', { year: selectedYear - 1 })
const tileValue = { fontFamily: 'var(--font-subtext)', fontSize: 14, fontWeight: 700, height: 16, lineHeight: '16px' } as const
return (
@@ -131,9 +188,21 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
<div style={{ ...tileValue, color: remainingColor }}>{fmtDays(s.remaining)}</div>
</div>
</div>
{s.carried_over > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[rgba(245,158,11,0.08)] border border-[rgba(245,158,11,0.15)]" style={{ marginTop: 10 }}>
<span className="text-[10px] text-[#d97706]">+{s.carried_over} {t('vacay.carriedOver', { year: selectedYear - 1 })}</span>
{(s.carried_over > 0 || compUsed > 0) && (
<div className="flex flex-wrap items-center gap-1.5" style={{ marginTop: 10 }}>
{s.carried_over > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[rgba(245,158,11,0.08)] border border-[rgba(245,158,11,0.15)]">
<span className="text-[10px] text-[#d97706]">+{s.carried_over} {carriedOverLabel}</span>
</div>
)}
{/* Comp / flex days (#1074) are logged but never deducted, so they sit
beside the tiles rather than inside the used/left arithmetic. */}
{compUsed > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)' }}>
<Clock size={9} style={{ color: 'var(--vg-ink3)' }} />
<span className="text-[10px]" style={{ color: 'var(--vg-ink2)' }}>{t('vacay.compUsedCount', { count: fmtDays(compUsed) })}</span>
</div>
)}
</div>
)}
</div>