mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 />)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user