From f1d520bbc253419d9b1480ad7aabe42204697e95 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 25 Jul 2026 11:50:50 +0200 Subject: [PATCH] feat(vacay): show comp days in the stats and fold the card away (#1074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/Vacay/VacayStats.test.tsx | 104 ++++++++++++++++++ client/src/components/Vacay/VacayStats.tsx | 97 +++++++++++++--- 2 files changed, 187 insertions(+), 14 deletions(-) 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) })} +
+ )}
)}