diff --git a/apps/zoom-app/src/b/AgendaReport.test.jsx b/apps/zoom-app/src/b/AgendaReport.test.jsx new file mode 100644 index 0000000..1415a80 --- /dev/null +++ b/apps/zoom-app/src/b/AgendaReport.test.jsx @@ -0,0 +1,223 @@ +import '@testing-library/jest-dom'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { + resetClubForTests, + resetEntitlementForTests, + resetFlagsForTests, + setEntitlement, + setFlags, +} from '@toastmaster-timer/shared'; +import { ToastProvider } from '../context/ToastContext'; +import { TimerProvider } from '../context/TimerContext'; +import AgendaTabB from './AgendaTabB'; +import ReportTabB from './ReportTabB'; +import RulesSheet from './RulesSheet'; + +vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() })); +vi.mock('../utils/zoomSdk', () => ({ + getVideoState: vi.fn().mockResolvedValue(null), + setVideoState: vi.fn(), + applyOverlay: vi.fn(), + removeOverlay: vi.fn(), + clearVideoPipelines: vi.fn().mockResolvedValue({ ok: true, declined: false, ungranted: [], lostBackground: false }), + isOverlayActive: vi.fn(() => false), + getBackgroundUrl: vi.fn((color) => `/backgrounds/${color}.png`), + getSdkStatus: vi.fn(() => ({ initialized: true, available: false, sdkExists: false, missingApis: [], apiCount: 0 })), + setLogCallback: vi.fn(), + getOverlayMode: vi.fn(() => 'camera'), + setOverlayMode: vi.fn().mockResolvedValue(undefined), + getOverlayTimePosition: vi.fn(() => ({ x: 0.18, y: 0.3 })), + setOverlayTimePosition: vi.fn(), + getOverlayTimeScale: vi.fn(() => 0.18), + setOverlayTimeScale: vi.fn((scale) => scale), + isOverlayTimeVisible: vi.fn(() => true), + setOverlayTimeVisible: vi.fn(), + setOverlayTimeLabel: vi.fn(), + setPopoutChangeCallback: vi.fn(), + setShareChangeCallback: vi.fn(), + setAppShare: vi.fn(), + setAppPopout: vi.fn(), + isAppShareActive: vi.fn(() => false), + isAppPoppedOut: vi.fn(() => false), + isVideoOverlayMode: vi.fn((mode) => mode !== 'stage'), + getClubBadgePlacement: vi.fn(() => ({ x: 0.8, y: 0.12, scale: 0.12, visible: true })), + setClubBadgePlacement: vi.fn((patch) => ({ x: 0.8, y: 0.12, scale: 0.12, visible: true, ...patch })), + resetClubBadgePlacement: vi.fn(() => ({ x: 0.8, y: 0.12, scale: 0.12, visible: true })), + isClubBadgeMoved: vi.fn(() => false), + getZoomParticipants: vi.fn().mockResolvedValue({ participants: [], role: 'host', restricted: false }), + getCardFileUrl: vi.fn((file) => `/cards/${file}`), + notifyCardImagesChanged: vi.fn(), + preloadBackgroundImages: vi.fn(), + notifyOwnBackgroundChanged: vi.fn(), + applyOwnBackground: vi.fn(), + removeOwnBackground: vi.fn(), + openExternalUrl: vi.fn(async () => true), + OVERLAY_MODE_CARD: 'card', + OVERLAY_MODE_CAMERA: 'camera', + OVERLAY_MODE_STAGE: 'stage', +})); +vi.mock('../utils/zoomIdentity', () => ({ + resolveZoomIdentity: vi.fn(async () => ({ identified: false })), + getSessionToken: vi.fn(() => null), +})); + +const wrap = (ui) => + render( + + {ui} + + ); + +const seedAgenda = (items) => localStorage.setItem('toastmaster_agenda', JSON.stringify(items)); +const seedReports = (items) => localStorage.setItem('toastmaster_reports', JSON.stringify(items)); + +beforeEach(() => { + localStorage.clear(); + resetFlagsForTests(); + resetEntitlementForTests(); + resetClubForTests(); +}); + +describe('the new-look Agenda tab', () => { + it('offers the paste and the add when there is no agenda', () => { + wrap(); + expect(screen.getByText('No speakers yet')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Paste from EasySpeak' })); + expect(screen.getByRole('dialog', { name: 'Paste agenda' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'From EasySpeak' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('counts a one-per-line paste before adding it, and leaves an unknown role for a pick', () => { + wrap(); + fireEvent.click(screen.getByRole('button', { name: 'Paste from EasySpeak' })); + fireEvent.click(screen.getByRole('tab', { name: 'One per line' })); + fireEvent.change(screen.getByRole('textbox', { name: 'One speaker per line' }), { + target: { value: 'Ana (Ice Breaker)\nAlex (Table Topics Master)' }, + }); + expect(screen.getByText(/Found 2 speakers/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add 2 speakers' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + const list = screen.getByRole('list', { name: 'Agenda' }); + expect(within(list).getByText('Ana')).toBeInTheDocument(); + expect(screen.getByText(/1 row needs a role/)).toBeInTheDocument(); + fireEvent.change(screen.getByRole('combobox', { name: 'Pick a role for Alex' }), { target: { value: 'Table Topics Speech' } }); + expect(screen.queryByText(/row needs a role/)).toBeNull(); + }); + + it('adds a speaker from the sheet with the role\'s times shown', () => { + wrap(); + fireEvent.click(screen.getByRole('button', { name: 'Add a speaker' })); + const sheet = screen.getByRole('dialog', { name: 'Add speaker' }); + expect(within(sheet).getByRole('button', { name: 'Add to agenda' })).toBeDisabled(); + fireEvent.change(within(sheet).getByRole('textbox', { name: 'Name' }), { target: { value: 'Priya' } }); + fireEvent.change(within(sheet).getByRole('combobox', { name: 'Role' }), { target: { value: 'Ice Breaker' } }); + expect(within(sheet).getByText('Green 4:00')).toBeInTheDocument(); + fireEvent.click(within(sheet).getByRole('button', { name: 'Add to agenda' })); + expect(within(screen.getByRole('list', { name: 'Agenda' })).getByText('Priya')).toBeInTheDocument(); + }); + + it('puts a speaker on the timer, but not one that needs a role', () => { + seedAgenda([ + { id: 'a1', name: 'Bob', role: 'Ice Breaker', completed: false }, + { id: 'a2', name: 'Alex', role: 'Table Topics Master', completed: false }, + ]); + const onSwitchToLive = vi.fn(); + wrap(); + fireEvent.click(screen.getByRole('button', { name: 'Alex: pick a role first' })); + expect(onSwitchToLive).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Put Bob on the timer' })); + expect(onSwitchToLive).toHaveBeenCalled(); + }); + + it('asks before clearing the agenda', () => { + seedAgenda([{ id: 'a1', name: 'Bob', role: 'Ice Breaker', completed: false }]); + wrap(); + fireEvent.click(screen.getByRole('button', { name: 'Clear the agenda' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Clear the agenda' }).slice(-1)[0]); + expect(screen.getByText('No speakers yet')).toBeInTheDocument(); + }); +}); + +describe('the new-look Report tab', () => { + it('sends an empty report back to the timer', () => { + const onGoToTimer = vi.fn(); + wrap(); + expect(screen.getByText('No speeches timed yet')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Go to the timer' })); + expect(onGoToTimer).toHaveBeenCalled(); + }); + + it('lists each speech with its time, colour or over time, and comment', () => { + seedReports([ + { speechId: 's1', name: 'Bob', role: 'Ice Breaker', duration: '05:42', color: 'yellow' }, + { speechId: 's2', name: 'Alex', role: 'Table Topics Speech', duration: '02:41', color: 'red', disqualified: true }, + { speechId: 's3', name: 'Sam', role: 'Speech Evaluation', duration: '01:12', color: 'blue', comments: 'Stopped early' }, + ]); + wrap(); + expect(screen.getByText('3 speeches · 1 over time')).toBeInTheDocument(); + const rows = within(screen.getByRole('list', { name: 'Speeches timed' })).getAllByRole('listitem'); + // Capitalised by CSS; the text itself is the stored colour. + expect(rows[0]).toHaveTextContent('BobIce Breaker05:42yellow'); + expect(rows[1]).toHaveTextContent('OVER TIME'); + expect(rows[2]).toHaveTextContent('“Stopped early”'); + expect(screen.getByText(/still talking past red/)).toBeInTheDocument(); + }); + + it('copies the report as text', async () => { + seedReports([{ speechId: 's1', name: 'Bob', role: 'Ice Breaker', duration: '05:42', color: 'yellow' }]); + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + wrap(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Copy report' })); + }); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Bob\tIce Breaker\t05:42\tyellow')); + }); + + it('pitches Pro only once it can be bought, and never to a Pro plan', () => { + seedReports([{ speechId: 's1', name: 'Bob', role: 'Ice Breaker', duration: '05:42', color: 'yellow' }]); + setFlags({ pro: false }); + setEntitlement({ plan: 'free' }); + const view = wrap(); + expect(screen.queryByRole('button', { name: 'See Pro' })).toBeNull(); + view.unmount(); + + act(() => setFlags({ pro: true })); + wrap(); + expect(screen.getByRole('button', { name: 'See Pro' })).toBeInTheDocument(); + }); +}); + +describe('the Roles & timing rules sheet', () => { + it('lists every role with its times, and opens one to edit', () => { + wrap( {}} />); + const sheet = screen.getByRole('dialog', { name: 'Roles & timing rules' }); + expect(within(sheet).getByRole('button', { name: /Ice Breaker\s*4:00 · 5:00 · 6:00/ })).toBeInTheDocument(); + fireEvent.click(within(sheet).getByRole('button', { name: /Ice Breaker/ })); + expect(within(sheet).getByLabelText('Green')).toHaveValue('4:00'); + expect(within(sheet).getByRole('button', { name: 'Remove Ice Breaker' })).toBeInTheDocument(); + }); + + it('saves an edited time', async () => { + const onClose = vi.fn(); + wrap(); + const sheet = screen.getByRole('dialog', { name: 'Roles & timing rules' }); + fireEvent.click(within(sheet).getByRole('button', { name: /Ice Breaker/ })); + const green = within(sheet).getByLabelText('Green'); + fireEvent.change(green, { target: { value: '3:30' } }); + fireEvent.blur(green); + await act(async () => { + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })); + }); + expect(onClose).toHaveBeenCalled(); + const stored = JSON.parse(localStorage.getItem('toastmaster_role_rules') || '{}'); + expect(stored['Ice Breaker']?.green).toBe(210); + }); + + it('adds a role that opens for its name', () => { + wrap( {}} />); + fireEvent.click(screen.getByRole('button', { name: 'Add role' })); + expect(screen.getByRole('textbox', { name: 'Role name' })).toBeInTheDocument(); + }); +}); diff --git a/apps/zoom-app/src/b/AgendaTabB.jsx b/apps/zoom-app/src/b/AgendaTabB.jsx new file mode 100644 index 0000000..534e878 --- /dev/null +++ b/apps/zoom-app/src/b/AgendaTabB.jsx @@ -0,0 +1,404 @@ +import { memo, useMemo } from 'react'; +import { AlertTriangle, Check, ClipboardList, GripVertical, Info, Pencil, Plus, Trash2, Upload } from 'lucide-react'; +import { DndContext, closestCenter } from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { DEFAULT_CUSTOM_RULES, parseEasySpeakText, parseSimpleFormatText, saveTimeInputMode } from '@toastmaster-timer/shared'; +import { useTimer } from '../context/TimerContext'; +import { useAgendaEditor } from '../hooks/useAgendaEditor'; +import ConfirmModal from '../components/ConfirmModal'; +import Sheet from './Sheet'; +import { CustomTimesPanel } from './TimerPanels'; +import { formatThreshold, thresholdsLine } from './format'; + +const PRIMARY = 'flex items-center justify-center gap-2 rounded-xl bg-blue-600 font-bold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300'; +const SECONDARY = 'flex items-center justify-center gap-2 rounded-xl border border-gray-300 bg-white font-semibold text-gray-900 hover:bg-slate-50'; + +/** One agenda row (board B-G02): drag dots, the name, what the row is set to, and edit / remove on hover. */ +function AgendaRow({ item, isActive, needsRole, done, roleOptions, roleRules, onPickRole, onEdit, onDelete, onClick }) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: item.id }); + const rules = roleRules[item.role] || item.rules; + const roleText = item.role === 'Short Roles' && item.originalShortRole ? `Short Roles - ${item.originalShortRole}` : item.role; + + return ( +
  • + {item.completed ? ( + + + ) : ( + + )} + +
    + + {needsRole && ( + <> + + + + + )} +
    + +
    + + +
    +
  • + ); +} + +/** How many speakers a paste will add, before it is added. */ +function countFound(tab, text, roleOptions) { + if (!text.trim()) return 0; + try { + const found = tab === 'easyspeak' ? parseEasySpeakText(text) : parseSimpleFormatText(text, roleOptions); + return Array.isArray(found) ? found.length : 0; + } catch { + return 0; + } +} + +/** + * Design B's Agenda tab (boards B-G01 to B-G04): the evening's speakers in + * order, a paste sheet for EasySpeak or one name per line, and an add / edit + * sheet. The same agenda, imports and rules as today's tab, from + * useAgendaEditor; only the look is new. + */ +export default memo(function AgendaTabB({ onSwitchToLive }) { + const a = useAgendaEditor({ onSwitchToLive }); + const { reports, roleRules } = useTimer(); + + // A finished row shows how it went: the newest report for that name and role. + const doneFor = useMemo(() => { + const byKey = new Map(); + for (const report of reports) byKey.set(`${report.name}\u0000${report.role}`, report); + return (item) => (item.completed ? byKey.get(`${item.name}\u0000${item.role}`) : null); + }, [reports]); + + const openImport = () => { + a.setImportTab('easyspeak'); + a.setImportText(''); + a.setEasySpeakText(''); + a.setShowImportModal(true); + }; + const closeImport = () => { + a.setShowImportModal(false); + a.setImportText(''); + a.setEasySpeakText(''); + a.setImportTab('easyspeak'); + }; + const openAdd = () => { + a.setEditItem(null); + a.setNewSpeakerName(''); + a.setNewSpeakerRole(a.defaultRole); + a.setCustomRules({ ...DEFAULT_CUSTOM_RULES }); + a.setShowAddModal(true); + }; + const closeAdd = () => { + a.setShowAddModal(false); + a.setEditItem(null); + a.setNewSpeakerName(''); + a.setNewSpeakerRole(a.defaultRole); + a.setCustomRules({ ...DEFAULT_CUSTOM_RULES }); + }; + + const importText = a.importTab === 'easyspeak' ? a.easySpeakText : a.importText; + const found = countFound(a.importTab, importText, a.roleOptions); + const addRules = a.newSpeakerRole && a.newSpeakerRole !== 'Custom' ? roleRules[a.newSpeakerRole] : null; + const count = a.agenda.length; + + return ( +
    +
    +

    Today's agenda

    + {count === 0 ? ( + 0 speakers + ) : ( + <> + + + + )} +
    + + {count === 0 ? ( +
    + + + No speakers yet +

    + Add the evening's speakers once. The timer then loads each one in order, with their role and times. +

    +
    + + +
    +
    + ) : ( + <> + {a.needsRoleCount > 0 && ( +
    +
    + )} + + item.id)} strategy={verticalListSortingStrategy}> +
      + {a.agenda.map((item) => ( + a.handleSpeakerClick(item)} + /> + ))} +
    +
    +
    +

    + Tap a speaker to put them on the timer. Drag the dots to reorder. Point at a row for edit and remove. +

    +
    + +
    + + )} + + {count === 0 && ( +
    +
    + )} + + +
    + {[ + ['easyspeak', 'From EasySpeak'], + ['simple', 'One per line'], + ].map(([id, label]) => ( + + ))} +
    + {a.importTab === 'easyspeak' ? ( +
      +
    1. Open the meeting in EasySpeak.
    2. +
    3. Select the whole agenda and copy it.
    4. +
    5. Paste it below. Roles we know get their times.
    6. +
    + ) : ( +

    + One speaker per line, with the role in brackets as your role list spells it: “Sarah Smith (Standard Speech)”. A row + whose role is not in the list stays red for you to pick. +

    + )} +