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,
+ };
+}
diff --git a/e2e/zoom-client/agenda-import.md b/e2e/zoom-client/agenda-import.md
index db2a9ba..5c980c2 100644
--- a/e2e/zoom-client/agenda-import.md
+++ b/e2e/zoom-client/agenda-import.md
@@ -6,6 +6,7 @@ covers:
- packages/shared/simpleFormatParser.js
- apps/zoom-app/src/context/TimerContext.jsx
- apps/zoom-app/src/components/AgendaTab.jsx
+ - apps/zoom-app/src/hooks/useAgendaEditor.js
automated:
- packages/shared/__tests__/simpleFormatParser.test.js
- apps/zoom-app/src/context/TimerContext.test.jsx
diff --git a/e2e/zoom-client/club-pro-gate.md b/e2e/zoom-client/club-pro-gate.md
index 2bd235b..6596f08 100644
--- a/e2e/zoom-client/club-pro-gate.md
+++ b/e2e/zoom-client/club-pro-gate.md
@@ -8,6 +8,7 @@ covers:
- packages/shared/clubArchive.js
- apps/zoom-app/src/hooks/useClub.js
- apps/zoom-app/src/components/ReportTab.jsx
+ - apps/zoom-app/src/hooks/useReportView.js
automated:
- packages/shared/__tests__/club.test.js
- apps/zoom-app/src/components/CardImagesModal.test.jsx
diff --git a/e2e/zoom-client/new-look.md b/e2e/zoom-client/new-look.md
index 1834ba5..23e536b 100644
--- a/e2e/zoom-client/new-look.md
+++ b/e2e/zoom-client/new-look.md
@@ -7,12 +7,14 @@ covers:
- apps/zoom-app/src/App.jsx
- apps/zoom-app/src/hooks/useNewLook.js
- apps/zoom-app/src/components/VideoPreviewLayer.jsx
+ - apps/zoom-app/src/hooks/useRulesEditor.js
automated:
- e2e/zoom.new-look.spec.js
- apps/zoom-app/src/b/NewLookShell.test.jsx
- apps/zoom-app/src/b/TimerCard.test.jsx
- apps/zoom-app/src/b/TopBar.test.jsx
- apps/zoom-app/src/b/Sheet.test.jsx
+ - apps/zoom-app/src/b/AgendaReport.test.jsx
- apps/zoom-app/src/hooks/useNewLook.test.js
needs:
- dev deployed
@@ -123,8 +125,42 @@ disabled, and the status line "Start works once your video is on". Turn video on
camera back and enables Start.
Evidence: screenshots. Human: the camera toggle is the user's.
+### NL-13 · auto · Agenda: paste, pick the missing role, time from the agenda
+Do: Agenda tab → Paste from EasySpeak; paste `demo-agenda-easyspeak.txt`; Add. Then Paste → One
+per line: `NL One (Short Roles)` and `NL Two (Table Topics Master)`; Add. Pick Table Topics
+Speech on NL Two's red row. Tap NL One.
+Expect: the counts match ("Found N speakers"); NL Two is red with "(Table Topics Master) is not
+one of your roles" until a role is picked; tapping NL One opens the Timer tab with NL One on the
+card and "Up next: NL Two"; a Start there turns the video green at 0:30.
+Evidence: screenshots; `toastmaster_agenda`.
+
+### NL-14 · auto · Report: rows, copy, clear
+Do: finish two short speeches, one past red plus grace. Report tab → Copy report; Clear (cancel,
+then confirm).
+Expect: one row per speech with its colour dot, time and last colour, "OVER TIME" on the long
+one and the grace note under the list; the clipboard holds the tab-separated report; Clear asks
+first and then empties the list.
+Evidence: screenshots; clipboard text; `toastmaster_reports`.
+
+### NL-15 · auto · Report for a club with Pro: archive, history, share
+Do: with FLAGS_FORCE "1" on the club device, finish a speech; Report tab → History, open the
+newest meeting, back; End meeting & share with a name.
+Expect: "Saved to Claude Test Club" above the list (or "waiting to upload" while offline);
+History opens as a sheet listing meetings; the meeting opens with the club header; the share
+sheet ends with "Meeting saved", a link and the picture; the note says Clear only empties this
+device.
+Evidence: screenshots; `POST /api/club/meetings/...` in the Worker log.
+
+### NL-16 · auto · Roles & timing rules: edit, save, and the club's list
+Do: ⋯ → Roles & timing rules; open Short Roles; set Green to 0:20; Save. Start a Short Roles
+speech. On the club device (FLAGS_FORCE "1"), open the sheet again.
+Expect: rows show each role's three times; one row opens at a time; after Save the role list
+shows 0:20 · 0:45 · 1:00 and the video turns green at 0:20. On the club device the strip reads
+"Using Claude Test Club's timings" with "Use mine".
+Evidence: screenshots; `toastmaster_role_rules`.
+
## Not covered
- Today's panel with `new_look` off: the other specs, run with FLAGS_FORCE "1,new_look=0".
- Audio of the sounds: timer-sounds.
-- Agenda and Report tabs in the new look, the Roles & timing rules sheet, the Pro sheets,
- the banners and the stage restyle: steps added with plan 13's Phases 2 and 3.
+- The Pro sheets, the banners and the stage restyle: steps added with plan 13's Phase 3.
+- Dragging agenda rows: the same dnd-kit sorting as today's tab, covered by the e2e and unit tests.
diff --git a/e2e/zoom.new-look.spec.js b/e2e/zoom.new-look.spec.js
index d17ff4b..5fd63d8 100644
--- a/e2e/zoom.new-look.spec.js
+++ b/e2e/zoom.new-look.spec.js
@@ -124,3 +124,72 @@ test('the ⋯ menu holds the footer\'s old buttons, and Pro once it is released'
await menu.getByRole('menuitem', { name: 'Send feedback' }).click();
await expect(menu).toHaveCount(0);
});
+
+// ── Phase 2: Agenda, Report, Roles & timing rules ─────────────────────────
+
+const tab = (page, name) => page.getByRole('navigation', { name: 'Sections' }).getByRole('button', { name });
+
+test('agenda: paste one per line, pick the missing role, then time from the agenda', async ({ page }) => {
+ await openPanel(page);
+ await tab(page, 'Agenda').click();
+ await expect(page.getByText('No speakers yet')).toBeVisible();
+ await page.getByRole('button', { name: 'Paste from EasySpeak' }).click();
+ await page.getByRole('tab', { name: 'One per line' }).click();
+ await page.getByRole('textbox', { name: 'One speaker per line' }).fill('Ana (Ice Breaker)\nAlex (Table Topics Master)');
+ await expect(page.getByText('Found 2 speakers.', { exact: false })).toBeVisible();
+ await page.getByRole('button', { name: 'Add 2 speakers' }).click();
+
+ const list = page.getByRole('list', { name: 'Agenda' });
+ await expect(list.getByRole('listitem')).toHaveCount(2);
+ await expect(page.getByText('1 row needs a role', { exact: false })).toBeVisible();
+ // A row that needs a role is not loaded onto the timer.
+ await page.getByRole('button', { name: 'Alex: pick a role first' }).click();
+ await expect(page.getByTestId('timer-card')).toHaveCount(0);
+ await page.getByRole('combobox', { name: 'Pick a role for Alex' }).selectOption('Table Topics Speech');
+ await expect(page.getByText('1 row needs a role', { exact: false })).toHaveCount(0);
+
+ await page.getByRole('button', { name: 'Put Ana on the timer' }).click();
+ await expect(page.getByRole('button', { name: 'Speaker: Ana. Change speaker' })).toBeVisible();
+ await expect(page.getByTestId('up-next')).toContainText('Alex');
+});
+
+test('agenda: an EasySpeak paste fills the agenda', async ({ page }) => {
+ const { readFileSync } = await import('node:fs');
+ const text = readFileSync(new URL('../demo-agenda-easyspeak.txt', import.meta.url), 'utf8');
+ await openPanel(page);
+ await tab(page, 'Agenda').click();
+ await page.getByRole('button', { name: 'Paste from EasySpeak' }).click();
+ await page.getByRole('textbox', { name: 'EasySpeak agenda' }).fill(text);
+ await expect(page.getByText(/Found \d+ speakers?\./)).toBeVisible();
+ await page.getByRole('button', { name: /^Add \d+ speakers?$/ }).click();
+ expect(await page.getByRole('list', { name: 'Agenda' }).getByRole('listitem').count()).toBeGreaterThan(2);
+ expect(await noSidewaysScroll(page)).toBe(true);
+});
+
+test('report: empty, then a speech to copy; the rules sheet edits a role', async ({ page, context }) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+ await openPanel(page);
+ await tab(page, 'Report').click();
+ await expect(page.getByText('No speeches timed yet')).toBeVisible();
+ await page.getByRole('button', { name: 'Go to the timer' }).click();
+
+ await page.getByRole('button', { name: 'Start' }).click();
+ await expect(page.getByTestId('timer-display')).not.toHaveText('00:00', { timeout: 4000 });
+ await page.getByRole('button', { name: 'Finish' }).click();
+ await tab(page, 'Report').click();
+ await expect(page.getByRole('list', { name: 'Speeches timed' }).getByRole('listitem')).toHaveCount(1);
+ await page.getByRole('button', { name: 'Copy report' }).click();
+ await expect(page.getByRole('button', { name: 'Copied' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'More' }).click();
+ await page.getByRole('menuitem', { name: 'Roles & timing rules' }).click();
+ const sheet = page.getByRole('dialog', { name: 'Roles & timing rules' });
+ await sheet.getByRole('button', { name: /^Ice Breaker/ }).click();
+ await sheet.getByLabel('Green').fill('3:30');
+ await sheet.getByLabel('Green').blur();
+ await sheet.getByRole('button', { name: 'Save' }).click();
+ await expect(sheet).toHaveCount(0);
+ await tab(page, 'Timer').click();
+ await page.getByRole('button', { name: /Timing role/ }).click();
+ await expect(page.getByRole('option', { name: /Ice Breaker/ })).toContainText('3:30 · 5:00 · 6:00');
+});