From 95f2db371209bc4a8552ba1fdde60d860e8c23e6 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 20:18:28 -0500 Subject: [PATCH 1/3] refactor(zoom-app): lift the Agenda, Report and rules logic into hooks useAgendaEditor, useReportView and useRulesEditor hold what AgendaTab, ReportTab and EditRulesModal did, moved unchanged (their JSX is byte-for-byte the same and their tests pass as written). Design B's views call the same hooks. --- apps/zoom-app/src/components/AgendaTab.jsx | 268 +++----------- .../src/components/EditRulesModal.jsx | 348 ++---------------- apps/zoom-app/src/components/ReportTab.jsx | 174 ++------- apps/zoom-app/src/hooks/useAgendaEditor.js | 274 ++++++++++++++ apps/zoom-app/src/hooks/useReportView.js | 189 ++++++++++ apps/zoom-app/src/hooks/useRulesEditor.js | 346 +++++++++++++++++ 6 files changed, 921 insertions(+), 678 deletions(-) create mode 100644 apps/zoom-app/src/hooks/useAgendaEditor.js create mode 100644 apps/zoom-app/src/hooks/useReportView.js create mode 100644 apps/zoom-app/src/hooks/useRulesEditor.js diff --git a/apps/zoom-app/src/components/AgendaTab.jsx b/apps/zoom-app/src/components/AgendaTab.jsx index 56a6cd4..ea6ff42 100644 --- a/apps/zoom-app/src/components/AgendaTab.jsx +++ b/apps/zoom-app/src/components/AgendaTab.jsx @@ -1,27 +1,12 @@ -import { useState, memo } from 'react'; -import { useTimer } from '../context/TimerContext'; -import { useToast } from '../context/ToastContext'; +import { memo } from 'react'; import { Plus, Upload, X, Edit2, Trash2, GripVertical, Check, AlertTriangle } from 'lucide-react'; import ConfirmModal from './ConfirmModal'; -import { trackEvent } from '../utils/posthog'; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; +import { DndContext, closestCenter } from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, agendaItemNeedsRole } from '@toastmaster-timer/shared'; +import { DEFAULT_CUSTOM_RULES, saveTimeInputMode } from '@toastmaster-timer/shared'; import TimeInput, { TimeInputModeToggle } from './TimeInput'; +import { useAgendaEditor } from '../hooks/useAgendaEditor'; function SortableItem({ item, isActive, isCompleted, needsRole, roleOptions, onPickRole, onEdit, onDelete, onClick }) { const { @@ -127,206 +112,51 @@ export default memo(function AgendaTab({ onSwitchToLive }) { const { agenda, activeSpeakerId, - addToAgenda, - removeFromAgenda, - reorderAgenda, - updateAgendaItem, - loadSpeakerFromAgenda, - importBulkSpeakers, - importEasySpeakSpeakers, - clearAllAgenda, - roleRules, roleOptions, - } = useTimer(); - const { showToast } = useToast(); - // Standard Speech unless it was removed in Edit Rules; then the first listed - // role, so the picker never shows one role while saving another. - const defaultRole = roleOptions.includes('Standard Speech') ? 'Standard Speech' : roleOptions[0] || 'Standard Speech'; - const needsRole = (item) => agendaItemNeedsRole(item, roleOptions); - const needsRoleCount = agenda.filter(needsRole).length; - - const [showImportModal, setShowImportModal] = useState(false); - const [showAddModal, setShowAddModal] = useState(false); - const [importTab, setImportTab] = useState('easyspeak'); // 'simple' | 'easyspeak' - const [importText, setImportText] = useState(''); - const [easySpeakText, setEasySpeakText] = useState(''); - const [editItem, setEditItem] = useState(null); - const [newSpeakerName, setNewSpeakerName] = useState(''); - const [newSpeakerRole, setNewSpeakerRole] = useState(defaultRole); - const [customRules, setCustomRules] = useState({ ...DEFAULT_CUSTOM_RULES }); - const [timeInputMode, setTimeInputMode] = useState(loadTimeInputMode); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [deleteItemId, setDeleteItemId] = useState(null); - const [showClearAllConfirm, setShowClearAllConfirm] = useState(false); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - const handleDragEnd = (event) => { - const { active, over } = event; - - if (active.id !== over.id) { - const oldIndex = agenda.findIndex((item) => item.id === active.id); - const newIndex = agenda.findIndex((item) => item.id === over.id); - const newOrder = arrayMove(agenda, oldIndex, newIndex); - reorderAgenda(newOrder); - - // Track agenda reordered - const movedItem = agenda[oldIndex]; - trackEvent('agenda_reordered', { - speaker_name: movedItem?.name || 'Unnamed', - old_index: oldIndex, - new_index: newIndex - }); - } - }; - - const handleSimpleImport = () => { - if (importText.trim()) { - const { count, needsRole: unmatched } = importBulkSpeakers(importText); - if (unmatched > 0) { - showToast(`Imported ${count} speaker(s). ${unmatched} need${unmatched === 1 ? 's' : ''} a role`, 'warning'); - } else { - showToast(`Imported ${count} speaker(s)`, 'success'); - } - setImportText(''); - setShowImportModal(false); - } - }; - - const handleEasySpeakImport = () => { - if (easySpeakText.trim()) { - const count = importEasySpeakSpeakers(easySpeakText); - showToast(`Imported ${count} speaker(s)`, 'success'); - setEasySpeakText(''); - setShowImportModal(false); - } - }; - - const handleAdd = () => { - if (newSpeakerName.trim()) { - const speakerData = { - name: newSpeakerName.trim(), - role: newSpeakerRole, - }; - // If Custom role, include custom rules - if (newSpeakerRole === 'Custom') { - speakerData.rules = customRules; - } - addToAgenda(speakerData); - setNewSpeakerName(''); - setNewSpeakerRole(defaultRole); - setCustomRules({ ...DEFAULT_CUSTOM_RULES }); - setShowAddModal(false); - } - }; - - const handleEdit = (item) => { - setEditItem(item); - setNewSpeakerName(item.name); - // A role that isn't listed can't be shown by the select; start on "Pick a role…". - setNewSpeakerRole(needsRole(item) ? '' : item.role); - // If item has custom rules, load them (merge so missing graceAfterRed is filled) - if (item.role === 'Custom' && item.rules) { - setCustomRules({ ...DEFAULT_CUSTOM_RULES, ...item.rules }); - } else { - setCustomRules({ ...DEFAULT_CUSTOM_RULES }); - } - setShowAddModal(true); - }; - - const handleUpdate = () => { - if (editItem && newSpeakerName.trim() && newSpeakerRole) { - const speakerData = { - name: newSpeakerName.trim(), - role: newSpeakerRole, - }; - // If Custom role, include custom rules - if (newSpeakerRole === 'Custom') { - speakerData.rules = customRules; - } - // Update in place so the speaker keeps their position in the agenda - updateAgendaItem(editItem.id, speakerData); - setEditItem(null); - setNewSpeakerName(''); - setNewSpeakerRole(defaultRole); - setCustomRules({ ...DEFAULT_CUSTOM_RULES }); - setShowAddModal(false); - } - }; - - const handlePickRole = (item, role) => { - if (!role) return; - updateAgendaItem(item.id, { name: item.name, role }); - trackEvent('agenda_role_picked', { is_custom: !(role in DEFAULT_ROLE_RULES) }); - }; - - const handleDelete = (id) => { - setDeleteItemId(id); - setShowDeleteConfirm(true); - }; - - const handleConfirmDelete = () => { - if (deleteItemId) { - removeFromAgenda(deleteItemId); - } - setShowDeleteConfirm(false); - setDeleteItemId(null); - }; - - const handleClearAll = () => { - setShowClearAllConfirm(true); - }; - - const handleConfirmClearAll = () => { - clearAllAgenda(); - setShowClearAllConfirm(false); - }; - - // Helper to format seconds as readable time - const formatTimeReadable = (seconds) => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - if (mins === 0) { - return `${secs} second${secs !== 1 ? 's' : ''}`; - } - if (secs === 0) { - return `${mins} minute${mins !== 1 ? 's' : ''}`; - } - return `${mins} min ${secs} sec`; - }; - - // Get explanation text for each role - const getRoleExplanation = (role) => { - const rules = roleRules[role] || DEFAULT_ROLE_RULES[role] || DEFAULT_ROLE_RULES['Standard Speech']; - return `Green: ${formatTimeReadable(rules.green)}, Yellow: ${formatTimeReadable(rules.yellow)}, Red: ${formatTimeReadable(rules.red)}`; - }; - - const handleCustomRuleChange = (field, value) => { - setCustomRules(prev => ({ ...prev, [field]: value })); - }; - - const handleSpeakerClick = (item) => { - // Without a role there are no rules to time with; the Live tab would quietly - // fall back to the default role. Ask instead. - if (needsRole(item)) { - showToast(`Pick a role for ${item.name} first`, 'warning'); - return; - } - loadSpeakerFromAgenda(item.id); - // Track speaker loaded from agenda - trackEvent('speaker_loaded_from_agenda', { - speaker_name: item.name || 'Unnamed', - role: item.role - }); - if (onSwitchToLive) { - onSwitchToLive(); - } - }; + defaultRole, + needsRole, + needsRoleCount, + showImportModal, + setShowImportModal, + showAddModal, + setShowAddModal, + importTab, + setImportTab, + importText, + setImportText, + easySpeakText, + setEasySpeakText, + editItem, + setEditItem, + newSpeakerName, + setNewSpeakerName, + newSpeakerRole, + setNewSpeakerRole, + customRules, + setCustomRules, + timeInputMode, + setTimeInputMode, + showDeleteConfirm, + setShowDeleteConfirm, + setDeleteItemId, + showClearAllConfirm, + setShowClearAllConfirm, + sensors, + handleDragEnd, + handleSimpleImport, + handleEasySpeakImport, + handleAdd, + handleEdit, + handleUpdate, + handlePickRole, + handleDelete, + handleConfirmDelete, + handleClearAll, + handleConfirmClearAll, + getRoleExplanation, + handleCustomRuleChange, + handleSpeakerClick, + } = useAgendaEditor({ onSwitchToLive }); return (
diff --git a/apps/zoom-app/src/components/EditRulesModal.jsx b/apps/zoom-app/src/components/EditRulesModal.jsx index 5d13ea1..0cbc3e5 100644 --- a/apps/zoom-app/src/components/EditRulesModal.jsx +++ b/apps/zoom-app/src/components/EditRulesModal.jsx @@ -1,326 +1,44 @@ -import { useState, useEffect, useRef } from 'react'; import { X, Plus, Trash2 } from 'lucide-react'; -import { useTimer } from '../context/TimerContext'; -import { useToast } from '../context/ToastContext'; -import { DEFAULT_ROLE_RULES, getDefaultGraceAfterRed, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, BREAK_ROLE } from '@toastmaster-timer/shared'; -import { - activeClubName, - canPublishPresets, - lapsedClubName, - clubPresetsAvailable, - clubPresetsLive, - ensureForked, - loadClub, - loadClubPresets, - presetSource, - publishPresets, - resetToClub, - resolveActiveRules, - useClubPresets, - usePersonalPresets, -} from '@toastmaster-timer/shared'; +import { DEFAULT_ROLE_RULES, getDefaultGraceAfterRed, DEFAULT_CUSTOM_RULES, saveTimeInputMode } from '@toastmaster-timer/shared'; import ConfirmModal from './ConfirmModal'; import ClubPresetBanner from './ClubPresetBanner'; import TimeInput, { TimeInputModeToggle } from './TimeInput'; -import { trackEvent } from '../utils/posthog'; -import { getSessionToken } from '../utils/zoomIdentity'; +import { useRulesEditor } from '../hooks/useRulesEditor'; const isBuiltInRole = (role) => role in DEFAULT_ROLE_RULES; export default function EditRulesModal({ isOpen, onClose }) { - const { roleRules, roleOptions, updateRoleRules, addRoleRules, removeRoleRules, resetAllRoleRulesToDefaults, reloadRoleRules } = useTimer(); - const { showToast } = useToast(); - const [editedRules, setEditedRules] = useState({}); - const [newRoleNames, setNewRoleNames] = useState({}); - const [showResetAllConfirm, setShowResetAllConfirm] = useState(false); - const [timeInputMode, setTimeInputMode] = useState(loadTimeInputMode); - const prevOpenRef = useRef(false); - - // --- the club's list ----------------------------------------------------- - // Read once per open and kept in state, because every one of these answers - // changes underneath the modal as soon as the switch moves or a publish lands. - const [source, setSource] = useState(presetSource); - const [publishing, setPublishing] = useState(false); - const [showForkConfirm, setShowForkConfirm] = useState(false); - const forkResolverRef = useRef(null); - - const clubName = activeClubName(); - const clubHasPresets = clubPresetsAvailable(); - // A lapse leaves the club's list where it is and simply stops applying it, so - // the editor owes the timer an explanation rather than a silent reversion. - const lapsedClub = lapsedClubName(); - const clubList = clubHasPresets ? loadClubPresets() : null; - const clubId = loadClub()?.club?.id ?? null; - const mayPublish = canPublishPresets(); - - /** Re-seed the editor from whichever list just became live. */ - const showLiveList = () => { - reloadRoleRules(); - const merged = resolveActiveRules(); - delete merged[BREAK_ROLE]; - setEditedRules(merged); - setNewRoleNames({}); - setSource(presetSource()); - }; - - /** - * The one confirmation that stands between the club's list and an edit. - * - * Resolved by the modal below rather than returned directly, so every - * mutating path can `await` the same promise and do nothing at all on cancel. - */ - const confirmFork = () => new Promise((resolve) => { - forkResolverRef.current = resolve; - setShowForkConfirm(true); - }); - - const settleFork = (confirmed) => { - setShowForkConfirm(false); - const resolve = forkResolverRef.current; - forkResolverRef.current = null; - resolve?.(confirmed); - }; - - /** - * Add, update, remove and reset-all all run this first, so they cannot drift - * into four behaviours. False means the timer cancelled: write nothing, and - * leave the modal exactly as it was. - */ - const guardEdit = async () => { - const wasClubList = clubPresetsLive(); - const proceed = await ensureForked(confirmFork); - if (proceed && wasClubList) { - setSource(presetSource()); - trackEvent('club_presets_forked', { surface: 'zoom', club_id: clubId }); - } - return proceed; - }; - - const handleUseClubPresets = () => { - useClubPresets(); - showLiveList(); - }; - - const handleUseMyPresets = () => { - usePersonalPresets(); - showLiveList(); - }; - - const handleResetToClub = () => { - if (!resetToClub()) return; - showLiveList(); - trackEvent('club_presets_reset', { surface: 'zoom', club_id: clubId }); - }; - - // Initialize edited rules only when modal opens (not when roleRules changes while open) - useEffect(() => { - if (isOpen && !prevOpenRef.current) { - setEditedRules({ ...roleRules }); - setNewRoleNames({}); - setSource(presetSource()); - } - prevOpenRef.current = isOpen; - }, [isOpen, roleRules]); - - const handleRuleChange = (role, field, value) => { - setEditedRules(prev => ({ - ...prev, - [role]: { - ...prev[role], - [field]: value - } - })); - }; - - const handleAddRole = () => { - const tempId = `__new_${Date.now()}`; - setEditedRules(prev => ({ - ...prev, - [tempId]: { ...DEFAULT_CUSTOM_RULES } - })); - setNewRoleNames(prev => ({ ...prev, [tempId]: 'New role' })); - }; - - const handleNewRoleNameChange = (tempId, name) => { - setNewRoleNames(prev => ({ ...prev, [tempId]: name })); - }; - - const handleRemoveNewRole = (tempId) => { - setEditedRules(prev => { - const next = { ...prev }; - delete next[tempId]; - return next; - }); - setNewRoleNames(prev => { - const next = { ...prev }; - delete next[tempId]; - return next; - }); - }; - - const handleRemoveRole = async (role) => { - if (!(await guardEdit())) return; - setEditedRules(prev => { - const next = { ...prev }; - delete next[role]; - return next; - }); - removeRoleRules(role); - }; - - // Break is not editable here: its thresholds are derived from the length - // picked on the Live tab, so a hand-edited copy would be overwritten by the - // next pick and mislead in the meantime. - const rolesToShow = [ - ...(roleOptions ?? []).filter((role) => role !== BREAK_ROLE), - ...Object.keys(editedRules).filter((k) => k.startsWith('__new_')) - ]; - - /** - * What "Share with my club" would send: the timings on screen right now. - * - * Built from the editor rather than from storage because the saves below run - * inside a state updater, so what is on disk lags what the officer is looking - * at by a render. A publish that silently shared the previous list would be - * the worst possible bug in this feature. - */ - const publishablePresets = () => { - const rules = {}; - for (const role of rolesToShow) { - const edited = editedRules[role]; - if (!edited) continue; - const name = role.startsWith('__new_') ? (newRoleNames[role] || '').trim() : role; - if (!name) continue; - rules[name] = { - green: edited.green, - yellow: edited.yellow, - red: edited.red, - graceAfterRed: edited.graceAfterRed ?? getDefaultGraceAfterRed(name), - }; - } - return { - rules, - order: Object.keys(rules).filter((role) => !(role in DEFAULT_ROLE_RULES)), - // A built-in missing from the list is a built-in the officer removed, and - // every device in the club has to stop offering it. - hiddenBuiltins: Object.keys(DEFAULT_ROLE_RULES).filter((role) => !(role in rules)), - }; - }; - - const handlePublish = async () => { - if (publishing || !validate()) return; - setPublishing(true); - const result = await publishPresets(publishablePresets(), { getToken: getSessionToken }); - setPublishing(false); - - if (!result.ok) { - showToast( - result.error === 'forbidden' - ? 'Only a club admin or editor can share presets.' - : "Couldn't share these presets with your club. Try again.", - 'error' - ); - return; - } - - // rememberPublishedPresets already took the new list and version, so a - // device sitting on the club's list is showing what was just shared. - if (presetSource() === 'club') showLiveList(); - showToast(`Shared with ${clubName || 'your club'}.`, 'success'); - trackEvent('club_presets_published', { - surface: 'zoom', - club_id: clubId, - role: loadClub()?.role ?? null, - roles: Object.keys(publishablePresets().rules).length, - }); - }; - - /** Every reason the list on screen could not be saved or shared. */ - const validate = () => { - const seenNames = new Set(); - - for (const role of rolesToShow) { - const rules = editedRules[role]; - if (!rules || rules.green <= 0 || rules.yellow <= rules.green || rules.red <= rules.yellow) { - const displayName = role.startsWith('__new_') ? (newRoleNames[role] || role) : role; - showToast(`Invalid timing rules for ${displayName}. Green must be > 0, Yellow must be > Green, and Red must be > Yellow.`, 'error'); - return false; - } - if (role.startsWith('__new_')) { - const name = (newRoleNames[role] || '').trim(); - if (!name) { - showToast('Please enter a name for the new role.', 'error'); - return false; - } - if (seenNames.has(name) || roleOptions.includes(name)) { - showToast(`A role named "${name}" already exists.`, 'error'); - return false; - } - seenNames.add(name); - } - } - return true; - }; - - const handleSave = async () => { - if (!validate()) return; - // Nothing is written until the club's list has been forked, so cancelling - // the confirmation leaves the modal open with the edit still on screen. - if (!(await guardEdit())) return; - - for (const role of rolesToShow) { - const rules = editedRules[role]; - if (role.startsWith('__new_')) { - const name = (newRoleNames[role] || '').trim(); - if (name) { - addRoleRules(name, rules); - trackEvent('role_added', { role: name, rules }); - } - } else { - const oldRules = roleRules[role]; - const graceOld = oldRules?.graceAfterRed ?? getDefaultGraceAfterRed(role); - const graceNew = rules.graceAfterRed ?? getDefaultGraceAfterRed(role); - if (oldRules && ( - oldRules.green !== rules.green || - oldRules.yellow !== rules.yellow || - oldRules.red !== rules.red || - graceOld !== graceNew - )) { - updateRoleRules(role, rules); - trackEvent('rules_edited', { - role, - new_rules: rules, - previous_rules: oldRules - }); - } - } - } - - onClose(); - }; - - const handleReset = (role) => { - const defaultRules = DEFAULT_ROLE_RULES[role]; - if (!defaultRules) return; - setEditedRules(prev => ({ - ...prev, - [role]: { ...defaultRules } - })); - }; - - const handleResetAll = () => { - setShowResetAllConfirm(true); - }; - - const handleConfirmResetAll = async () => { - setShowResetAllConfirm(false); - if (!(await guardEdit())) return; - resetAllRoleRulesToDefaults(); - setEditedRules(prev => ({ - ...DEFAULT_ROLE_RULES, - ...Object.fromEntries(Object.entries(prev).filter(([k]) => !(k in DEFAULT_ROLE_RULES))) - })); - }; + const { + editedRules, + newRoleNames, + showResetAllConfirm, + setShowResetAllConfirm, + timeInputMode, + setTimeInputMode, + source, + publishing, + showForkConfirm, + clubName, + clubHasPresets, + lapsedClub, + clubList, + mayPublish, + settleFork, + handleUseClubPresets, + handleUseMyPresets, + handleResetToClub, + handleRuleChange, + handleAddRole, + handleNewRoleNameChange, + handleRemoveNewRole, + handleRemoveRole, + rolesToShow, + handlePublish, + handleSave, + handleReset, + handleResetAll, + handleConfirmResetAll, + } = useRulesEditor({ isOpen, onClose }); if (!isOpen) return null; diff --git a/apps/zoom-app/src/components/ReportTab.jsx b/apps/zoom-app/src/components/ReportTab.jsx index 1100c0a..e1def91 100644 --- a/apps/zoom-app/src/components/ReportTab.jsx +++ b/apps/zoom-app/src/components/ReportTab.jsx @@ -1,16 +1,10 @@ import { memo } from 'react'; -import { useTimer } from '../context/TimerContext'; -import { useToast } from '../context/ToastContext'; import { Copy, Check, Trash2, X, History, CloudOff, ChevronLeft, Share2 } from 'lucide-react'; -import { useState } from 'react'; import BrandedReportHeader from '@toastmaster-timer/ui/BrandedReportHeader'; import ReportHistoryList from '@toastmaster-timer/ui/ReportHistoryList'; import ShareStep from '@toastmaster-timer/ui/ShareStep'; -import { clubFeaturesOn, fetchHistory, fetchMeeting, endMeetingAndShare, copyReportImage } from '@toastmaster-timer/shared'; import ConfirmModal from './ConfirmModal'; -import { useClub, useOutboxPending } from '../hooks/useClub'; -import { getSessionToken } from '../utils/zoomIdentity'; -import { trackEvent } from '../utils/posthog'; +import { useReportView } from '../hooks/useReportView'; function ColorDot({ color }) { const colorClasses = { @@ -25,93 +19,36 @@ function ColorDot({ color }) { ); } -const UNREACHABLE = 'Could not reach the club just now. Nothing is lost — this device keeps its own copy.'; - export default memo(function ReportTab() { - const { reports, clearAllReports } = useTimer(); - const { showToast } = useToast(); - // The club's header, on a surface free never touches. With no club, or with - // "Show on reports" off, nothing renders and the tab is what it always was. - const { club, kit, clubName } = useClub(); - // Outbox emptiness, not the last request's result: a device that went offline - // mid-meeting must show work pending rather than claim it is saved. - const pending = useOutboxPending(); - const [copied, setCopied] = useState(false); - const [showClearConfirm, setShowClearConfirm] = useState(false); - const [showClipboardFallback, setShowClipboardFallback] = useState(false); - const [clipboardText, setClipboardText] = useState(''); - const [history, setHistory] = useState(null); - const [openMeeting, setOpenMeeting] = useState(null); - // null | { phase: 'title'|'working'|'done', title, result } - const [share, setShare] = useState(null); - - // A lapsed club keeps its cached record (the device still knows which club to - // re-check) but stops archiving, so the strip and History go with it. So - // does a club while Pro is not released (clubFeaturesOn). - const archiving = clubFeaturesOn(club); - const archiveName = clubName || club?.club?.name || 'your club'; - - const endMeeting = async () => { - const title = share?.title ?? ''; - setShare({ phase: 'working', title, result: null }); - const result = await endMeetingAndShare({ title, speeches: reports, getToken: getSessionToken }); - setShare({ phase: 'done', title, result }); - trackEvent('meeting_ended', { - speeches: result.speeches, - overtime: result.overtime, - titled: Boolean(result.title), - shared: Boolean(result.url), - surface: 'zoom', - }); - }; - - // Every destination reports itself, so the "artifacts that travel" thesis has - // a number behind it rather than an intuition. - const onShareChannel = async (channel) => { - const result = share?.result; - trackEvent('report_shared', { channel, surface: 'zoom' }); - if (channel === 'image') { - const copied = await copyReportImage(result?.blob, { filename: result?.filename }); - if (!copied.ok) { - showToast('That image could not be copied on this device', 'warning'); - return false; - } - if (copied.method === 'download') showToast('Image downloaded', 'success'); - return true; - } - if (channel === 'link') { - try { - await navigator.clipboard.writeText(result.url); - return true; - } catch { - showToast('Could not copy the link', 'warning'); - return false; - } - } - return true; - }; - - const openHistory = async () => { - setOpenMeeting(null); - setHistory({ loading: true, error: null, meetings: [] }); - const result = await fetchHistory({ getToken: getSessionToken }); - if (!result.ok) { - setHistory({ loading: false, error: UNREACHABLE, meetings: [] }); - return; - } - setHistory({ loading: false, error: null, meetings: result.meetings }); - trackEvent('report_history_viewed', { meetings: result.meetings.length, surface: 'zoom' }); - }; - - const selectMeeting = async (meetingId) => { - setOpenMeeting({ loading: true, error: null, meeting: null }); - const result = await fetchMeeting(meetingId, { getToken: getSessionToken }); - if (!result.ok) { - setOpenMeeting({ loading: false, error: UNREACHABLE, meeting: null }); - return; - } - setOpenMeeting({ loading: false, error: null, meeting: result.meeting }); - }; + const { + reports, + club, + kit, + clubName, + pending, + copied, + showClearConfirm, + setShowClearConfirm, + showClipboardFallback, + setShowClipboardFallback, + clipboardText, + setClipboardText, + history, + setHistory, + openMeeting, + setOpenMeeting, + share, + setShare, + archiving, + archiveName, + endMeeting, + onShareChannel, + openHistory, + selectMeeting, + copyToClipboard, + handleClear, + handleConfirmClear, + } = useReportView(); const speechRows = (rows) => ( @@ -136,57 +73,6 @@ export default memo(function ReportTab() {
); - const copyToClipboard = async () => { - if (reports.length === 0) { - showToast('No reports to copy', 'warning'); - return; - } - - // Format as tab-separated values - const header = 'Name\tRole\tDuration\tStatus\tOver time\tComments\n'; - const rows = reports.map(r => - `${r.name}\t${r.role}\t${r.duration}\t${r.color}\t${r.disqualified ? 'Yes' : ''}\t${r.comments || ''}` - ).join('\n'); - const text = header + rows; - - // The secondary destination, and the oldest one. Counted alongside the - // others so "reports that travel" is one funnel rather than two. - trackEvent('report_shared', { channel: 'text', surface: 'zoom' }); - - try { - // Check if clipboard API is available - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - showToast('Copied to clipboard', 'success'); - } else { - // Fallback: show modal with text - setClipboardText(text); - setShowClipboardFallback(true); - } - } catch (error) { - console.error('Failed to copy:', error); - // Fallback: show modal with text - setClipboardText(text); - setShowClipboardFallback(true); - } - }; - - const handleClear = () => { - if (reports.length === 0) { - return; - } - setShowClearConfirm(true); - }; - - // Deliberately local only: the archive is the club's record of the evening - // and clearing this device's scratch list must not reach into it. - const handleConfirmClear = () => { - clearAllReports(); - setShowClearConfirm(false); - }; - return (
{archiving && ( diff --git a/apps/zoom-app/src/hooks/useAgendaEditor.js b/apps/zoom-app/src/hooks/useAgendaEditor.js new file mode 100644 index 0000000..c55ddf1 --- /dev/null +++ b/apps/zoom-app/src/hooks/useAgendaEditor.js @@ -0,0 +1,274 @@ +import { useState } from 'react'; +import { useTimer } from '../context/TimerContext'; +import { useToast } from '../context/ToastContext'; +import { trackEvent } from '../utils/posthog'; +import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; +import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, agendaItemNeedsRole } from '@toastmaster-timer/shared'; + +/** + * Everything the Agenda tab does, apart from how it looks: importing an + * EasySpeak or one-per-line agenda, adding, editing, reordering and removing + * rows, picking a role for a row that needs one, and loading a row onto the + * timer. + * + * Lifted out of AgendaTab unchanged so design B's Agenda tab (plan 13) draws + * the same agenda without a second copy of the logic. + * + * @param {{onSwitchToLive?: () => void}} options + * @returns {Object} state and handlers for an agenda view + */ +export function useAgendaEditor({ onSwitchToLive } = {}) { + const { + agenda, + activeSpeakerId, + addToAgenda, + removeFromAgenda, + reorderAgenda, + updateAgendaItem, + loadSpeakerFromAgenda, + importBulkSpeakers, + importEasySpeakSpeakers, + clearAllAgenda, + roleRules, + roleOptions, + } = useTimer(); + const { showToast } = useToast(); + // Standard Speech unless it was removed in Edit Rules; then the first listed + // role, so the picker never shows one role while saving another. + const defaultRole = roleOptions.includes('Standard Speech') ? 'Standard Speech' : roleOptions[0] || 'Standard Speech'; + const needsRole = (item) => agendaItemNeedsRole(item, roleOptions); + const needsRoleCount = agenda.filter(needsRole).length; + + const [showImportModal, setShowImportModal] = useState(false); + const [showAddModal, setShowAddModal] = useState(false); + const [importTab, setImportTab] = useState('easyspeak'); // 'simple' | 'easyspeak' + const [importText, setImportText] = useState(''); + const [easySpeakText, setEasySpeakText] = useState(''); + const [editItem, setEditItem] = useState(null); + const [newSpeakerName, setNewSpeakerName] = useState(''); + const [newSpeakerRole, setNewSpeakerRole] = useState(defaultRole); + const [customRules, setCustomRules] = useState({ ...DEFAULT_CUSTOM_RULES }); + const [timeInputMode, setTimeInputMode] = useState(loadTimeInputMode); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteItemId, setDeleteItemId] = useState(null); + const [showClearAllConfirm, setShowClearAllConfirm] = useState(false); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event) => { + const { active, over } = event; + + if (active.id !== over.id) { + const oldIndex = agenda.findIndex((item) => item.id === active.id); + const newIndex = agenda.findIndex((item) => item.id === over.id); + const newOrder = arrayMove(agenda, oldIndex, newIndex); + reorderAgenda(newOrder); + + // Track agenda reordered + const movedItem = agenda[oldIndex]; + trackEvent('agenda_reordered', { + speaker_name: movedItem?.name || 'Unnamed', + old_index: oldIndex, + new_index: newIndex + }); + } + }; + + const handleSimpleImport = () => { + if (importText.trim()) { + const { count, needsRole: unmatched } = importBulkSpeakers(importText); + if (unmatched > 0) { + showToast(`Imported ${count} speaker(s). ${unmatched} need${unmatched === 1 ? 's' : ''} a role`, 'warning'); + } else { + showToast(`Imported ${count} speaker(s)`, 'success'); + } + setImportText(''); + setShowImportModal(false); + } + }; + + const handleEasySpeakImport = () => { + if (easySpeakText.trim()) { + const count = importEasySpeakSpeakers(easySpeakText); + showToast(`Imported ${count} speaker(s)`, 'success'); + setEasySpeakText(''); + setShowImportModal(false); + } + }; + + const handleAdd = () => { + if (newSpeakerName.trim()) { + const speakerData = { + name: newSpeakerName.trim(), + role: newSpeakerRole, + }; + // If Custom role, include custom rules + if (newSpeakerRole === 'Custom') { + speakerData.rules = customRules; + } + addToAgenda(speakerData); + setNewSpeakerName(''); + setNewSpeakerRole(defaultRole); + setCustomRules({ ...DEFAULT_CUSTOM_RULES }); + setShowAddModal(false); + } + }; + + const handleEdit = (item) => { + setEditItem(item); + setNewSpeakerName(item.name); + // A role that isn't listed can't be shown by the select; start on "Pick a role…". + setNewSpeakerRole(needsRole(item) ? '' : item.role); + // If item has custom rules, load them (merge so missing graceAfterRed is filled) + if (item.role === 'Custom' && item.rules) { + setCustomRules({ ...DEFAULT_CUSTOM_RULES, ...item.rules }); + } else { + setCustomRules({ ...DEFAULT_CUSTOM_RULES }); + } + setShowAddModal(true); + }; + + const handleUpdate = () => { + if (editItem && newSpeakerName.trim() && newSpeakerRole) { + const speakerData = { + name: newSpeakerName.trim(), + role: newSpeakerRole, + }; + // If Custom role, include custom rules + if (newSpeakerRole === 'Custom') { + speakerData.rules = customRules; + } + // Update in place so the speaker keeps their position in the agenda + updateAgendaItem(editItem.id, speakerData); + setEditItem(null); + setNewSpeakerName(''); + setNewSpeakerRole(defaultRole); + setCustomRules({ ...DEFAULT_CUSTOM_RULES }); + setShowAddModal(false); + } + }; + + const handlePickRole = (item, role) => { + if (!role) return; + updateAgendaItem(item.id, { name: item.name, role }); + trackEvent('agenda_role_picked', { is_custom: !(role in DEFAULT_ROLE_RULES) }); + }; + + const handleDelete = (id) => { + setDeleteItemId(id); + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = () => { + if (deleteItemId) { + removeFromAgenda(deleteItemId); + } + setShowDeleteConfirm(false); + setDeleteItemId(null); + }; + + const handleClearAll = () => { + setShowClearAllConfirm(true); + }; + + const handleConfirmClearAll = () => { + clearAllAgenda(); + setShowClearAllConfirm(false); + }; + + // Helper to format seconds as readable time + const formatTimeReadable = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins === 0) { + return `${secs} second${secs !== 1 ? 's' : ''}`; + } + if (secs === 0) { + return `${mins} minute${mins !== 1 ? 's' : ''}`; + } + return `${mins} min ${secs} sec`; + }; + + // Get explanation text for each role + const getRoleExplanation = (role) => { + const rules = roleRules[role] || DEFAULT_ROLE_RULES[role] || DEFAULT_ROLE_RULES['Standard Speech']; + return `Green: ${formatTimeReadable(rules.green)}, Yellow: ${formatTimeReadable(rules.yellow)}, Red: ${formatTimeReadable(rules.red)}`; + }; + + const handleCustomRuleChange = (field, value) => { + setCustomRules(prev => ({ ...prev, [field]: value })); + }; + + const handleSpeakerClick = (item) => { + // Without a role there are no rules to time with; the Live tab would quietly + // fall back to the default role. Ask instead. + if (needsRole(item)) { + showToast(`Pick a role for ${item.name} first`, 'warning'); + return; + } + loadSpeakerFromAgenda(item.id); + // Track speaker loaded from agenda + trackEvent('speaker_loaded_from_agenda', { + speaker_name: item.name || 'Unnamed', + role: item.role + }); + if (onSwitchToLive) { + onSwitchToLive(); + } + }; + + return { + agenda, + activeSpeakerId, + roleOptions, + defaultRole, + needsRole, + needsRoleCount, + showImportModal, + setShowImportModal, + showAddModal, + setShowAddModal, + importTab, + setImportTab, + importText, + setImportText, + easySpeakText, + setEasySpeakText, + editItem, + setEditItem, + newSpeakerName, + setNewSpeakerName, + newSpeakerRole, + setNewSpeakerRole, + customRules, + setCustomRules, + timeInputMode, + setTimeInputMode, + showDeleteConfirm, + setShowDeleteConfirm, + setDeleteItemId, + showClearAllConfirm, + setShowClearAllConfirm, + sensors, + handleDragEnd, + handleSimpleImport, + handleEasySpeakImport, + handleAdd, + handleEdit, + handleUpdate, + handlePickRole, + handleDelete, + handleConfirmDelete, + handleClearAll, + handleConfirmClearAll, + getRoleExplanation, + handleCustomRuleChange, + handleSpeakerClick, + }; +} diff --git a/apps/zoom-app/src/hooks/useReportView.js b/apps/zoom-app/src/hooks/useReportView.js new file mode 100644 index 0000000..34c9fd9 --- /dev/null +++ b/apps/zoom-app/src/hooks/useReportView.js @@ -0,0 +1,189 @@ +import { useState } from 'react'; +import { useTimer } from '../context/TimerContext'; +import { useToast } from '../context/ToastContext'; +import { clubFeaturesOn, fetchHistory, fetchMeeting, endMeetingAndShare, copyReportImage } from '@toastmaster-timer/shared'; +import { useClub, useOutboxPending } from './useClub'; +import { getSessionToken } from '../utils/zoomIdentity'; +import { trackEvent } from '../utils/posthog'; + +export const UNREACHABLE = 'Could not reach the club just now. Nothing is lost — this device keeps its own copy.'; + +/** + * Everything the Report tab does, apart from how it looks: the speeches timed + * on this device, copying them, clearing them, and for a club with Pro the + * archive strip, its history, and ending the meeting with a link and a + * picture to share. + * + * Lifted out of ReportTab unchanged so design B's Report tab (plan 13) shows + * the same report without a second copy of the logic. + * + * @returns {Object} state and handlers for a report view + */ +export function useReportView() { + const { reports, clearAllReports } = useTimer(); + const { showToast } = useToast(); + // The club's header, on a surface free never touches. With no club, or with + // "Show on reports" off, nothing renders and the tab is what it always was. + const { club, kit, clubName } = useClub(); + // Outbox emptiness, not the last request's result: a device that went offline + // mid-meeting must show work pending rather than claim it is saved. + const pending = useOutboxPending(); + const [copied, setCopied] = useState(false); + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [showClipboardFallback, setShowClipboardFallback] = useState(false); + const [clipboardText, setClipboardText] = useState(''); + const [history, setHistory] = useState(null); + const [openMeeting, setOpenMeeting] = useState(null); + // null | { phase: 'title'|'working'|'done', title, result } + const [share, setShare] = useState(null); + + // A lapsed club keeps its cached record (the device still knows which club to + // re-check) but stops archiving, so the strip and History go with it. So + // does a club while Pro is not released (clubFeaturesOn). + const archiving = clubFeaturesOn(club); + const archiveName = clubName || club?.club?.name || 'your club'; + + const endMeeting = async () => { + const title = share?.title ?? ''; + setShare({ phase: 'working', title, result: null }); + const result = await endMeetingAndShare({ title, speeches: reports, getToken: getSessionToken }); + setShare({ phase: 'done', title, result }); + trackEvent('meeting_ended', { + speeches: result.speeches, + overtime: result.overtime, + titled: Boolean(result.title), + shared: Boolean(result.url), + surface: 'zoom', + }); + }; + + // Every destination reports itself, so the "artifacts that travel" thesis has + // a number behind it rather than an intuition. + const onShareChannel = async (channel) => { + const result = share?.result; + trackEvent('report_shared', { channel, surface: 'zoom' }); + if (channel === 'image') { + const copied = await copyReportImage(result?.blob, { filename: result?.filename }); + if (!copied.ok) { + showToast('That image could not be copied on this device', 'warning'); + return false; + } + if (copied.method === 'download') showToast('Image downloaded', 'success'); + return true; + } + if (channel === 'link') { + try { + await navigator.clipboard.writeText(result.url); + return true; + } catch { + showToast('Could not copy the link', 'warning'); + return false; + } + } + return true; + }; + + const openHistory = async () => { + setOpenMeeting(null); + setHistory({ loading: true, error: null, meetings: [] }); + const result = await fetchHistory({ getToken: getSessionToken }); + if (!result.ok) { + setHistory({ loading: false, error: UNREACHABLE, meetings: [] }); + return; + } + setHistory({ loading: false, error: null, meetings: result.meetings }); + trackEvent('report_history_viewed', { meetings: result.meetings.length, surface: 'zoom' }); + }; + + const selectMeeting = async (meetingId) => { + setOpenMeeting({ loading: true, error: null, meeting: null }); + const result = await fetchMeeting(meetingId, { getToken: getSessionToken }); + if (!result.ok) { + setOpenMeeting({ loading: false, error: UNREACHABLE, meeting: null }); + return; + } + setOpenMeeting({ loading: false, error: null, meeting: result.meeting }); + }; + + + const copyToClipboard = async () => { + if (reports.length === 0) { + showToast('No reports to copy', 'warning'); + return; + } + + // Format as tab-separated values + const header = 'Name\tRole\tDuration\tStatus\tOver time\tComments\n'; + const rows = reports.map(r => + `${r.name}\t${r.role}\t${r.duration}\t${r.color}\t${r.disqualified ? 'Yes' : ''}\t${r.comments || ''}` + ).join('\n'); + const text = header + rows; + + // The secondary destination, and the oldest one. Counted alongside the + // others so "reports that travel" is one funnel rather than two. + trackEvent('report_shared', { channel: 'text', surface: 'zoom' }); + + try { + // Check if clipboard API is available + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + showToast('Copied to clipboard', 'success'); + } else { + // Fallback: show modal with text + setClipboardText(text); + setShowClipboardFallback(true); + } + } catch (error) { + console.error('Failed to copy:', error); + // Fallback: show modal with text + setClipboardText(text); + setShowClipboardFallback(true); + } + }; + + const handleClear = () => { + if (reports.length === 0) { + return; + } + setShowClearConfirm(true); + }; + + // Deliberately local only: the archive is the club's record of the evening + // and clearing this device's scratch list must not reach into it. + const handleConfirmClear = () => { + clearAllReports(); + setShowClearConfirm(false); + }; + + return { + reports, + club, + kit, + clubName, + pending, + copied, + showClearConfirm, + setShowClearConfirm, + showClipboardFallback, + setShowClipboardFallback, + clipboardText, + setClipboardText, + history, + setHistory, + openMeeting, + setOpenMeeting, + share, + setShare, + archiving, + archiveName, + endMeeting, + onShareChannel, + openHistory, + selectMeeting, + copyToClipboard, + handleClear, + handleConfirmClear, + }; +} diff --git a/apps/zoom-app/src/hooks/useRulesEditor.js b/apps/zoom-app/src/hooks/useRulesEditor.js new file mode 100644 index 0000000..6a996d6 --- /dev/null +++ b/apps/zoom-app/src/hooks/useRulesEditor.js @@ -0,0 +1,346 @@ +import { useState, useEffect, useRef } from 'react'; +import { useTimer } from '../context/TimerContext'; +import { useToast } from '../context/ToastContext'; +import { DEFAULT_ROLE_RULES, getDefaultGraceAfterRed, DEFAULT_CUSTOM_RULES, loadTimeInputMode, BREAK_ROLE, activeClubName, canPublishPresets, lapsedClubName, clubPresetsAvailable, clubPresetsLive, ensureForked, loadClub, loadClubPresets, presetSource, publishPresets, resetToClub, resolveActiveRules, useClubPresets, usePersonalPresets } from '@toastmaster-timer/shared'; +import { trackEvent } from '../utils/posthog'; +import { getSessionToken } from '../utils/zoomIdentity'; + +/** + * Everything the Roles & timing rules editor does, apart from how it looks: + * the roles and their times as edited, new roles, removing and resetting, the + * club's list (use it, use mine, reset to the club's), sharing a list with the + * club, and saving. + * + * Lifted out of EditRulesModal unchanged so design B's rules sheet (plan 13) + * edits the same rules without a second copy of the logic. + * + * @param {{isOpen: boolean, onClose: () => void}} options + * @returns {Object} state and handlers for a rules view + */ +export function useRulesEditor({ isOpen, onClose }) { + const { roleRules, roleOptions, updateRoleRules, addRoleRules, removeRoleRules, resetAllRoleRulesToDefaults, reloadRoleRules } = useTimer(); + const { showToast } = useToast(); + const [editedRules, setEditedRules] = useState({}); + const [newRoleNames, setNewRoleNames] = useState({}); + const [showResetAllConfirm, setShowResetAllConfirm] = useState(false); + const [timeInputMode, setTimeInputMode] = useState(loadTimeInputMode); + const prevOpenRef = useRef(false); + + // --- the club's list ----------------------------------------------------- + // Read once per open and kept in state, because every one of these answers + // changes underneath the modal as soon as the switch moves or a publish lands. + const [source, setSource] = useState(presetSource); + const [publishing, setPublishing] = useState(false); + const [showForkConfirm, setShowForkConfirm] = useState(false); + const forkResolverRef = useRef(null); + + const clubName = activeClubName(); + const clubHasPresets = clubPresetsAvailable(); + // A lapse leaves the club's list where it is and simply stops applying it, so + // the editor owes the timer an explanation rather than a silent reversion. + const lapsedClub = lapsedClubName(); + const clubList = clubHasPresets ? loadClubPresets() : null; + const clubId = loadClub()?.club?.id ?? null; + const mayPublish = canPublishPresets(); + + /** Re-seed the editor from whichever list just became live. */ + const showLiveList = () => { + reloadRoleRules(); + const merged = resolveActiveRules(); + delete merged[BREAK_ROLE]; + setEditedRules(merged); + setNewRoleNames({}); + setSource(presetSource()); + }; + + /** + * The one confirmation that stands between the club's list and an edit. + * + * Resolved by the modal below rather than returned directly, so every + * mutating path can `await` the same promise and do nothing at all on cancel. + */ + const confirmFork = () => new Promise((resolve) => { + forkResolverRef.current = resolve; + setShowForkConfirm(true); + }); + + const settleFork = (confirmed) => { + setShowForkConfirm(false); + const resolve = forkResolverRef.current; + forkResolverRef.current = null; + resolve?.(confirmed); + }; + + /** + * Add, update, remove and reset-all all run this first, so they cannot drift + * into four behaviours. False means the timer cancelled: write nothing, and + * leave the modal exactly as it was. + */ + const guardEdit = async () => { + const wasClubList = clubPresetsLive(); + const proceed = await ensureForked(confirmFork); + if (proceed && wasClubList) { + setSource(presetSource()); + trackEvent('club_presets_forked', { surface: 'zoom', club_id: clubId }); + } + return proceed; + }; + + const handleUseClubPresets = () => { + useClubPresets(); + showLiveList(); + }; + + const handleUseMyPresets = () => { + usePersonalPresets(); + showLiveList(); + }; + + const handleResetToClub = () => { + if (!resetToClub()) return; + showLiveList(); + trackEvent('club_presets_reset', { surface: 'zoom', club_id: clubId }); + }; + + // Initialize edited rules only when modal opens (not when roleRules changes while open) + useEffect(() => { + if (isOpen && !prevOpenRef.current) { + setEditedRules({ ...roleRules }); + setNewRoleNames({}); + setSource(presetSource()); + } + prevOpenRef.current = isOpen; + }, [isOpen, roleRules]); + + const handleRuleChange = (role, field, value) => { + setEditedRules(prev => ({ + ...prev, + [role]: { + ...prev[role], + [field]: value + } + })); + }; + + const handleAddRole = () => { + const tempId = `__new_${Date.now()}`; + setEditedRules(prev => ({ + ...prev, + [tempId]: { ...DEFAULT_CUSTOM_RULES } + })); + setNewRoleNames(prev => ({ ...prev, [tempId]: 'New role' })); + }; + + const handleNewRoleNameChange = (tempId, name) => { + setNewRoleNames(prev => ({ ...prev, [tempId]: name })); + }; + + const handleRemoveNewRole = (tempId) => { + setEditedRules(prev => { + const next = { ...prev }; + delete next[tempId]; + return next; + }); + setNewRoleNames(prev => { + const next = { ...prev }; + delete next[tempId]; + return next; + }); + }; + + const handleRemoveRole = async (role) => { + if (!(await guardEdit())) return; + setEditedRules(prev => { + const next = { ...prev }; + delete next[role]; + return next; + }); + removeRoleRules(role); + }; + + // Break is not editable here: its thresholds are derived from the length + // picked on the Live tab, so a hand-edited copy would be overwritten by the + // next pick and mislead in the meantime. + const rolesToShow = [ + ...(roleOptions ?? []).filter((role) => role !== BREAK_ROLE), + ...Object.keys(editedRules).filter((k) => k.startsWith('__new_')) + ]; + + /** + * What "Share with my club" would send: the timings on screen right now. + * + * Built from the editor rather than from storage because the saves below run + * inside a state updater, so what is on disk lags what the officer is looking + * at by a render. A publish that silently shared the previous list would be + * the worst possible bug in this feature. + */ + const publishablePresets = () => { + const rules = {}; + for (const role of rolesToShow) { + const edited = editedRules[role]; + if (!edited) continue; + const name = role.startsWith('__new_') ? (newRoleNames[role] || '').trim() : role; + if (!name) continue; + rules[name] = { + green: edited.green, + yellow: edited.yellow, + red: edited.red, + graceAfterRed: edited.graceAfterRed ?? getDefaultGraceAfterRed(name), + }; + } + return { + rules, + order: Object.keys(rules).filter((role) => !(role in DEFAULT_ROLE_RULES)), + // A built-in missing from the list is a built-in the officer removed, and + // every device in the club has to stop offering it. + hiddenBuiltins: Object.keys(DEFAULT_ROLE_RULES).filter((role) => !(role in rules)), + }; + }; + + const handlePublish = async () => { + if (publishing || !validate()) return; + setPublishing(true); + const result = await publishPresets(publishablePresets(), { getToken: getSessionToken }); + setPublishing(false); + + if (!result.ok) { + showToast( + result.error === 'forbidden' + ? 'Only a club admin or editor can share presets.' + : "Couldn't share these presets with your club. Try again.", + 'error' + ); + return; + } + + // rememberPublishedPresets already took the new list and version, so a + // device sitting on the club's list is showing what was just shared. + if (presetSource() === 'club') showLiveList(); + showToast(`Shared with ${clubName || 'your club'}.`, 'success'); + trackEvent('club_presets_published', { + surface: 'zoom', + club_id: clubId, + role: loadClub()?.role ?? null, + roles: Object.keys(publishablePresets().rules).length, + }); + }; + + /** Every reason the list on screen could not be saved or shared. */ + const validate = () => { + const seenNames = new Set(); + + for (const role of rolesToShow) { + const rules = editedRules[role]; + if (!rules || rules.green <= 0 || rules.yellow <= rules.green || rules.red <= rules.yellow) { + const displayName = role.startsWith('__new_') ? (newRoleNames[role] || role) : role; + showToast(`Invalid timing rules for ${displayName}. Green must be > 0, Yellow must be > Green, and Red must be > Yellow.`, 'error'); + return false; + } + if (role.startsWith('__new_')) { + const name = (newRoleNames[role] || '').trim(); + if (!name) { + showToast('Please enter a name for the new role.', 'error'); + return false; + } + if (seenNames.has(name) || roleOptions.includes(name)) { + showToast(`A role named "${name}" already exists.`, 'error'); + return false; + } + seenNames.add(name); + } + } + return true; + }; + + const handleSave = async () => { + if (!validate()) return; + // Nothing is written until the club's list has been forked, so cancelling + // the confirmation leaves the modal open with the edit still on screen. + if (!(await guardEdit())) return; + + for (const role of rolesToShow) { + const rules = editedRules[role]; + if (role.startsWith('__new_')) { + const name = (newRoleNames[role] || '').trim(); + if (name) { + addRoleRules(name, rules); + trackEvent('role_added', { role: name, rules }); + } + } else { + const oldRules = roleRules[role]; + const graceOld = oldRules?.graceAfterRed ?? getDefaultGraceAfterRed(role); + const graceNew = rules.graceAfterRed ?? getDefaultGraceAfterRed(role); + if (oldRules && ( + oldRules.green !== rules.green || + oldRules.yellow !== rules.yellow || + oldRules.red !== rules.red || + graceOld !== graceNew + )) { + updateRoleRules(role, rules); + trackEvent('rules_edited', { + role, + new_rules: rules, + previous_rules: oldRules + }); + } + } + } + + onClose(); + }; + + const handleReset = (role) => { + const defaultRules = DEFAULT_ROLE_RULES[role]; + if (!defaultRules) return; + setEditedRules(prev => ({ + ...prev, + [role]: { ...defaultRules } + })); + }; + + const handleResetAll = () => { + setShowResetAllConfirm(true); + }; + + const handleConfirmResetAll = async () => { + setShowResetAllConfirm(false); + if (!(await guardEdit())) return; + resetAllRoleRulesToDefaults(); + setEditedRules(prev => ({ + ...DEFAULT_ROLE_RULES, + ...Object.fromEntries(Object.entries(prev).filter(([k]) => !(k in DEFAULT_ROLE_RULES))) + })); + }; + + return { + editedRules, + newRoleNames, + showResetAllConfirm, + setShowResetAllConfirm, + timeInputMode, + setTimeInputMode, + source, + publishing, + showForkConfirm, + clubName, + clubHasPresets, + lapsedClub, + clubList, + mayPublish, + settleFork, + handleUseClubPresets, + handleUseMyPresets, + handleResetToClub, + handleRuleChange, + handleAddRole, + handleNewRoleNameChange, + handleRemoveNewRole, + handleRemoveRole, + rolesToShow, + handlePublish, + handleSave, + handleReset, + handleResetAll, + handleConfirmResetAll, + }; +} From 3148ef1e9235fb6c737550a150e8c13bd9054640 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 20:18:28 -0500 Subject: [PATCH 2/3] feat(zoom-app): design B's Agenda and Report tabs and the rules sheet Agenda: empty state with Paste from EasySpeak / Add a speaker, rows with drag dots, ON THE TIMER, finished times and the red needs-role row, a paste sheet that counts speakers before adding, an add / edit sheet. Report: rows with colour, time and OVER TIME, copy, clear, the club archive, history and share as sheets, the Pro pitch only while Pro can be bought. Roles & timing rules: one row per role, one open at a time, the club's list and sharing. --- apps/zoom-app/src/b/AgendaReport.test.jsx | 223 ++++++++++++ apps/zoom-app/src/b/AgendaTabB.jsx | 404 ++++++++++++++++++++++ apps/zoom-app/src/b/NewLookShell.jsx | 16 +- apps/zoom-app/src/b/ReportTabB.jsx | 279 +++++++++++++++ apps/zoom-app/src/b/RulesSheet.jsx | 207 +++++++++++ 5 files changed, 1121 insertions(+), 8 deletions(-) create mode 100644 apps/zoom-app/src/b/AgendaReport.test.jsx create mode 100644 apps/zoom-app/src/b/AgendaTabB.jsx create mode 100644 apps/zoom-app/src/b/ReportTabB.jsx create mode 100644 apps/zoom-app/src/b/RulesSheet.jsx 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. +

    + )} +