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. */} +
+ + - {stats.length === 0 ? ( + {!collapsed && (stats.length === 0 ? (

{t('vacay.noData')}

) : (
@@ -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} /> ))}
- )} + ))}
) } @@ -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
{fmtDays(s.remaining)}
- {s.carried_over > 0 && ( -
- +{s.carried_over} {t('vacay.carriedOver', { year: selectedYear - 1 })} + {(s.carried_over > 0 || compUsed > 0) && ( +
+ {s.carried_over > 0 && ( +
+ +{s.carried_over} {carriedOverLabel} +
+ )} + {/* Comp / flex days (#1074) are logged but never deducted, so they sit + beside the tiles rather than inside the used/left arithmetic. */} + {compUsed > 0 && ( +
+ + {t('vacay.compUsedCount', { count: fmtDays(compUsed) })} +
+ )}
)}