diff --git a/client/src/components/Vacay/VacayStats.test.tsx b/client/src/components/Vacay/VacayStats.test.tsx
index d4607c4be..805b608b8 100644
--- a/client/src/components/Vacay/VacayStats.test.tsx
+++ b/client/src/components/Vacay/VacayStats.test.tsx
@@ -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()
+ 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()
+ await user.click(screen.getByRole('button', { expanded: true }))
+ unmount()
+
+ render()
+ 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()
+ await user.click(screen.getByRole('button', { expanded: false }))
+ expect(screen.getByText('Alice')).toBeInTheDocument()
+ unmount()
+
+ render()
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ expect(screen.getByText(/Jul 2026\s*–\s*Jun 2027/)).toBeInTheDocument()
+ })
+})
+
describe('VacayStats', () => {
it('FE-COMP-VACAYSTATS-001: Shows empty state when no stats', () => {
render()
diff --git a/client/src/components/Vacay/VacayStats.tsx b/client/src/components/Vacay/VacayStats.tsx
index 69a541995..604918683 100644
--- a/client/src/components/Vacay/VacayStats.tsx
+++ b/client/src/components/Vacay/VacayStats.tsx
@@ -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 (
-
-
- {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. */}
+
)
}
@@ -53,11 +97,12 @@ interface StatCardProps {
isMe: boolean
canEdit: boolean
selectedYear: number
+ isShiftedYear: boolean
onSave: (userId: number, year: number, days: number) => Promise
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
+ )}
+ {/* Comp / flex days (#1074) are logged but never deducted, so they sit
+ beside the tiles rather than inside the used/left arithmetic. */}
+ {compUsed > 0 && (
+