From 18e62d3ed69313eadc850e012b48d12e6c7dcdf0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 20 May 2026 12:37:55 +0100 Subject: [PATCH] Normalize Proxmox backup tables --- .../platformOverviewLayout.guardrails.test.ts | 19 +- .../features/proxmox/BackupActivityChart.tsx | 319 ++++++++++++++++ .../features/proxmox/ProxmoxBackupsTable.tsx | 349 ++++++++++++++---- .../proxmoxBackupActivityPresentation.test.ts | 65 ++++ .../proxmoxBackupActivityPresentation.ts | 160 ++++++++ 5 files changed, 827 insertions(+), 85 deletions(-) create mode 100644 frontend-modern/src/features/proxmox/BackupActivityChart.tsx create mode 100644 frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts create mode 100644 frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 47a8077e93..c6fd5ebc3f 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -6,6 +6,7 @@ import kubernetesClustersTableSource from '@/features/kubernetes/KubernetesClust import kubernetesDeploymentsTableSource from '@/features/kubernetes/KubernetesDeploymentsTable.tsx?raw'; import kubernetesNodesTableSource from '@/features/kubernetes/KubernetesNodesTable.tsx?raw'; import kubernetesPageSurfaceSource from '@/features/kubernetes/KubernetesPageSurface.tsx?raw'; +import proxmoxBackupsTableSource from '@/features/proxmox/ProxmoxBackupsTable.tsx?raw'; import proxmoxCephTableSource from '@/features/proxmox/ProxmoxCephTable.tsx?raw'; import proxmoxMailGatewayTableSource from '@/features/proxmox/ProxmoxMailGatewayTable.tsx?raw'; import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw'; @@ -52,6 +53,13 @@ const proxmoxDetailTableSources = [ proxmoxReplicationTableSource, ]; +const proxmoxBespokeTableSources = [ + proxmoxBackupsTableSource, + proxmoxCephTableSource, + proxmoxMailGatewayTableSource, + proxmoxReplicationTableSource, +]; + describe('platform overview layout guardrails', () => { it('keeps platform inventory tables on the shared dense table styling contract', () => { expect(sharedPlatformPageSource).toContain('PLATFORM_TABLE_CARD_CLASS'); @@ -85,19 +93,22 @@ describe('platform overview layout guardrails', () => { }); it('keeps Proxmox detail tables on the shared platform table primitives', () => { - for (const source of proxmoxDetailTableSources) { + for (const source of proxmoxBespokeTableSources) { expect(source).toContain('TableCard'); - expect(source).toContain('PlatformTableToolbar'); expect(source).toContain('PLATFORM_TABLE_CARD_CLASS'); expect(source).toContain('PLATFORM_TABLE_HEADER_ROW_CLASS'); expect(source).toContain('PLATFORM_TABLE_BODY_CLASS'); expect(source).toContain('getPlatformTableHeadClassForKind'); expect(source).toContain('getPlatformTableCellClassForKind'); - expect(source).not.toContain("from '@/components/shared/SearchInput'"); - expect(source).not.toContain("from '@/components/shared/FilterButtonGroup'"); expect(source).not.toContain('border-collapse text-xs'); expect(source).not.toContain('bg-surface-alt text-muted border-b border-border'); } + + for (const source of proxmoxDetailTableSources) { + expect(source).toContain('PlatformTableToolbar'); + expect(source).not.toContain("from '@/components/shared/SearchInput'"); + expect(source).not.toContain("from '@/components/shared/FilterButtonGroup'"); + } }); it('keeps provider overview pages in the parent-table plus child-inventory stack', () => { diff --git a/frontend-modern/src/features/proxmox/BackupActivityChart.tsx b/frontend-modern/src/features/proxmox/BackupActivityChart.tsx new file mode 100644 index 0000000000..270279a54b --- /dev/null +++ b/frontend-modern/src/features/proxmox/BackupActivityChart.tsx @@ -0,0 +1,319 @@ +import { createSignal, For, Show } from 'solid-js'; +import type { Accessor, Component } from 'solid-js'; + +import { TooltipPortal } from '@/components/shared/TooltipPortal'; +import { + SUMMARY_CHART_PLOT_AREA_CLASS, + SUMMARY_CHART_SLOT_COMPACT_CLASS, +} from '@/components/shared/summaryChartLayout'; +import { + getRecoveryCompactAxisLabel, + getRecoveryPrettyDateLabel, +} from '@/utils/recoveryDatePresentation'; +import { + getRecoveryTimelineAxisLabelClass, + getRecoveryTimelineAxisTicks, + getRecoveryTimelineChartGapPx, + getRecoveryTimelineChartMinWidthPx, + RECOVERY_TIMELINE_LEGEND_ITEM_CLASS, +} from '@/utils/recoveryTimelineChartPresentation'; +import { + getRecoveryTimelineBarMarkerClass, + getRecoveryTimelineColumnButtonClass, + getRecoveryTimelineEmptyMarkerClass, +} from '@/utils/recoveryTimelinePresentation'; + +import { + BACKUP_ACTIVITY_RANGE_DAYS, + getBackupActivityColumnAriaLabel, + getBackupActivityDayFilterStateLabel, + getBackupActivityPointTotalLabel, + getBackupActivitySegmentPresentation, + getBackupActivityTooltipRows, + type BackupActivityPoint, + type BackupActivityRangeDays, + type BackupActivitySegmentKind, + type BackupActivityTimeline, +} from './proxmoxBackupActivityPresentation'; + +interface TooltipState { + dateLabel: string; + point: BackupActivityPoint; + x: number; + y: number; +} + +interface BackupActivityChartProps { + title: string; + noun: 'archive' | 'task'; + segmentKinds: readonly BackupActivitySegmentKind[]; + range: Accessor; + onRangeChange: (days: BackupActivityRangeDays) => void; + timeline: Accessor; + selectedDateKey: Accessor; + onToggleDay: (key: string) => void; +} + +export const BackupActivityChart: Component = (props) => { + const [tooltip, setTooltip] = createSignal(null); + + const points = () => props.timeline().points; + const axisMax = () => props.timeline().axisMax; + const hasSelection = () => props.selectedDateKey() !== null; + + const axisTicks = () => + getRecoveryTimelineAxisTicks(points().length, false, props.timeline().labelEvery); + + const chartMinWidthStyle = () => { + const minWidth = getRecoveryTimelineChartMinWidthPx(false, props.range(), points().length); + return minWidth > 0 ? `${minWidth}px` : '100%'; + }; + + const chartGapStyle = () => `${getRecoveryTimelineChartGapPx(props.range())}px`; + + const showTooltip = (target: HTMLElement, point: BackupActivityPoint, dateLabel: string) => { + const rect = target.getBoundingClientRect(); + setTooltip({ + dateLabel, + point, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }; + const hideTooltip = () => setTooltip(null); + + return ( +
+
+
+
{props.title}
+ + {(kind) => { + const presentation = getBackupActivitySegmentPresentation(kind); + return ( + + + {presentation.label} + + ); + }} + +
+
+
+ + {(days) => { + const selected = () => props.range() === days; + return ( + + ); + }} + +
+
+
+ + 0} + fallback={ +
+ No activity in the selected window. +
+ } + > +
+
+
+ + {(step) => { + const value = () => Math.round((axisMax() * (2 - step)) / 2); + return {value()}; + }} + +
+ +
+
+
+ + {(point) => { + const total = () => point.total; + const heightPct = () => + axisMax() > 0 ? Math.min(100, (total() / axisMax()) * 100) : 0; + const isSelected = () => props.selectedDateKey() === point.key; + const dateLabel = getRecoveryPrettyDateLabel(point.key); + + return ( +
+ +
+ ); + }} +
+
+ +
+ + {(tick) => { + const point = points()[tick.index]; + const isSelected = () => + point ? props.selectedDateKey() === point.key : false; + const alignmentClass = + tick.align === 'start' + ? 'left-0 text-left' + : tick.align === 'end' + ? 'left-full -translate-x-full text-right' + : '-translate-x-1/2 text-center'; + return ( + + + {getRecoveryCompactAxisLabel(point!.key, props.range())} + + + ); + }} + +
+
+
+
+ + + + {(t) => ( +
+
+
+
+ {t().dateLabel} +
+
+ {getBackupActivityPointTotalLabel(t().point.total, props.noun)} +
+
+
+ {getBackupActivityDayFilterStateLabel( + props.selectedDateKey() === t().point.key, + hasSelection(), + )} +
+
+
    + + {(row) => ( +
  • + + + {row.label} + + {row.value} +
  • + )} +
    +
+
+ )} +
+
+
+
+
+ ); +}; + +export default BackupActivityChart; diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index b4544dfb15..ba477c8db6 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -15,6 +15,7 @@ import { EmptyState } from '@/components/shared/EmptyState'; import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; import { SearchInput } from '@/components/shared/SearchInput'; import { StatusDot } from '@/components/shared/StatusDot'; +import { TableCard } from '@/components/shared/TableCard'; import type { StatusIndicatorVariant } from '@/utils/status'; import { Table, @@ -26,6 +27,17 @@ import { } from '@/components/shared/Table'; import { apiFetch } from '@/utils/apiClient'; import { formatBytes, formatRelativeTime } from '@/utils/format'; +import { + recoveryDateKeyFromTimestamp, + getRecoveryFilterDateLabel, +} from '@/utils/recoveryDatePresentation'; +import { + PLATFORM_TABLE_BODY_CLASS, + PLATFORM_TABLE_CARD_CLASS, + PLATFORM_TABLE_HEADER_ROW_CLASS, + getPlatformTableCellClassForKind, + getPlatformTableHeadClassForKind, +} from '@/features/platformPage/sharedPlatformPage'; import type { BackupTask, GuestSnapshot, @@ -33,6 +45,12 @@ import type { PVEBackupsResponse, StorageBackup, } from '@/types/api'; +import { BackupActivityChart } from './BackupActivityChart'; +import { + buildBackupActivityTimeline, + type BackupActivityRangeDays, + type BackupActivitySegmentKind, +} from './proxmoxBackupActivityPresentation'; // Proxmox VE backups split into three meaningfully different surfaces: // - Snapshots: qm/pct snapshots taken on the host (no external storage) @@ -149,13 +167,55 @@ export const ProxmoxBackupsTable: Component<{ const [backups, { refetch }] = createResource(fetchPVEBackups); const [tab, setTab] = createSignal('snapshots'); const [search, setSearch] = createSignal(''); - const [archiveFilter, setArchiveFilter] = createSignal<'all' | 'protected' | 'verified' | 'unverified'>('all'); + const [archiveFilter, setArchiveFilter] = createSignal< + 'all' | 'protected' | 'verified' | 'unverified' + >('all'); const [taskFilter, setTaskFilter] = createSignal<'all' | 'ok' | 'failed' | 'running'>('all'); + const [chartRange, setChartRange] = createSignal(30); + const [selectedDateKey, setSelectedDateKey] = createSignal(null); + const toggleDay = (key: string) => + setSelectedDateKey((current) => (current === key ? null : key)); const snapshots = createMemo(() => backups()?.guestSnapshots ?? []); const archives = createMemo(() => backups()?.storageBackups ?? []); const tasks = createMemo(() => backups()?.backupTasks ?? []); + const archiveTimestampMs = (arc: StorageBackup): number | undefined => { + const ms = Date.parse(arc.time ?? ''); + return Number.isFinite(ms) ? ms : undefined; + }; + const taskTimestampMs = (task: BackupTask): number | undefined => { + const ms = Date.parse(task.startTime ?? ''); + return Number.isFinite(ms) ? ms : undefined; + }; + const classifyTaskSegment = (task: BackupTask): BackupActivitySegmentKind | null => { + const variant = classifyTaskStatus(task.status).variant; + if (variant === 'success') return 'ok'; + if (variant === 'danger') return 'failed'; + if (variant === 'warning') return 'running'; + return null; + }; + + const archiveTimeline = createMemo(() => + buildBackupActivityTimeline( + chartRange(), + archives(), + archiveTimestampMs, + () => 'archive', + ), + ); + const taskTimeline = createMemo(() => + buildBackupActivityTimeline( + chartRange(), + tasks(), + taskTimestampMs, + classifyTaskSegment, + ), + ); + + const ARCHIVE_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['archive']; + const TASK_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['ok', 'failed', 'running']; + const filteredSnapshots = createMemo(() => { const term = search().trim().toLowerCase(); return snapshots().filter((snap) => { @@ -171,10 +231,15 @@ export const ProxmoxBackupsTable: Component<{ const filteredArchives = createMemo(() => { const term = search().trim().toLowerCase(); const filter = archiveFilter(); + const dateKey = selectedDateKey(); return archives().filter((arc) => { if (filter === 'protected' && !arc.protected) return false; if (filter === 'verified' && !arc.verified) return false; if (filter === 'unverified' && arc.verified) return false; + if (dateKey) { + const ms = archiveTimestampMs(arc); + if (ms === undefined || recoveryDateKeyFromTimestamp(ms) !== dateKey) return false; + } if (!term) return true; return [arc.storage, arc.node, arc.instance, arc.volid, arc.notes, String(arc.vmid)] .filter(Boolean) @@ -187,11 +252,16 @@ export const ProxmoxBackupsTable: Component<{ const filteredTasks = createMemo(() => { const term = search().trim().toLowerCase(); const filter = taskFilter(); + const dateKey = selectedDateKey(); return tasks().filter((task) => { const classify = classifyTaskStatus(task.status); if (filter === 'ok' && classify.variant !== 'success') return false; if (filter === 'failed' && classify.variant !== 'danger') return false; if (filter === 'running' && classify.variant !== 'warning') return false; + if (dateKey) { + const ms = taskTimestampMs(task); + if (ms === undefined || recoveryDateKeyFromTimestamp(ms) !== dateKey) return false; + } if (!term) return true; return [task.node, task.instance, task.status, task.error, String(task.vmid)] .filter(Boolean) @@ -289,6 +359,31 @@ export const ProxmoxBackupsTable: Component<{ + + + + + + +
+ + + } > - - - - - Name - Guest - Node - Parent - Captured - Size - RAM + +
+ + + Name + Guest + Node + Parent + + Captured + + + Size + + RAM - + {(snap) => ( - +
{snap.name || '—'}
-
+
{snap.description}
- {guestLabel(snap.type, snap.vmid)} - + + {guestLabel(snap.type, snap.vmid)} + + {snap.node || '—'} - + {snap.parent?.trim() || '—'} - + {formatRelativeTime(snap.time, { compact: true })} - - 0} fallback={—}> + + 0} + fallback={—} + > {formatBytes(snap.sizeBytes ?? 0)} - - —} - > + + —}> with RAM @@ -403,7 +537,7 @@ export const ProxmoxBackupsTable: Component<{
-
+
@@ -428,57 +562,84 @@ export const ProxmoxBackupsTable: Component<{ } > - - - - - Volume - Guest - Storage - Node - Format - Created - Size - Protection - Verified + +
+ + + Volume + Guest + + Storage + + Node + Format + + Created + + + Size + + + Protection + + + Verified + - + {(arc) => ( - + {arc.volid} - + {guestLabel(arc.type, arc.vmid)} - {arc.storage || '—'} - + + {arc.storage || '—'} + + {arc.node || '—'} - + {arc.format || '—'} - + {formatRelativeTime(arc.time, { compact: true })} - + {formatBytes(arc.size)} - - —} - > + + —}> Protected - +
-
+ @@ -510,9 +671,7 @@ export const ProxmoxBackupsTable: Component<{ } > - - - - - Status - Guest - Node - Started - Duration - Size - Error + +
+ + + Status + Guest + Node + + Started + + + Duration + + + Size + + Error - + {(task) => { const classify = classifyTaskStatus(task.status); return ( - +
- - {classify.label} + + + {classify.label} +
- + {guestLabel(task.type, task.vmid)} - + {task.node || '—'} - + {formatRelativeTime(task.startTime, { compact: true })} - + {formatDuration(task.startTime, task.endTime)} - - 0} fallback={—}> + + 0} + fallback={—} + > {formatBytes(task.size ?? 0)} - + —} @@ -584,7 +771,7 @@ export const ProxmoxBackupsTable: Component<{
-
+
diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts new file mode 100644 index 0000000000..babd9dc166 --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { recoveryDateKeyFromTimestamp } from '@/utils/recoveryDatePresentation'; +import { + buildBackupActivityTimeline, + getBackupActivityColumnAriaLabel, + getBackupActivityTooltipRows, + type BackupActivitySegmentKind, +} from '../proxmoxBackupActivityPresentation'; + +describe('proxmoxBackupActivityPresentation', () => { + it('buckets backup activity by local day and segment kind', () => { + const now = new Date(2026, 4, 20, 12); + const today = new Date(2026, 4, 20, 9).getTime(); + const yesterday = new Date(2026, 4, 19, 9).getTime(); + const outsideWindow = new Date(2026, 4, 10, 9).getTime(); + const future = new Date(2026, 4, 21, 9).getTime(); + + const timeline = buildBackupActivityTimeline( + 7, + [ + { ts: today, kind: 'ok' }, + { ts: today, kind: 'failed' }, + { ts: yesterday, kind: 'ok' }, + { ts: outsideWindow, kind: 'ok' }, + { ts: future, kind: 'running' }, + ], + (item) => item.ts, + (item) => item.kind as BackupActivitySegmentKind, + now, + ); + + const todayPoint = timeline.points.find( + (point) => point.key === recoveryDateKeyFromTimestamp(today), + ); + const yesterdayPoint = timeline.points.find( + (point) => point.key === recoveryDateKeyFromTimestamp(yesterday), + ); + + expect(timeline.points).toHaveLength(7); + expect(todayPoint?.total).toBe(2); + expect(todayPoint?.counts.ok).toBe(1); + expect(todayPoint?.counts.failed).toBe(1); + expect(yesterdayPoint?.total).toBe(1); + expect(timeline.points.reduce((sum, point) => sum + point.total, 0)).toBe(3); + expect(timeline.axisMax).toBeGreaterThanOrEqual(2); + }); + + it('formats activity labels and tooltip rows from segment totals', () => { + const point = { + key: '2026-05-20', + total: 4, + counts: { archive: 0, ok: 3, failed: 1, running: 0 }, + }; + + expect(getBackupActivityColumnAriaLabel('Wed 20 May', point.total, true, 'task')).toBe( + 'Wed 20 May: 4 tasks, selected', + ); + expect(getBackupActivityTooltipRows(point, ['ok', 'failed', 'running'])).toMatchObject([ + { kind: 'ok', label: 'OK', value: '3 (75%)', muted: false }, + { kind: 'failed', label: 'Failed', value: '1 (25%)', muted: false }, + { kind: 'running', label: 'Running', value: '0', muted: true }, + ]); + }); +}); diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts new file mode 100644 index 0000000000..babede268a --- /dev/null +++ b/frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts @@ -0,0 +1,160 @@ +import { + getRecoveryNiceAxisMax, + recoveryDateKeyFromTimestamp, +} from '@/utils/recoveryDatePresentation'; +import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPresentation'; +import { getRecoveryTimelineDayFilterStateLabel } from '@/utils/recoveryTimelinePresentation'; + +export type BackupActivityRangeDays = 7 | 30 | 90 | 365; + +export const BACKUP_ACTIVITY_RANGE_DAYS = [ + 7, 30, 90, 365, +] as const satisfies readonly BackupActivityRangeDays[]; + +export type BackupActivitySegmentKind = 'archive' | 'ok' | 'failed' | 'running'; + +interface BackupActivitySegmentPresentation { + label: string; + segmentClassName: string; + swatchClassName: string; +} + +const SEGMENT_PRESENTATION: Record = { + archive: { + label: 'Archives', + segmentClassName: 'bg-blue-500', + swatchClassName: 'bg-blue-500', + }, + ok: { + label: 'OK', + segmentClassName: 'bg-emerald-500', + swatchClassName: 'bg-emerald-500', + }, + failed: { + label: 'Failed', + segmentClassName: 'bg-red-500', + swatchClassName: 'bg-red-500', + }, + running: { + label: 'Running', + segmentClassName: 'bg-amber-500', + swatchClassName: 'bg-amber-500', + }, +}; + +export function getBackupActivitySegmentPresentation( + kind: BackupActivitySegmentKind, +): BackupActivitySegmentPresentation { + return SEGMENT_PRESENTATION[kind]; +} + +export interface BackupActivityPoint { + key: string; + total: number; + counts: Record; +} + +export interface BackupActivityTimeline { + points: BackupActivityPoint[]; + axisMax: number; + labelEvery: number; +} + +function emptyCounts(): Record { + return { archive: 0, ok: 0, failed: 0, running: 0 }; +} + +function startOfLocalDayMs(date: Date): number { + const copy = new Date(date); + copy.setHours(0, 0, 0, 0); + return copy.getTime(); +} + +export function buildBackupActivityTimeline( + days: BackupActivityRangeDays, + items: readonly T[], + getTimestampMs: (item: T) => number | undefined, + classify: (item: T) => BackupActivitySegmentKind | null, + now: Date = new Date(), +): BackupActivityTimeline { + const todayStart = startOfLocalDayMs(now); + const windowStart = todayStart - (days - 1) * 24 * 60 * 60 * 1000; + + const buckets = new Map(); + const orderedKeys: string[] = []; + + for (let i = 0; i < days; i += 1) { + const dayStart = windowStart + i * 24 * 60 * 60 * 1000; + const key = recoveryDateKeyFromTimestamp(dayStart); + orderedKeys.push(key); + buckets.set(key, { key, total: 0, counts: emptyCounts() }); + } + + for (const item of items) { + const ts = getTimestampMs(item); + if (ts === undefined || !Number.isFinite(ts)) continue; + if (ts < windowStart) continue; + if (ts >= todayStart + 24 * 60 * 60 * 1000) continue; + const kind = classify(item); + if (!kind) continue; + const key = recoveryDateKeyFromTimestamp(ts); + const bucket = buckets.get(key); + if (!bucket) continue; + bucket.counts[kind] += 1; + bucket.total += 1; + } + + const points = orderedKeys.map((key) => buckets.get(key)!); + const rawMax = points.reduce((max, p) => (p.total > max ? p.total : max), 0); + const axisMax = Math.max(2, getRecoveryNiceAxisMax(rawMax)); + const labelEvery = getRecoveryTimelineLabelEvery(points.length); + + return { points, axisMax, labelEvery }; +} + +export interface BackupActivityTooltipRow { + kind: BackupActivitySegmentKind; + label: string; + count: number; + value: string; + segmentClassName: string; + muted: boolean; +} + +export function getBackupActivityTooltipRows( + point: BackupActivityPoint, + kinds: readonly BackupActivitySegmentKind[], +): BackupActivityTooltipRow[] { + const total = Math.max(0, point.total); + return kinds.map((kind) => { + const presentation = SEGMENT_PRESENTATION[kind]; + const count = Math.max(0, point.counts[kind] ?? 0); + const percentage = total > 0 && count > 0 ? Math.round((count / total) * 100) : 0; + return { + kind, + label: presentation.label, + count, + value: percentage > 0 ? `${count} (${percentage}%)` : String(count), + segmentClassName: presentation.segmentClassName, + muted: count === 0, + }; + }); +} + +export function getBackupActivityPointTotalLabel(total: number, noun: 'archive' | 'task'): string { + const normalized = Math.max(0, total); + if (normalized === 1) return `1 ${noun}`; + return `${normalized} ${noun}s`; +} + +export function getBackupActivityColumnAriaLabel( + dateLabel: string, + total: number, + selected: boolean, + noun: 'archive' | 'task', +): string { + const countLabel = getBackupActivityPointTotalLabel(total, noun); + return selected ? `${dateLabel}: ${countLabel}, selected` : `${dateLabel}: ${countLabel}`; +} + +export { getRecoveryTimelineDayFilterStateLabel as getBackupActivityDayFilterStateLabel };