diff --git a/docs/plans/74-warn-unnamed-speakers-on-analysis.md b/docs/plans/74-warn-unnamed-speakers-on-analysis.md new file mode 100644 index 0000000..340a876 --- /dev/null +++ b/docs/plans/74-warn-unnamed-speakers-on-analysis.md @@ -0,0 +1,49 @@ +# Plan: Warn on Analysis tab when speakers are unnamed + +**Story**: #74 +**Spec**: N/A +**Branch**: feature/74-warn-unnamed-speakers-on-analysis +**Date**: 2026-05-29 +**Mode**: Standard — small vanilla JS/CSS change; project has no frontend test framework, no need for TDD scaffolding. + +## Technical Decisions + +### TD-1: Reuse `.overview-notice` CSS class instead of introducing `.analysis-warning` +- **Context**: The Analysis tab needs a visual banner; `.overview-notice` already provides the exact look (warning-colored left border, surface background, muted text) used by the Overview tab. +- **Decision**: Reuse `.overview-notice` directly on the Analysis tab banner. +- **Alternatives considered**: A parallel `.analysis-warning` class — rejected to avoid visual drift between two identical banners (Architect Minor finding). + +### TD-2: Read unnamed-speaker info from `window._speakerEditorState` +- **Context**: The transcript viewer already computes `speakers` and `speakerIds` and stashes them on a global state bag. Re-deriving from the meeting object would duplicate logic. +- **Decision**: Read `window._speakerEditorState` in `analysis-viewer.js` with an inline comment documenting the dependency on `transcript-viewer.renderSegments()`. +- **Alternatives considered**: Re-fetching from a `currentMeeting` global — none exists; would require a new global. + +### TD-3: Show the warning both before and after Generate Prompt +- **Context**: After generation the prompt UI replaces the generator UI. Hiding the warning at that point would let users copy the prompt without seeing that it still contains raw `SPEAKER_xx` labels. +- **Decision**: Render the warning in both `renderAnalysisTab` (initial) and `renderPromptContent` (post-generate). +- **Alternatives considered**: Hide post-generate — rejected; the warning is more useful precisely when the user is about to copy the prompt. + +## Files to Create or Modify + +- `frontend/js/components/analysis-viewer.js` — add helpers `getUnnamedSpeakersInfo()` and `renderUnnamedSpeakersWarning()`; prepend the banner in both render functions. +- `frontend/css/styles.css` — no new class; if a tab-context margin override is needed, add a one-liner. + +## Approach per AC + +### AC: Warning visible on the Analysis tab when speakers are not labeled +- On Analysis tab render, count speakers in `window._speakerEditorState.speakerIds` where `isUnidentifiedSpeaker(state.speakers[id] || id)` is true. +- If `unnamed > 0`, render an `.overview-notice` banner at the top with a sentence explaining the impact and pointing users to the Transcript tab. +- If `unnamed === 0` or state is missing, render nothing. + +## Commit Sequence + +1. `[#74] Warn on analysis tab when speakers are unnamed` + +## Risks and Trade-offs + +- Cross-component read of `window._speakerEditorState` couples `analysis-viewer.js` to `transcript-viewer.js` lifecycle. Mitigated by an inline comment so a future refactor flags the dependency. +- Persistent warning after Generate Prompt is intentional (see TD-3). Worth a note in the PR description so reviewers don't read it as accidental duplication. + +## Deviations from Plan + +_Populated after implementation._ diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 614cc97..2cb2cfa 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1239,7 +1239,47 @@ body { color: var(--text-muted); } -/* Analysis */ +/* Unnamed-speakers warning (shared by Plain Text + Analysis tabs) */ +.unnamed-speakers-warning { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 20px; + padding: 14px 18px; + background: color-mix(in srgb, var(--warning) 18%, var(--bg-surface)); + border: 1px solid var(--warning); + border-left: 4px solid var(--warning); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +.unnamed-speakers-warning-icon { + flex: 0 0 auto; + font-size: 20px; + line-height: 1.2; + color: var(--warning); +} + +.unnamed-speakers-warning-body { + flex: 1 1 auto; +} + +.unnamed-speakers-warning-body strong { + display: block; + margin-bottom: 2px; + color: var(--text); +} + +.unnamed-speakers-warning code { + background: var(--bg); + padding: 1px 6px; + border-radius: 3px; + font-size: 12px; + border: 1px solid var(--border); +} + .analysis-generator { text-align: center; padding: 40px 20px; diff --git a/frontend/js/components/analysis-viewer.js b/frontend/js/components/analysis-viewer.js index d5d8071..f22548a 100644 --- a/frontend/js/components/analysis-viewer.js +++ b/frontend/js/components/analysis-viewer.js @@ -1,6 +1,7 @@ function renderAnalysisTab(container, meetingId, meetingType) { if (meetingId) container.dataset.meetingId = meetingId; container.innerHTML = ` + ${renderUnnamedSpeakersWarning()}

Select a template to generate a prompt you can paste into any LLM for analysis.

@@ -84,6 +85,7 @@ function buildPlainTextTranscript() { function renderPromptContent(container, prompt) { container.innerHTML = ` + ${renderUnnamedSpeakersWarning()}
diff --git a/frontend/js/components/transcript-viewer.js b/frontend/js/components/transcript-viewer.js index fb4753f..c7dc629 100644 --- a/frontend/js/components/transcript-viewer.js +++ b/frontend/js/components/transcript-viewer.js @@ -478,6 +478,7 @@ function renderPlainTextTab(container) { const plainText = lines.join('\n'); container.innerHTML = ` + ${renderUnnamedSpeakersWarning()}
diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 16c25c5..e6dd051 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -46,6 +46,36 @@ function isUnidentifiedSpeaker(name) { return name === 'UNKNOWN' || /^SPEAKER_\d+$/.test(name); } +// Reads `window._speakerEditorState`, populated by `renderSegments()` in +// transcript-viewer.js when the meeting loads. If that contract changes, +// update this read site. +function getUnnamedSpeakersInfo() { + const state = window._speakerEditorState; + if (!state || !state.speakerIds || !state.speakers) return null; + const total = state.speakerIds.length; + const unnamed = state.speakerIds.filter(id => + isUnidentifiedSpeaker(state.speakers[id] || id) + ).length; + return { unnamed, total }; +} + +function renderUnnamedSpeakersWarning() { + const info = getUnnamedSpeakersInfo(); + if (!info || info.unnamed === 0) return ''; + const { unnamed, total } = info; + const verb = unnamed === 1 ? 'is' : 'are'; + const noun = unnamed === 1 ? 'speaker' : 'speakers'; + return ` + + `; +} + function getRecentSpeakerNames() { try { return JSON.parse(localStorage.getItem('recentSpeakerNames') || '[]');