Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | 2x 8x 8x 48x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 9x 8x 8x 8x 8x 8x 32x 32x 32x 2x | /**
* MetricTiles (Epic 14 Phase B — Story 14.B.5; FR13, UX-DR4).
*
* Four "At a glance" tiles that double as deep-links into their destination
* tab/sub-view. Values come from the existing `useEvent(['metrics','registrations'])`
* payload — no backend change. Each tile is keyboard-operable (ButtonBase) and
* screen-reader labelled (NFR7). Sub-view keys are the stable `pool|agenda|slots`
* that survive Phase C's relabel.
*/
import React from 'react';
import { Box, Paper, ButtonBase, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import type { Event, EventDetailUI } from '@/types/event.types';
import type { CardTarget } from './cockpitCards';
interface MetricTilesProps {
event: Event | EventDetailUI;
onNavigate: (target: CardTarget) => void;
}
interface Tile {
id: string;
icon: string;
labelKey: string;
value: number;
total?: number | null;
/** Secondary line (e.g. "72% filled · 3 on waitlist"). */
sub?: string;
target: CardTarget;
}
export const MetricTiles: React.FC<MetricTilesProps> = ({ event, onNavigate }) => {
const { t } = useTranslation('events');
const e = event as Record<string, unknown>;
const num = (v: unknown) => (typeof v === 'number' ? v : 0);
// Use the canonical `confirmedCount` (registrations expansion) — same field the
// Cockpit "watch registrations" card reads, so the tile and the card never diverge.
const confirmedCount = num(e.confirmedCount);
const registrationCapacity =
typeof e.registrationCapacity === 'number' ? e.registrationCapacity : null;
const waitlistCount = num(e.waitlistCount);
const confirmedSpeakers = num(e.confirmedSpeakersCount);
const maxSpeakerSlots = num(e.maxSpeakerSlots);
const materials = num(e.sessionsWithMaterialsCount);
const totalSessions =
num(e.totalSessionsCount) || ((e.sessions as unknown[] | undefined)?.length ?? 0);
// Agenda tile = speaker sessions that have a start time, out of the event's max
// speaker slots. Structural sessions (moderation/break/lunch) are NOT speaker slots,
// so they must be excluded — counting them inflated the fraction (e.g. 8/8 for an
// event with 5 talks slotted + 2 moderation + 1 break). The structural set mirrors
// STRUCTURAL_TYPES in DragDropSlotAssignment (the timetable's non-SPEAKER_SLOT types).
const STRUCTURAL_SESSION_TYPES = ['moderation', 'break', 'lunch', 'aperitif'];
const sessionsArr =
(e.sessions as { startTime?: string | null; sessionType?: string | null }[] | undefined) ?? [];
const speakerSessions = sessionsArr.filter(
(s) => !STRUCTURAL_SESSION_TYPES.includes((s.sessionType ?? '').toLowerCase())
);
const sessionsSlotted = speakerSessions.filter((s) => !!s.startTime).length;
// Denominator = max speaker slots (the agenda capacity); fall back to the count of
// speaker sessions, then the metric, when maxSpeakerSlots isn't hydrated.
const agendaTotal = maxSpeakerSlots || speakerSessions.length || totalSessions;
const pctFilled =
registrationCapacity && registrationCapacity > 0
? Math.round((confirmedCount / registrationCapacity) * 100)
: null;
const tiles: Tile[] = [
{
id: 'registrations',
icon: '🎟️',
labelKey: 'registrations',
value: confirmedCount,
total: registrationCapacity,
sub: [
pctFilled !== null
? t('eventPage.cockpit.tiles.filled', '{{percent}}% filled', { percent: pctFilled })
: null,
waitlistCount > 0
? t('eventPage.cockpit.tiles.waitlist', '{{count}} on waitlist', { count: waitlistCount })
: null,
]
.filter(Boolean)
.join(' · '),
target: { kind: 'tab', tab: 'registrations' },
},
{
id: 'speakers',
icon: '🎤',
labelKey: 'speakers',
value: confirmedSpeakers,
total: maxSpeakerSlots,
target: { kind: 'tab', tab: 'speakers', view: 'pool' },
},
{
id: 'materials',
icon: '📋',
labelKey: 'materials',
value: materials,
total: totalSessions,
target: { kind: 'tab', tab: 'speakers', view: 'agenda' },
},
{
id: 'agenda',
icon: '🗓️',
labelKey: 'agenda',
value: sessionsSlotted,
total: agendaTotal,
target: { kind: 'tab', tab: 'speakers', view: 'slots' },
},
];
return (
<Box
data-testid="cockpit-metric-tiles"
sx={{
display: 'grid',
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
gap: 2,
}}
>
{tiles.map((tile) => {
const label = t(`eventPage.cockpit.tiles.${tile.labelKey}`, tile.labelKey);
const fraction = tile.total != null ? `${tile.value} / ${tile.total}` : String(tile.value);
return (
<ButtonBase
key={tile.id}
onClick={() => onNavigate(tile.target)}
data-testid={`cockpit-metric-tile-${tile.id}`}
aria-label={`${label}: ${fraction}${tile.sub ? `. ${tile.sub}` : ''}`}
sx={{ display: 'block', textAlign: 'left', borderRadius: 1, width: '100%' }}
>
<Paper variant="outlined" sx={{ p: 2, width: '100%', height: '100%' }}>
<Typography variant="overline" color="text.secondary">
{tile.icon} {label}
</Typography>
<Typography variant="h5" sx={{ mt: 0.5 }}>
{fraction}
</Typography>
{tile.sub && (
<Typography variant="caption" color="text.secondary">
{tile.sub}
</Typography>
)}
</Paper>
</ButtonBase>
);
})}
</Box>
);
};
|