All files / src/components/organizer/EventPage/cockpit MetricTiles.tsx

100% Statements 26/26
64.28% Branches 18/28
100% Functions 6/6
100% Lines 24/24

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>
  );
};