diff --git a/.github/workflows/porter-test.yml b/.github/workflows/porter-test.yml new file mode 100644 index 0000000..decb2b1 --- /dev/null +++ b/.github/workflows/porter-test.yml @@ -0,0 +1,29 @@ +"on": + push: + branches: + - test-porter +name: Deploy to mentra-notes-test-porter +jobs: + porter-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set Github tag + id: vars + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + - name: Setup porter + uses: porter-dev/setup-porter@v0.1.0 + - name: Deploy stack + timeout-minutes: 30 + run: exec porter apply -f ./porter.yaml + env: + PORTER_APP_NAME: mentra-notes-dev + PORTER_CLUSTER: "4689" + PORTER_DEPLOYMENT_TARGET_ID: 4a24a192-04c8-421f-8fc2-22db1714fdc0 + PORTER_HOST: https://dashboard.porter.run + PORTER_PR_NUMBER: ${{ github.event.number }} + PORTER_PROJECT: "15081" + PORTER_REPO_NAME: ${{ github.event.repository.name }} + PORTER_TAG: ${{ steps.vars.outputs.sha_short }} + PORTER_TOKEN: ${{ secrets.PORTER_APP_15081_4689 }} diff --git a/env.example b/env.example index e9c2c99..aa62eb5 100644 --- a/env.example +++ b/env.example @@ -31,6 +31,25 @@ ANTHROPIC_API_KEY=your_anthropic_api_key_here MONGODB_URI=mongodb+srv://user:pass@cluster.mongodb.net/notes +# ============================================================================= +# Cloudflare R2 - For storing transcripts and photos +# ============================================================================= + +CLOUDFLARE_R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com +CLOUDFLARE_R2_BUCKET_NAME=mentra-notes +CLOUDFLARE_R2_ACCESS_KEY_ID=your_r2_access_key +CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_r2_secret_key + +# ============================================================================= +# Email (Resend) - For sending notes and transcripts via email +# ============================================================================= + +RESEND_API_KEY=re_your_resend_api_key + +# Optional: Override the base URL for download links in emails. +# If not set, automatically derived from the request Origin/Host header. +# BASE_URL=https://your-app-domain.com + # ============================================================================= # Development Tip # ============================================================================= diff --git a/figma-design/src/app/views/FolderDetail.tsx b/figma-design/src/app/views/FolderDetail.tsx index 15fe9fb..2f8a8e0 100644 --- a/figma-design/src/app/views/FolderDetail.tsx +++ b/figma-design/src/app/views/FolderDetail.tsx @@ -304,7 +304,7 @@ This note covers the discussion from ${timeRange}. )} - {folder.isTrashed ? 'Restore from Trash' : 'Move to Trash'} + {folder.isTrashed ? 'Restore from Trash' : 'Trash'} diff --git a/issues/1-auto-conversation-notes/PLAN.md b/issues/1-auto-conversation-notes/PLAN.md new file mode 100644 index 0000000..81c83c6 --- /dev/null +++ b/issues/1-auto-conversation-notes/PLAN.md @@ -0,0 +1,406 @@ +# Automatic Conversation Notes — Implementation Plan + +Owner: Aryan +Priority: High — Core product differentiator + +--- + +## Overview + +The system listens to a continuous transcript stream, classifies chunks as meaningful or filler, tracks conversations in real-time, and generates structured notes when conversations end. Built on a 40-second heartbeat cycle with 5 pipeline stages. + +--- + +## What Already Exists + +- `TranscriptManager` — accumulates transcript segments (interim + final), stores daily transcripts, generates hour summaries +- `DailyTranscript` model — stores segments grouped by user's timezone day +- `HourSummary` model — AI-generated hourly summaries +- `Note` model — both manual and AI-generated notes +- `NotesManager` — AI note generation + manual notes +- LLM abstraction layer (`services/llm/`) — Gemini + Anthropic support +- WebSocket sync (`@ballah/synced`) — real-time state broadcast to frontend + +--- + +## Step-by-Step Build Order + +### Phase 1: Buffer System & Chunk Storage + +**Goal:** Get 40-second chunks flowing and persisting. + +#### Step 1.1 — Create Chunk Database Model +- New file: `src/backend/models/transcript-chunk.model.ts` +- Schema fields: + - `userId: string` + - `chunkIndex: number` (sequential per day) + - `text: string` (raw transcript text for this 40s window) + - `wordCount: number` + - `startTime: Date` + - `endTime: Date` + - `date: string` (YYYY-MM-DD, user's timezone) + - `classification: 'pending' | 'filler' | 'meaningful' | 'auto-skipped'` + - `conversationId: string | null` (links to which conversation this chunk belongs to, if any) + - `metadata: object` (flexible field for debug info) +- Index on `{ userId, date, chunkIndex }` + +#### Step 1.2 — Create ChunkBuffer Class +- New file: `src/backend/session/managers/ChunkBufferManager.ts` +- Responsibilities: + - Accumulate incoming transcript segments from `TranscriptManager` into a rolling buffer + - Every 40 seconds, package buffer contents into a chunk + - Handle sentence boundaries — if a sentence is mid-flow at the 40s mark, include the full sentence in the current chunk + - Persist every chunk to DB (including filler — needed for Stage 5 safety pass) + - Emit event / call callback when a new chunk is ready +- Uses a `setInterval` (40s) as the heartbeat +- Reads from `TranscriptManager`'s existing segment accumulation + +#### Step 1.3 — Wire ChunkBuffer into NotesSession +- Modify `src/backend/session/NotesSession.ts`: + - Instantiate `ChunkBufferManager` + - Feed transcript data from `TranscriptManager` into the buffer + - Start/stop the 40s heartbeat with session lifecycle + +#### Step 1.4 — Test the Buffer +- Verify chunks are being created every ~40 seconds +- Verify sentence boundary handling works +- Verify chunks persist to MongoDB +- Verify chunk index increments correctly per day + +--- + +### Phase 2: Triage Classifier (Stage 2) + +**Goal:** Classify each chunk as auto-skip, filler, or meaningful. + +#### Step 2.1 — Create Triage Classifier +- New file: `src/backend/services/auto-notes/TriageClassifier.ts` +- Logic: + 1. **Auto-skip check** (no LLM call needed): + - Under 4 words AND no high-signal keywords → mark `auto-skipped` + - Even short chunks with important keywords go to LLM (e.g., "Cancel the deal.") + 2. **LLM classification**: + - Send chunk text + domain context to LLM + - Prompt returns: `FILLER` or `MEANINGFUL` + - If `MEANINGFUL`: pull previous 2 chunks from DB for context, pass to Stage 3 + +#### Step 2.2 — Create Domain Context Configuration +- New file: `src/backend/services/auto-notes/domain-config.ts` +- Define room context profiles: + - `medical`: patient names, medications, vitals, procedures + - `engineering`: deploy, sprint, bug, migration, deadline + - `home`: user-configured keywords + - `general`: sensible defaults +- Store active profile in user settings (extend `UserSettings` model) +- Inject domain context into every classifier prompt + +#### Step 2.3 — Create Configurable Parameters Store +- New file: `src/backend/services/auto-notes/config.ts` +- All tunable parameters in one place: + ``` + BUFFER_INTERVAL_MS = 40_000 + PRE_FILTER_WORD_MIN = 4 + SILENCE_PAUSE_CHUNKS = 1 + SILENCE_END_CHUNKS = 3 + CONTEXT_LOOKBACK_CHUNKS = 2 + SUMMARY_MAX_WORDS = 300 + RESUMPTION_WINDOW_MS = 30 * 60 * 1000 + CHUNK_RETENTION_HOURS = 24 + ``` + +#### Step 2.4 — Wire Triage into the Pipeline +- When `ChunkBufferManager` emits a new chunk: + - Run it through `TriageClassifier` + - Update chunk's `classification` field in DB + - If `MEANINGFUL` → forward to Stage 3 (conversation tracker) + +--- + +### Phase 3: Conversation Tracker (Stage 3) + +**Goal:** Track active conversations, handle continuations, new topics, and session end. + +#### Step 3.1 — Create Conversation Model +- New file: `src/backend/models/conversation.model.ts` +- Schema fields: + - `userId: string` + - `date: string` (YYYY-MM-DD) + - `title: string` (generated after conversation ends) + - `status: 'active' | 'paused' | 'ended'` + - `startTime: Date` + - `endTime: Date | null` + - `chunkIds: string[]` (ordered list of chunk IDs in this conversation) + - `runningSummary: string` (compressed every 3 chunks) + - `pausedAt: Date | null` + - `resumedFrom: string | null` (ID of conversation this was resumed from) + - `noteId: string | null` (link to generated note) + +#### Step 3.2 — Create ConversationTracker +- New file: `src/backend/services/auto-notes/ConversationTracker.ts` +- State machine with states: `IDLE`, `TRACKING`, `PAUSED` +- On each incoming meaningful chunk: + - If `IDLE` → start new conversation, transition to `TRACKING` + - If `TRACKING` → classify chunk as: + - `CONTINUATION` — same topic, append to conversation + - `NEW_CONVERSATION` — close current, start new + - `FILLER` — transition to `PAUSED` (1 silent chunk = pause) + - If `PAUSED`: + - Next chunk is on-topic → resume conversation (back to `TRACKING`) + - Next chunk is filler → increment silence counter + - 3 consecutive silent/filler chunks (2 min) → end conversation permanently +- LLM prompt for classification includes: current running summary + new chunk + domain context + +#### Step 3.3 — Running Summary Compression +- Every 3 chunks added to a conversation: + - Send full running summary + last 3 chunks to LLM + - Compress to under 300 words + - Preserve: names, numbers, decisions, action items + - Update `runningSummary` field on Conversation document + +#### Step 3.4 — Resumption Detection +- When a new meaningful chunk arrives and there's no active conversation: + - Check DB for conversations with `status: 'paused'` or `status: 'ended'` in the last 30 minutes + - Send chunk + previous conversation's summary to LLM: "Is this a continuation of the previous conversation?" + - If yes → reopen that conversation instead of creating a new one + - Update `resumedFrom` field + +#### Step 3.5 — Wire Tracker into Pipeline +- Modify the triage output handler: + - `MEANINGFUL` chunks → `ConversationTracker.processChunk()` + - Tracker manages its own state machine + - When conversation ends → trigger Stage 4 + +--- + +### Phase 4: Note Generation (Stage 4) + +**Goal:** Generate structured notes from completed conversations. + +#### Step 4.1 — Create Note Generator +- New file: `src/backend/services/auto-notes/NoteGenerator.ts` +- When a conversation ends: + 1. Fetch all chunks belonging to this conversation from DB + 2. Assemble full transcript text (ordered by chunkIndex) + 3. Send to stronger LLM (e.g., Gemini Pro or Claude Sonnet) with prompt to generate: + - **Title** (e.g., "Client Deadline Acceleration") + - **Participants** (Speaker 1, Speaker 2, etc.) + - **Summary** (2-3 paragraph overview) + - **Key Points** (bulleted list of facts discussed) + - **Decisions Made** (bulleted list) + - **Action Items** (with owners if identifiable) + 4. Create a `Note` document using existing `Note` model + 5. Link note back to Conversation document (`noteId` field) + 6. Mark note as `type: 'auto'` to distinguish from manual notes + +#### Step 4.2 — Integrate with Existing NotesManager +- Use existing `NotesManager` to create and sync the note +- The note should appear in the frontend alongside manual notes +- Tag auto-generated notes visually (e.g., "Auto" badge) + +#### Step 4.3 — Wire Note Generation into Pipeline +- When `ConversationTracker` ends a conversation → call `NoteGenerator.generate(conversationId)` +- Handle errors gracefully (LLM timeout, etc.) — retry once, then log and move on + +--- + +### Phase 4B: Frontend — "Conversations" Tab + +**Goal:** Surface auto-detected conversations in a dedicated tab on the DayPage, with real-time status indicators. + +#### Step 4B.1 — Add Shared Types + +- Add to `src/shared/types.ts`: + ```typescript + interface Conversation { + id: string; + userId: string; + date: string; + title: string; + status: 'active' | 'paused' | 'ended'; + startTime: Date; + endTime: Date | null; + runningSummary: string; + chunks: ConversationChunk[]; + note: Note | null; // populated after note generation + generatingNote: boolean; // true while LLM is producing the note + } + + interface ConversationChunk { + id: string; + text: string; + startTime: Date; + endTime: Date; + wordCount: number; + } + ``` +- Add `ConversationManagerI` interface: + ```typescript + interface ConversationManagerI { + state: { + conversations: Conversation[]; + activeConversationId: string | null; + }; + } + ``` + +#### Step 4B.2 — Create Backend ConversationManager + +- New file: `src/backend/session/managers/ConversationManager.ts` +- Exposes conversations for the current day via `@ballah/synced` +- Syncs state to frontend in real-time: + - When `ConversationTracker` starts a new conversation → push to `conversations` array, set `activeConversationId` + - When new chunks arrive → append to active conversation's `chunks` + - When conversation is paused/ended → update `status` + - When note generation starts → set `generatingNote: true` + - When note generation finishes → attach `note`, set `generatingNote: false` +- Wire into `NotesSession.ts` alongside existing managers + +#### Step 4B.3 — Add "Conversations" Tab to DayPage + +- Modify `src/frontend/pages/day/DayPage.tsx`: + - Add to `TabType`: `"conversations"` + - Add to `tabs` array: `{ id: "conversations", label: "Conversations", icon: MessagesSquare }` + - Place between Transcript and Notes tabs (order: Transcript, Conversations, Notes) +- Access data: `const conversations = session?.conversation?.conversations ?? [];` + +#### Step 4B.4 — Create ConversationsTab Component + +- New file: `src/frontend/pages/day/components/tabs/ConversationsTab.tsx` +- **List view:** Shows conversation cards for the selected day, ordered by startTime (newest first) +- **Each card shows:** + - Title (or "Untitled Conversation" while active/before note generation) + - Time range (e.g., "2:14 PM – 2:38 PM") + - Status badge: "Live" (green pulse) / "Paused" / "Ended" + - Summary preview (first ~120 chars of runningSummary or note summary) +- **Tap a card → expand inline or navigate to detail view showing:** + - **Section 1: Summary** — the full structured note (title, participants, summary, key points, decisions, action items) + - **Section 2: Full Transcript** — all chunks concatenated in order with timestamps +- **Empty state:** Friendly message like "No conversations detected yet today" + +#### Step 4B.5 — Real-Time "Generating Note" Status Bar + +- When a conversation ends and note generation begins, show a **status banner** at the top of the Conversations tab: + - Banner text: "Generating conversation note..." with a subtle loading animation + - Stays visible while `generatingNote: true` on any conversation + - Disappears once the note is attached +- Also show inline on the conversation card itself: replace the status badge with "Generating note..." indicator +- This gives the user immediate feedback that the system detected something and is processing it + +#### Step 4B.6 — Active Conversation Live Indicator + +- While a conversation is `active` (being tracked in real-time): + - The card has a green "Live" badge with a subtle pulse animation + - The running summary updates in real-time as new chunks come in + - The transcript section (if expanded) shows chunks appearing live +- While `paused`: show an amber "Paused" badge — the system is waiting to see if the conversation resumes +- This makes the whole pipeline visible to the user — they can see the system is listening and deciding + +--- + +### Phase 5: Safety Pass (Stage 5) — DO LATER, ONLY IF NEEDED + +**Goal:** End-of-day review to catch missed conversations and discard false positives. + +#### Step 5.1 — Create Safety Pass Service +- New file: `src/backend/services/auto-notes/SafetyPass.ts` +- Runs once at end of day (triggered by `TimeManager` EOD detection) +- **Job A — Review Captured Conversations:** + - For each conversation captured today: send summary + sample chunks to LLM + - LLM returns: `KEEP` or `DISCARD` + - Discarded conversations: mark note as `discarded: true` (soft delete) +- **Job B — Scan Filler for Missed Conversations:** + - Fetch all chunks for the day classified as `filler` or `auto-skipped` + - Send sequential batches to LLM: "Do any of these consecutive filler chunks actually form a meaningful conversation?" + - If yes → run those chunks through Stage 4 (note generation) + - Also check for fragmentation: two separate conversations that are actually the same topic → flag for merge + +--- + +### Phase 6: Feedback Loop — DO LATER + +**Goal:** Collect user feedback to improve classification over time. + +#### Step 6.1 — Add Feedback UI +- Add thumbs-up / thumbs-down buttons to auto-generated note cards in frontend +- New field on `Note` model: `feedback: 'positive' | 'negative' | null` +- New field on `Note` model: `feedbackComment: string | null` (optional text) + +#### Step 6.2 — Create Feedback Model / Extend Note Model +- Store feedback in the Note document itself (simplest approach) +- API endpoint: `PATCH /api/notes/:id/feedback` with `{ rating: 'positive' | 'negative', comment?: string }` + +#### Step 6.3 — Log Feedback +- Just persist to DB for now — data collection only +- No action taken on feedback yet (Phase 2 of feedback loop) + +--- + +## File Map (New Files) + +``` +src/backend/ +├── models/ +│ ├── transcript-chunk.model.ts (Phase 1) +│ └── conversation.model.ts (Phase 3) +├── services/ +│ └── auto-notes/ +│ ├── config.ts (Phase 2) +│ ├── domain-config.ts (Phase 2) +│ ├── TriageClassifier.ts (Phase 2) +│ ├── ConversationTracker.ts (Phase 3) +│ ├── NoteGenerator.ts (Phase 4) +│ └── SafetyPass.ts (Phase 5) +└── session/ + └── managers/ + ├── ChunkBufferManager.ts (Phase 1) + └── ConversationManager.ts (Phase 4B) + +src/frontend/ +└── pages/ + └── day/ + └── components/ + └── tabs/ + └── ConversationsTab.tsx (Phase 4B) +``` + +## Files to Modify + +``` +src/backend/session/NotesSession.ts — instantiate ChunkBufferManager + ConversationManager, wire pipeline +src/backend/models/note.model.ts — add feedback fields, auto-note type +src/backend/models/user-settings.model.ts — add domain context profile +src/shared/types.ts — add Conversation, ConversationChunk, ConversationManagerI types +src/frontend/pages/day/DayPage.tsx — add "Conversations" tab to TabType and tabs array +src/frontend/ (NoteCard, DayPage) — auto badge + feedback buttons (Phase 6) +``` + +--- + +## Edge Cases & Risks to Handle + +### 1. Sentence Boundary — Max Wait Cap +The buffer waits for a sentence to finish at the 40s mark, but a long rambling sentence could delay the chunk indefinitely. **Cap the wait at 10 extra seconds** (50s max chunk). If the sentence still isn't done, cut it — the next chunk will pick up the remainder. + +### 2. Server Crash Recovery +The `ConversationTracker` state machine lives in memory. If the server restarts mid-conversation, that state is lost. **On startup:** check DB for conversations with `status: 'active'` or `status: 'paused'` for this user. Reconstruct tracker state from the conversation document (runningSummary, chunkIds, pausedAt) and resume tracking. + +### 3. Resumption — Only Resume Paused, Not Ended +The resumption window (30 min) should only apply to `status: 'paused'` conversations. A conversation that reached `ended` (3 consecutive silent chunks / 2 minutes of silence) should stay ended. Creating a new conversation is the correct behavior in that case. + +### 4. Chunk Retention Cleanup +Configurable parameter says 24-hour retention, but no cleanup job is defined. **Add a daily cleanup task** (can run alongside the Safety Pass or as a separate scheduled job) that deletes chunks older than the retention window. Conversations and their notes persist — only raw chunks are cleaned up. + +### 5. Note Generation Failure +If the LLM call for note generation fails (timeout, rate limit, etc.): retry once after 5 seconds. If it still fails, mark the conversation with `noteGenerationFailed: true` and log it. The user sees a "Note generation failed" state on the card. Provide a manual "Retry" button in the frontend. + +--- + +## Immediate First Steps (Start Here) + +1. Create `src/backend/services/auto-notes/config.ts` with all configurable parameters +2. Create `src/backend/models/transcript-chunk.model.ts` +3. Create `src/backend/session/managers/ChunkBufferManager.ts` +4. Wire `ChunkBufferManager` into `NotesSession.ts` +5. Test that chunks are flowing and persisting every 40 seconds + +Once the buffer is solid, move to the triage classifier (Phase 2). diff --git a/issues/10-homepage-conversations-ui/PLAN.md b/issues/10-homepage-conversations-ui/PLAN.md new file mode 100644 index 0000000..c0035c5 --- /dev/null +++ b/issues/10-homepage-conversations-ui/PLAN.md @@ -0,0 +1,186 @@ +# Homepage Conversations UI Refactor + +Owner: Aryan +Priority: Medium — UI-only, no schema/backend changes + +--- + +## Overview + +Redesign the HomePage from a **day-based folder list** to a **conversation-based list** matching the new Paper designs. The core change is: instead of grouping by day and showing folder cards, we show individual conversations grouped by day sections (Today, Yesterday, date headers). No backend or schema changes — we already have `ConversationManagerI` with `Conversation[]` on the session. + +--- + +## What Already Exists (DO NOT TOUCH) + +- `ConversationManagerI` — exposes `conversations: Conversation[]` and `activeConversationId` +- `Conversation` interface — has `id`, `title`, `status`, `startTime`, `endTime`, `chunks`, `runningSummary`, `aiSummary`, `generatingSummary` +- `FileManagerI` — existing file/folder state (keep for filter logic, archive, trash) +- `TranscriptManager.isRecording` — recording status +- All backend managers, models, RPCs — untouched +- Router structure — untouched + +--- + +## Design States (from Paper) + +### State 1: Empty (no conversations) +- Header: "MENTRA NOTES" brand label (red, uppercase, tracking-widest) + "Conversations" title (30px extrabold) +- Subtitle: "No conversations yet" +- Center: chat bubble icon in rounded square + "Start a conversation" message + description text +- Bottom pill: "Microphone active · Listening" (red indicator, shown when `isRecording`) +- Top-right: overflow menu button (3 dots + collapse) +- FAB: red "+" button (bottom-right, above tab bar) +- Tab bar: Conversations (active/filled), Search, Notes, Settings + +### State 2: Populated conversations list +- Same header with "Conversations" title +- **Filter bar**: filter icon button + list/calendar view toggle (pill segmented control) +- **Filter pills**: "All" (active/dark) and "Today" (inactive/gray) +- **Day section headers**: "TODAY", "YESTERDAY", "FRI MAR 6" (uppercase, tracking-widest, muted) +- **Conversation rows** (each row): + - Left: time (hour:minute bold) + AM/PM below + - Center: conversation title (16px semibold) + metadata row (duration badge "16 min" + speaker names "You, Sarah, Mike") + - Right: chevron + - Active conversation: red time + red "Transcribing now" badge with audio bars animation + red bottom border +- FAB + top-right menu + tab bar (same as empty state) + +### State 3: Swipe-to-manage (conversation row) +- Swipe left reveals: Archive (black bg) + Delete (red bg) action buttons +- Shows icon + label for each action + +### State 4: FAB expanded (floating action menu) +- FAB becomes X (close) +- Stacked action pills above FAB: + - "Ask AI" + star icon (white bg, shadow) + - "Add manual note" + document icon (white bg, shadow) + - "Stop transcribing" + mic icon (red bg — only when recording) + +--- + +## Data Mapping + +| Design element | Source | Notes | +|---|---|---| +| Conversation title | `conversation.title` | From ConversationManagerI | +| Time (2:10 PM) | `conversation.startTime` | Format with date-fns | +| Duration (16 min) | `conversation.startTime` + `conversation.endTime` | Calculate diff, null endTime = active | +| "Transcribing now" | `conversation.status === "active"` | Red styling + audio bars | +| Speaker names | Not in schema yet | Use placeholder or omit for now | +| Day grouping | `conversation.date` | Group by YYYY-MM-DD, compare to today/yesterday | +| "All" / "Today" filter | Frontend-only state | Filter conversations by date | +| Archive/Delete swipe | `file.archiveFile()` / existing trash RPCs | Wire to existing FileManager RPCs | +| "Ask AI" action | Opens GlobalAIChat | Already exists | +| "Add manual note" | Navigate to `/day/{today}/note/new` or equivalent | Existing flow | +| "Stop transcribing" | Existing transcript stop RPC | Only shown when recording | +| Microphone active pill | `session.transcript.isRecording` | Already available | + +--- + +## Implementation Steps + +### Step 1: Create ConversationList component +**File:** `src/frontend/pages/home/components/ConversationList.tsx` + +Replace `FolderList` usage in HomePage with a new `ConversationList` that: +- Takes `conversations: Conversation[]`, `isRecording: boolean`, `onSelectConversation` +- Groups conversations by day (Today / Yesterday / formatted date) +- Renders day section headers (uppercase, tracking-widest, muted text) +- Renders conversation rows with: time | title + metadata | chevron +- Active conversation row gets red styling + "Transcribing now" badge +- Keep the existing `FolderList` file intact (don't delete it — may be needed for folder view mode) + +### Step 2: Create ConversationRow component +**File:** `src/frontend/pages/home/components/ConversationRow.tsx` + +Individual conversation row: +- Time column (fixed width): formatted hour:minute + AM/PM +- Content column (flex grow): title + metadata row (duration pill + speaker count placeholder) +- Chevron right +- Active state: red text color for time, red "Transcribing now" badge with animated audio bars +- Swipe-to-reveal: Archive + Delete action buttons (use touch events or a swipe library) + +### Step 3: Update HomePage header +**File:** `src/frontend/pages/home/HomePage.tsx` + +- Replace current header with new design: + - Brand label: "MENTRA NOTES" (red, 11px, uppercase, tracking-widest, Red Hat Display font) + - Title: "Conversations" (30px, extrabold, Red Hat Display) + - Subtitle: count text like "Today · 6 conversations" (only when populated) +- Right side: filter button + list/calendar segmented control toggle +- Add filter pills row below header: "All" (active) / "Today" +- Top-right: overflow/collapse menu (absolute positioned) + +### Step 4: Update empty state +**File:** `src/frontend/pages/home/HomePage.tsx` + +Match Paper empty design: +- Centered chat bubble icon in rounded square (64px) +- "Start a conversation" bold heading +- Description text about background listening +- "Microphone active · Listening" pill (red, shown when `isRecording`) + +### Step 5: Create FAB menu component +**File:** `src/frontend/pages/home/components/FABMenu.tsx` + +- Default: red "+" FAB button (bottom-right, 52px, rounded-2xl, red shadow) +- Expanded: X close button + stacked action pills animating in from below + - "Ask AI" — triggers GlobalAIChat + - "Add manual note" — navigates to note creation + - "Stop transcribing" — only shown when recording, stops transcript +- Backdrop overlay when expanded +- Smooth animation (motion/react) + +### Step 6: Wire up HomePage to use conversations +**File:** `src/frontend/pages/home/HomePage.tsx` + +- Read `session.conversation.conversations` instead of (or alongside) `session.file.files` +- Pass conversations to ConversationList +- Handle conversation selection → navigate to conversation detail or day page +- Keep existing filter/view logic working alongside new conversation view + +### Step 7: Add tab bar +**File:** `src/frontend/components/shared/TabBar.tsx` (or in layout) + +- Fixed bottom bar: Conversations, Search, Notes, Settings +- Active state: filled icon + semibold label (dark) +- Inactive: outlined icon + medium label (muted) +- This may already exist or be part of the router layout — check before creating + +--- + +## Design Tokens (from Paper) + +``` +Font: Red Hat Display, system-ui, sans-serif +Background: #FAFAF9 (warm off-white) +Text primary: #1C1917 +Text muted: #A8A29E +Text secondary: #78716C +Accent red: #DC2626 +Accent red light: #FEE2E2 +Active bg dark: #1C1917 +Surface: #F5F5F4 +Border: #E7E5E4 +Border active: #FEE2E2 (red tint for active conversation) + +Brand label: 11px, uppercase, tracking-widest, bold, red +Page title: 30px, -0.03em tracking, extrabold +Section header: 11px, uppercase, tracking-widest, bold, muted +Conversation title: 16px, semibold +Time: 14px, semibold +Duration badge: 11px, medium, in rounded pill with #F5F5F4 bg +Tab label: 10px, active=semibold, inactive=medium +``` + +--- + +## What NOT to Do + +- Do NOT change any backend code, schemas, or models +- Do NOT change the router structure +- Do NOT remove existing components — add new ones alongside +- Do NOT implement conversation detection logic (that's issue #1) +- Do NOT implement speaker identification (not in schema yet) +- Do NOT add new RPCs or manager methods +- Do NOT change the sync/websocket layer diff --git a/issues/11-interim-word-threshold/PLAN.md b/issues/11-interim-word-threshold/PLAN.md new file mode 100644 index 0000000..70c6e29 --- /dev/null +++ b/issues/11-interim-word-threshold/PLAN.md @@ -0,0 +1,146 @@ +# Interim Word Threshold — Force-Finalize Long Interim Segments + +Owner: Aryan +Priority: Medium — UX reliability for continuous speech +Related: `issues/2-interim-fallback` (timeout-based, different trigger) + +--- + +## Problem + +When a user speaks continuously without pausing, the speech recognition engine may not emit an `isFinal` segment for a long time. The interim text keeps growing unboundedly — a single interim can become hundreds of words. This causes: + +1. **UI issue**: The interim text blob on the frontend keeps growing, making it hard to read +2. **Data loss risk**: If the connection drops mid-interim, all that text is lost (only final segments are persisted) +3. **Chunk pipeline starvation**: The auto-notes chunk buffer only receives final segments, so a 2-minute monologue produces zero chunks + +--- + +## Solution: Word Count Threshold on Interim Text + +When interim text exceeds a word count threshold (50 words), force-finalize it: + +1. Take the current interim text and emit it as a final segment +2. Reset the interim buffer +3. The next interim from the speech engine will contain **all** accumulated text (old + new), but we only keep the **new portion** (everything after what we already finalized) + +This creates a stream of reasonably-sized final segments even during continuous speech. + +--- + +## Key Design Decision: Cumulative Word Count Stripping + +The speech recognition engine doesn't know we force-finalized. It will keep sending full cumulative interim text that includes words we already finalized. We strip the prefix using a **cumulative word count** — we track how many words have been force-finalized so far, and skip that many words from each new interim. + +This is simpler and more reliable than text comparison, since the speech engine may revise earlier words. + +--- + +## Files to Modify + +``` +src/backend/session/managers/TranscriptManager.ts — add threshold logic in addSegment() +``` + +--- + +## Implementation Steps + +### Step 1 — Add Threshold Config + +In `TranscriptManager.ts`, add a constant: + +```ts +const INTERIM_WORD_THRESHOLD = 50; // Force-finalize after this many words +``` + +### Step 2 — Track Force-Finalized Word Count + +Add instance variables to `TranscriptManager`: + +```ts +private _forceFinalizedWordCount: number = 0; // Cumulative words force-finalized +``` + +### Step 3 — Modify `addSegment()` Interim Handling + +Current logic (simplified): +```ts +if (!isFinal) { + this.interimText = text; + return; +} +``` + +New logic: +```ts +if (!isFinal) { + // Strip already-finalized words from the interim using word count + const interimWords = text.trim().split(/\s+/); + const cleanWords = interimWords.slice(this._forceFinalizedWordCount); + const cleanInterim = cleanWords.join(" "); + + if (cleanWords.length >= INTERIM_WORD_THRESHOLD) { + // Force-finalize: treat this interim as a final segment + console.log(`[TranscriptManager] Force-finalizing interim (${cleanWords.length} words)`); + this._forceFinalizedWordCount = interimWords.length; // Track total words finalized + // Fall through to final segment handling with cleanInterim as the text + // ... (create segment with isFinal: true, cleanInterim as text) + } else { + this.interimText = cleanInterim; + return; + } +} +``` + +### Step 4 — Reset on Final Segment + +When a real `isFinal` segment arrives from the speech engine, reset the tracking: + +```ts +if (isFinal) { + this._forceFinalizedWordCount = 0; // Reset — speech engine finalized naturally + // ... existing final handling +} +``` + +### Step 5 — Reset on Recording Stop + +In `stopRecording()` or equivalent, reset: +```ts +this._forceFinalizedWordCount = 0; +``` + +--- + +## Edge Cases + +### 1. User pauses briefly then continues +The speech engine may emit a final for the first part, then start fresh interims for the continuation. `_forceFinalizedWordCount` gets reset on real finals, so this works naturally. + +### 2. Force-finalized segment overlaps with a late real final +If the speech engine finally emits a real `isFinal` that covers the same text we already force-finalized, we'd get duplicates. + +**Mitigation**: When a real final arrives and `_forceFinalizedWordCount > 0`, check if its text substantially overlaps with what we already force-finalized. If >70% word overlap, skip it and just reset the counter. + +### 3. Connection drops mid-interim +With the threshold in place, we force-finalize every ~50 words, so at most ~50 words of speech would be lost on disconnect instead of potentially minutes of speech. + +--- + +## Testing Checklist + +- [ ] Normal speech with natural pauses: verify force-finalize never triggers (pauses produce real finals before 50 words) +- [ ] Continuous monologue (no pauses): verify segments are force-finalized every ~50 words +- [ ] Verify stripped text doesn't include duplicates from previous force-finalized segments +- [ ] Verify real finals after force-finalized segments don't create duplicates +- [ ] Verify chunk buffer receives the force-finalized segments correctly +- [ ] Verify UI shows clean interim text (not growing unboundedly) + +--- + +## Config Summary + +| Parameter | Value | Purpose | +|-----------|-------|---------| +| `INTERIM_WORD_THRESHOLD` | 50 words | Force-finalize interim text after this many words | diff --git a/issues/12-swipe-to-action-fix/PLAN.md b/issues/12-swipe-to-action-fix/PLAN.md new file mode 100644 index 0000000..c824379 --- /dev/null +++ b/issues/12-swipe-to-action-fix/PLAN.md @@ -0,0 +1,120 @@ +# Issue 12: Swipe-to-Action — Fix Janky Gesture Handling + +## Problem + +The swipe-to-reveal-actions gesture on ConversationRow and NoteRow is broken on mobile: + +1. **Fast swipes cause jittering** — Quick left/right flicks make the row jump erratically between open/closed states +2. **Hold-and-release snaps back** — Dragging left past the threshold and releasing causes the row to snap back to closed instead of staying open +3. **Conflicts with vertical scroll** — `dragDirectionLock` isn't enough; small diagonal gestures still trigger horizontal drag +4. **Action buttons bleed through** — NoteRow wrapper is missing `overflow-hidden`, so delete button shows through at rest +5. **`dragSnapToOrigin` fights `animate`** — Both try to control the `x` position, causing the row to rubber-band back even when `isSwiped` is true + +## Root Cause + +The current approach uses Framer Motion's `drag="x"` with `dragSnapToOrigin`, `dragDirectionLock`, and `animate` all competing to control position. This creates conflicts: + +- `dragSnapToOrigin` always wants to return to 0 +- `animate` wants to go to `-146` when swiped +- These fight each other, causing the jitter on fast gestures +- `dragDirectionLock` doesn't have a dead zone, so tiny movements trigger drag + +## Solution: Drop Framer Motion drag, use native touch events + +Replace the Framer Motion drag system with manual `onTouchStart`/`onTouchMove`/`onTouchEnd` handlers. This gives full control over the gesture lifecycle with no conflicting animation systems. + +### Architecture + +Create a shared `useSwipeToReveal` hook used by both ConversationRow and NoteRow. + +``` +src/frontend/hooks/useSwipeToReveal.ts ← new shared hook +``` + +### Hook API + +```ts +const { containerRef, rowStyle, handlers } = useSwipeToReveal({ + openDistance: 146, // how far to slide open (button widths) + threshold: 0.3, // 30% of openDistance to trigger + deadZone: 10, // px of movement before deciding axis + onOpen?: () => void, + onClose?: () => void, +}); +``` + +### Implementation Steps + +#### Step 1: Create `useSwipeToReveal` hook + +The hook manages three phases of touch: + +**Phase 1 — Dead zone (first 10px of movement)** +- Track `touchStart` x/y position +- Do NOT move the row yet +- Once total movement exceeds `deadZone` (10px), decide axis: + - If `|deltaY| > |deltaX|` → vertical scroll, abort horizontal gesture entirely + - If `|deltaX| > |deltaY|` → lock to horizontal, prevent scroll with `e.preventDefault()` + +**Phase 2 — Tracking (finger is moving horizontally)** +- Update `translateX` via `useMotionValue` (for action button opacity transforms) +- Clamp between `-openDistance` and `0` (no overscroll, no rightward drag) +- No animation during this phase — raw 1:1 finger tracking + +**Phase 3 — Release (touchend)** +- If `|totalDeltaX| > openDistance * threshold` → animate to `-openDistance` (open) +- Else → animate to `0` (closed) +- Use `animate(x, target, { type: "tween", duration: 0.2, ease: "easeOut" })` from Framer Motion's imperative API +- Start auto-close timer if opened + +**States:** +- `idle` → not being touched, position is either 0 or -openDistance +- `tracking` → finger is down and we've locked to horizontal +- `settling` → finger released, animating to final position + +#### Step 2: Update ConversationRow + +- Remove: `drag`, `dragDirectionLock`, `dragSnapToOrigin`, `dragConstraints`, `dragElastic`, `onDragStart`, `onDragEnd` +- Keep: `motion.div` with `style={{ x }}` and action button opacity transforms +- Add: `onTouchStart`, `onTouchMove`, `onTouchEnd` from the hook +- Keep: `onTap` → replaced with `onClick` that checks `isDragging` +- Keep: `overflow-hidden` on wrapper + +#### Step 3: Update NoteRow + +- Same changes as ConversationRow +- Add `overflow-hidden` back to the wrapper div (currently missing) + +#### Step 4: Handle edge cases + +| Edge case | How to handle | +|---|---| +| Tap (no movement) | Dead zone never exceeded → fire `onSelect` | +| Fast flick | Use velocity check: if `velocity.x < -300` and direction is left, treat as open regardless of distance | +| Swipe right to close | If already open, allow rightward drag; if `deltaX > threshold` from open position → close | +| Multiple rows open | Optional: emit event so parent closes other open rows (not required for MVP) | +| Scroll while swiped open | Auto-close on scroll (attach scroll listener to parent) | + +### Files to Modify + +| File | Change | +|---|---| +| `src/frontend/hooks/useSwipeToReveal.ts` | **New** — shared hook | +| `src/frontend/pages/home/components/ConversationRow.tsx` | Replace drag props with hook | +| `src/frontend/pages/notes/NoteRow.tsx` | Replace drag props with hook, add `overflow-hidden` | + +### Testing Checklist + +- [ ] Slow drag left past 30% → opens smoothly, stays open +- [ ] Slow drag left under 30% → snaps back smoothly +- [ ] Fast flick left → opens +- [ ] Fast flick right on open row → closes +- [ ] Tap on closed row → navigates (no swipe triggered) +- [ ] Tap on open row → closes row +- [ ] Vertical scroll through list → no horizontal movement on any row +- [ ] Diagonal gesture → no jitter, picks one axis +- [ ] Action buttons not visible at rest +- [ ] Archive button works when revealed +- [ ] Delete button works when revealed +- [ ] Auto-close after 6 seconds +- [ ] No bounce/jitter on any gesture diff --git a/issues/13-folders-system/PLAN.md b/issues/13-folders-system/PLAN.md new file mode 100644 index 0000000..b9474ef --- /dev/null +++ b/issues/13-folders-system/PLAN.md @@ -0,0 +1,204 @@ +Go for it.# Issue 13: Folders System — Notes Organization + +## Overview + +Add a folder system to organize notes. Users can create folders with a name and color, and assign any note to exactly one folder. Every new user gets two default folders: "Personal" and "Work". Folders are manageable (rename, delete, change color). Notes are assigned to folders via a dropdown selector on the note editor page. + +## Data Model + +### Folder Schema (`folder.model.ts`) + +```ts +{ + id: string; // unique ID + userId: string; // owner + name: string; // e.g. "Work Notes" + color: "red" | "gray" | "blue"; // one of 3 options + createdAt: Date; + updatedAt: Date; +} +``` + +**MongoDB collection:** `folders` + +**Color hex mapping:** +- `red` → `#DC2626` +- `gray` → `#78716C` +- `blue` → `#2563EB` + +### Note Schema Change + +Add optional `folderId` field to existing `Note` interface: + +```ts +// in shared/types.ts — Note interface +folderId?: string; // ID of folder this note belongs to (null = no folder) +``` + +Also add to the Mongoose note model schema. + +## Default Folders + +On first session creation (or when user has 0 folders), auto-create: + +| Name | Color | +|------|-------| +| Personal | blue | +| Work | red | + +**Where:** In `NotesManager` or a new `FoldersManager` initialization, triggered during session setup. Check `folders.countDocuments({ userId })` — if 0, seed defaults. + +## Backend Implementation + +### New Files + +| File | Purpose | +|------|---------| +| `src/backend/models/folder.model.ts` | Mongoose schema + CRUD functions: `createFolder`, `getFolders`, `updateFolder`, `deleteFolder` | +| `src/backend/session/managers/FoldersManager.ts` | Synced manager exposing folders state + methods to frontend | + +### Model Functions (`folder.model.ts`) + +```ts +createFolder(userId: string, name: string, color: FolderColor): Promise +getFolders(userId: string): Promise +updateFolder(userId: string, folderId: string, updates: { name?: string; color?: FolderColor }): Promise +deleteFolder(userId: string, folderId: string): Promise +// When a folder is deleted, unset folderId on all notes that reference it +``` + +### FoldersManager (synced manager) + +Exposes to frontend via sync system: +- **State:** `folders: Folder[]` +- **Methods:** `createFolder(name, color)`, `updateFolder(folderId, updates)`, `deleteFolder(folderId)` + +Register in session setup alongside existing managers (NotesManager, ConversationManager, etc.) + +### Note Model Changes + +- Add `folderId` to note Mongoose schema (optional String, default null) +- Add/update model function: `updateNote` already exists — ensure it can set `folderId` +- When a folder is deleted, run: `Note.updateMany({ userId, folderId }, { $unset: { folderId: 1 } })` + +### Shared Types (`shared/types.ts`) + +```ts +type FolderColor = "red" | "gray" | "blue"; + +interface Folder { + id: string; + name: string; + color: FolderColor; + createdAt: Date; + updatedAt: Date; +} + +// Add to SessionI or create FoldersSyncedI: +interface FoldersSyncedI { + folders: Folder[]; + createFolder(name: string, color: FolderColor): Promise; + updateFolder(folderId: string, updates: { name?: string; color?: FolderColor }): Promise; + deleteFolder(folderId: string): Promise; +} +``` + +Add `folderId?: string` to existing `Note` interface. + +## Frontend Implementation + +### 1. Note Editor — Folder Dropdown (`/note/:id`) + +Add a folder selector bar at the bottom of the note editor (above keyboard area or below content). + +**Collapsed state (from Paper design):** +``` +┌─────────────────────────────────┐ +│ 📁 No folder ▼ │ +└─────────────────────────────────┘ +``` +- Rounded pill, `bg-[#F5F5F4]`, folder icon + label + chevron down +- Label shows current folder name or "No folder" + +**Expanded state (dropdown):** +- List of all user folders, each showing: color dot + folder name +- "No folder" option at top to unassign +- Tapping a folder calls `session.notes.updateNote(noteId, { folderId })` and closes dropdown + +**File:** Add `FolderPicker` component at `src/frontend/pages/note/FolderPicker.tsx` + +### 2. CollectionsPage Updates + +Replace placeholder `PLACEHOLDER_FOLDERS` with real data from `session.folders.folders`. + +- Folder cards show real name, color bar, and note count +- Note count: derive from `notes.filter(n => n.folderId === folder.id).length` +- "New Folder" button → opens a create folder modal/sheet + +### 3. Create Folder Flow + +Simple inline creation (modal or bottom sheet): +- Text input for name +- 3 color swatches to pick from (red, gray, blue) +- "Create" button +- Calls `session.folders.createFolder(name, color)` + +**File:** `src/frontend/pages/notes/CreateFolderSheet.tsx` + +### 4. Folder Detail Page (`/folder/:id`) + +Reuses the NotesPage layout but filtered: +- Header: folder icon (colored) + folder name as title +- Subtitle: "X notes" +- List: only notes where `note.folderId === folderId` +- Same NoteRow components with swipe-to-delete/archive +- Edit button in header → rename/change color/delete folder + +**File:** `src/frontend/pages/notes/FolderPage.tsx` +**Route:** `/folder/:id` + +### 5. Folder Management (Edit/Delete) + +On FolderPage header, an edit/settings button opens options: +- Rename folder (text input) +- Change color (3 swatches) +- Delete folder (confirmation prompt — notes are unassigned, not deleted) + +## File Changes Summary + +### New Files + +| File | Description | +|------|-------------| +| `src/backend/models/folder.model.ts` | Mongoose model + CRUD | +| `src/backend/session/managers/FoldersManager.ts` | Synced manager | +| `src/frontend/pages/note/FolderPicker.tsx` | Dropdown on note editor | +| `src/frontend/pages/notes/CreateFolderSheet.tsx` | Create folder modal | +| `src/frontend/pages/notes/FolderPage.tsx` | Folder detail view | + +### Modified Files + +| File | Change | +|------|--------| +| `src/shared/types.ts` | Add `Folder`, `FolderColor`, `FoldersSyncedI`, add `folderId?` to `Note` | +| `src/backend/models/index.ts` | Export folder model functions | +| `src/backend/models/note.model.ts` | Add `folderId` to schema | +| `src/backend/session/managers/NotesManager.ts` | Ensure `updateNote` handles `folderId` | +| `src/backend/session/Session.ts` (or equivalent) | Register FoldersManager | +| `src/frontend/router.tsx` | Add `/folder/:id` route | +| `src/frontend/pages/notes/CollectionsPage.tsx` | Replace placeholders with real folder data | +| `src/frontend/pages/notes/NotesFABMenu.tsx` | Wire "Create folder" to open CreateFolderSheet | +| `src/frontend/pages/note/NotePage.tsx` | Add FolderPicker component | + +## Implementation Order + +1. **Backend model** — `folder.model.ts` with Mongoose schema + CRUD +2. **Shared types** — Add `Folder`, `FolderColor` types, `folderId` to `Note` +3. **Note model update** — Add `folderId` field to note schema +4. **FoldersManager** — Synced manager with default folder seeding +5. **Register manager** — Wire into session setup +6. **FolderPicker** — Dropdown component on NotePage +7. **CollectionsPage** — Replace placeholders with real data +8. **CreateFolderSheet** — Create folder UI +9. **FolderPage** — Folder detail view with filtered notes +10. **Folder management** — Rename, recolor, delete actions diff --git a/issues/14-notes-filter-system/PLAN.md b/issues/14-notes-filter-system/PLAN.md new file mode 100644 index 0000000..19a22fc --- /dev/null +++ b/issues/14-notes-filter-system/PLAN.md @@ -0,0 +1,235 @@ +# Issue 14: Notes Filter System — Trash, Archive, Favourites, Sort, AI/Manual + +## Overview + +Add the same filter/sort system from conversations to notes. Users can favourite, archive, and trash individual notes. Filter drawer with sort and show filters. Active filters show as tags in the pill bar. Empty Trash permanently deletes notes. Swipe-to-delete only available in trash view. + +## Note Schema Changes + +### Rename `isStarred` → `isFavourite` + +The Note model has `isStarred` but conversations/files use `isFavourite`. Rename for consistency. Add fallback: if `isStarred` is true on existing docs, treat as `isFavourite: true`. + +### Add new fields + +```ts +isFavourite: boolean; // default false (replaces isStarred, with migration fallback) +isArchived: boolean; // default false +isTrashed: boolean; // default false +``` + +### Fix `updateNote()` data parameter + +Current `updateNote()` only accepts `title`, `content`, `summary`, `isStarred`, `folderId`. Must expand to include all new fields: + +```ts +data: Partial<{ + title: string; + content: string; + summary: string; + isFavourite: boolean; + isArchived: boolean; + isTrashed: boolean; + folderId: string | null; +}> +``` + +### Fix `persistNoteUpdate()` in NotesManager + +Currently cherry-picks only `title`, `summary`, `content`, `folderId`. Must also pass through `isFavourite`, `isArchived`, `isTrashed`. + +### Files to modify: +- `src/backend/models/note.model.ts` — Rename `isStarred` → `isFavourite`, add `isArchived`, `isTrashed`, expand `updateNote()` type +- `src/shared/types.ts` — Add fields to `Note` interface +- `src/backend/session/managers/NotesManager.ts` — Update `NoteData`, hydrate mapping (with `isStarred` fallback), `persistNoteUpdate` + +### Hydrate fallback for `isStarred` → `isFavourite` + +```ts +isFavourite: n.isFavourite ?? n.isStarred ?? false, +``` + +This handles old docs that have `isStarred: true` but no `isFavourite` field. + +## New RPCs on NotesManager + +```ts +favouriteNote(noteId: string): Promise; // sets isFavourite=true, clears archived+trashed +unfavouriteNote(noteId: string): Promise; +archiveNote(noteId: string): Promise; // sets isArchived=true, clears favourite+trashed +unarchiveNote(noteId: string): Promise; +trashNote(noteId: string): Promise; // sets isTrashed=true, clears favourite+archived +untrashNote(noteId: string): Promise; +permanentlyDeleteNote(noteId: string): Promise; // hard delete from MongoDB +emptyNoteTrash(): Promise; // permanently deletes all trashed notes +``` + +States are mutually exclusive (same as conversations). + +**Important:** The existing `deleteNote()` RPC remains as a hard delete but is only called from: +- `permanentlyDeleteNote()` (new) +- `emptyNoteTrash()` (new) +- Swipe-to-delete in trash view only + +Normal "delete" actions (3-dots menu, swipe on non-trashed notes) call `trashNote()` instead. + +### Files to modify: +- `src/backend/session/managers/NotesManager.ts` — Add RPCs +- `src/shared/types.ts` — Add to `NotesManagerI` + +## Archive Refactor: Note-Level Only + +**Current problem:** `handleArchiveNote` calls `session.file.archiveFile(note.date)` which archives the entire day, not the individual note. + +**Fix:** Archive is now note-level. Remove file-level archive calls from notes. Use `session.notes.archiveNote(noteId)` instead of `session.file.archiveFile(date)`. + +### Files to modify: +- `src/frontend/pages/notes/NotesPage.tsx` — Change `handleArchiveNote` to call note-level RPC +- `src/frontend/pages/notes/FolderPage.tsx` — Same change + +## Frontend: NotesFilterDrawer + +Create `src/frontend/components/shared/NotesFilterDrawer.tsx` — same pattern as `ConversationFilterDrawer`. + +### Sort options: +- Most recent (default) +- Oldest first + +### Show filters: +- All notes (hides archived + trashed) +- Favourites +- Archived +- Trash + +### No date range filter. + +## Frontend: NotesPage Changes + +### Filter state +```ts +const [sortBy, setSortBy] = useState("recent"); +const [showFilter, setShowFilter] = useState("all"); +const [filterLoading, setFilterLoading] = useState(false); +``` + +### Filter logic in `filteredNotes` useMemo + +Two-level filtering: show filter + pill filter (AI/Manual) stack together. + +```ts +let result = [...notes]; + +// Show filter (mutually exclusive states) +if (showFilter === "favourites") result = result.filter(n => n.isFavourite); +else if (showFilter === "archived") result = result.filter(n => n.isArchived); +else if (showFilter === "trash") result = result.filter(n => n.isTrashed); +else result = result.filter(n => !n.isTrashed && !n.isArchived); // "all" + +// Pill filters (AI/Manual) — applied on top +if (activeFilter === "manual") result = result.filter(n => !n.isAIGenerated); +else if (activeFilter === "ai") result = result.filter(n => n.isAIGenerated); + +// Sort +if (sortBy === "oldest") result.sort(byOldest); +else result.sort(byNewest); +``` + +### Filter pill bar + +Current pills: All | Favorites | Manual | AI Generated + +Changes: +- Rename "AI Generated" → "AI" +- "Favorites" pill sets `showFilter = "favourites"` (no longer a broken TODO) +- Add show filter tag (black pill) for Favourites/Archived/Trash when active from drawer +- When show filter is active from drawer, gray out All pill +- Tapping "All" pill resets show filter to "all" +- Horizontal scroll (`overflow-x-auto`) if pills overflow +- No date range tags + +### Trash view + +When `showFilter === "trash"`: +- Show "X notes in trash" + "Empty Trash" button above list +- Swipe-to-delete on trashed notes calls `permanentlyDeleteNote()` (hard delete) +- "Empty Trash" opens `BottomDrawer` confirmation: "Your notes will be permanently deleted. Are you sure?" with Cancel / Delete All + +### Swipe actions by context + +| Note state | Left swipe reveals | +|---|---| +| Normal (not trashed/archived) | Archive + Trash | +| Trashed | Restore + Delete (permanent) | +| Archived | shown via archive filter only, swipe reveals Unarchive + Trash | + +### Loading animation + +Same `LoadingState` spinner for 3 seconds when switching show filters via drawer. + +### Empty state + +Same dot art "Nothing found" illustration with contextual messages: +- Trash: "Your trash is empty" +- Archived: "No archived notes" +- Favourites: "No favourite notes yet" +- Default: "Try adjusting your filters" + +## Frontend: NotePage 3-Dots Menu + +Current menu: Send Email | Delete Note + +Replace with: +- Send Email +- Divider +- Favourite / Unfavourite (moved star button here) +- Archive / Unarchive +- Divider +- Trash / Untrash + +Remove the standalone star button from header (move to menu). + +Trash navigates back to `/notes`. States are mutually exclusive. + +### Files to modify: +- `src/frontend/pages/note/NotePage.tsx` — Update dropdown menu, remove star button + +## Frontend: FolderPage + +Filter out trashed notes from folder view: +```ts +const folderNotes = notes.filter(n => n.folderId === folderId && !n.isTrashed); +``` + +### Files to modify: +- `src/frontend/pages/notes/FolderPage.tsx` — Filter trashed notes + +## Files Summary + +### New files: +| File | Description | +|------|-------------| +| `src/frontend/components/shared/NotesFilterDrawer.tsx` | Filter drawer for notes | + +### Modified files: +| File | Change | +|------|--------| +| `src/backend/models/note.model.ts` | Rename `isStarred` → `isFavourite`, add `isArchived`/`isTrashed`, expand `updateNote` type | +| `src/shared/types.ts` | Add fields to `Note`, add RPCs to `NotesManagerI` | +| `src/backend/session/managers/NotesManager.ts` | Update `NoteData`, hydrate (with fallback), `persistNoteUpdate`, add RPCs | +| `src/frontend/pages/notes/NotesPage.tsx` | Filter state, filter logic, pill bar, trash UI, loading, empty state | +| `src/frontend/pages/note/NotePage.tsx` | Update 3-dots menu, remove star button | +| `src/frontend/pages/notes/NoteRow.tsx` | Context-aware swipe actions | +| `src/frontend/pages/notes/FolderPage.tsx` | Filter out trashed notes | + +## Implementation Order + +1. **Schema** — Rename `isStarred` → `isFavourite`, add `isArchived`/`isTrashed` to model + types +2. **Fix updateNote + persistNoteUpdate** — Expand accepted fields +3. **Backend RPCs** — Add favourite/archive/trash/permanentlyDelete/emptyTrash RPCs +4. **Hydrate fallback** — Map new fields with `isStarred` fallback +5. **NotesFilterDrawer** — Create the filter drawer component +6. **NotesPage** — Filter state, logic, pill bar tags, trash view, loading, empty state +7. **NotePage menu** — Update 3-dots with favourite/archive/trash, remove star button +8. **NoteRow swipe** — Context-aware swipe actions +9. **FolderPage** — Filter out trashed notes +10. **Archive refactor** — Change note archive from file-level to note-level diff --git a/issues/15-multi-select-share/PLAN.md b/issues/15-multi-select-share/PLAN.md new file mode 100644 index 0000000..a861c59 --- /dev/null +++ b/issues/15-multi-select-share/PLAN.md @@ -0,0 +1,442 @@ +# Multi-Select & Share/Export Feature + +## Overview + +Add long-press multi-select to **Notes**, **Conversations**, and **Transcripts** lists. Selected items can be batch exported (clipboard, text file, native share), moved to folders (notes only), favorited, or deleted. Tapping "Export" opens a bottom drawer with content toggles and export destination options. + +**Edge case:** Active/paused conversations (`status !== "ended"`) cannot be selected — they're still recording. + +--- + +## Screens + +### A. Multi-Select Mode (Notes) +- Long-press any note row → enters selection mode +- Header becomes: `Cancel` | `{n} selected` | `Select All` +- Each row shows animated checkbox (left side), swipe gestures disabled +- Bottom action bar: **Export** | **Move** | **Favorite** | **Delete** + +### B. Multi-Select Mode (Conversations) +- Same long-press trigger, same header +- **Cannot select** conversations where `status === "active" || status === "paused"` +- Bottom action bar: **Export** | **Favorite** | **Delete** (no Move) + +### C. Multi-Select Mode (Transcripts) +- Long-press any transcript date row → enters selection mode +- Same header pattern +- Bottom action bar: **Export** | **Delete** +- No favorite or move (transcripts are date-based files, not individual items) + +### D. Export Drawer +- Title: "Export Note" (single) / "Export {n} Notes" (batch) +- Subtitle: note title (single) or note count summary +- **Included in Export** section: + - Toggle: "Note Content" — summary, decisions, action items (default ON) + - Toggle: "Linked Transcript" — full conversation with speaker labels (default OFF) +- **Export To** section: + - Clipboard | Text File | Share (native share sheet) +- "Export Note" button at bottom + +For conversations: same drawer but toggles are "Conversation Summary" + "Full Transcript" +For transcripts: single toggle "Transcript Content" (always on), no linked content + +--- + +## Implementation Tasks + +### Task 1: Create `useMultiSelect` hook + +**New file:** `src/frontend/hooks/useMultiSelect.ts` + +```ts +import { useState, useCallback, useRef } from "react"; + +interface UseMultiSelectReturn { + /** Whether selection mode is active */ + isSelecting: boolean; + /** Set of selected item IDs */ + selectedIds: Set; + /** Number of selected items */ + count: number; + /** Enter selection mode and select the first item */ + startSelecting: (id: T) => void; + /** Toggle an item's selection state */ + toggleItem: (id: T) => void; + /** Select all items from a given list */ + selectAll: (allIds: T[]) => void; + /** Exit selection mode and clear selection */ + cancel: () => void; + /** Long-press handler factory — returns onTouchStart/onTouchEnd props */ + longPressProps: (id: T, disabled?: boolean) => { + onTouchStart: () => void; + onTouchEnd: () => void; + onTouchMove: () => void; + }; +} +``` + +**Behavior:** +- `longPressProps(id, disabled?)` returns touch handlers that trigger `startSelecting(id)` after 500ms hold +- Moving finger cancels the long-press (prevents conflict with scroll/swipe) +- `disabled` param prevents selection (for active conversations) +- Once in selection mode, tapping a row calls `toggleItem(id)` instead of navigating +- If `selectedIds` becomes empty after a toggle, auto-exit selection mode +- `selectAll(ids)` selects everything in the filtered list +- `cancel()` clears everything and exits selection mode + +**Implementation detail:** Use `useRef` for the timeout ID to avoid re-renders during the hold period. Clear timeout on `touchMove` and `touchEnd`. + +--- + +### Task 2: Create `MultiSelectBar` component + +**New file:** `src/frontend/components/shared/MultiSelectBar.tsx` + +A fixed bottom bar that replaces the tab bar during selection mode. + +```ts +interface MultiSelectBarProps { + actions: Array<{ + icon: ReactNode; + label: string; + onClick: () => void; + variant?: "default" | "danger"; + }>; +} +``` + +**Layout:** +- Fixed to bottom, same height as tab bar (72px + safe area) +- White background with top border `#F5F5F4` +- Actions evenly spaced as icon + label columns +- "Delete" action uses `#DC2626` (red) for icon and label +- Slides up with `motion` animation when entering selection mode + +**Actions by context:** + +| Context | Export | Move | Favorite | Delete | +|---------|--------|------|----------|--------| +| Notes | yes | yes | yes | yes | +| Conversations | yes | no | yes | yes | +| Transcripts | yes | no | no | yes | + +--- + +### Task 3: Create `ExportDrawer` component + +**New file:** `src/frontend/components/shared/ExportDrawer.tsx` + +Uses existing `BottomDrawer` (vaul) as the base. + +```ts +interface ExportDrawerProps { + isOpen: boolean; + onClose: () => void; + /** "note" | "conversation" | "transcript" */ + itemType: "note" | "conversation" | "transcript"; + /** Title of the single item, or count for batch */ + itemLabel: string; + /** Number of items being exported */ + count: number; + /** Callback when export is triggered */ + onExport: (options: ExportOptions) => Promise; +} + +interface ExportOptions { + includeContent: boolean; + includeTranscript: boolean; + destination: "clipboard" | "textFile" | "share"; +} +``` + +**UI Structure:** +1. Drag handle bar +2. Title row: "Export Note" / "Export 3 Notes" + X close button +3. Subtitle: item title or "{n} notes selected" +4. Section label: "INCLUDED IN EXPORT" (uppercase, 12px, `#A8A29E`) +5. Toggle row: "Note Content" / subtitle / toggle switch (blue when on) +6. Divider +7. Toggle row: "Linked Transcript" / subtitle / toggle switch +8. Section label: "EXPORT TO" +9. Three option cards in a row: Clipboard (default selected, black border) | Text File | Share +10. Full-width "Export Note" button (`#1C1917` bg, white text, rounded-xl) + +**Toggle switch:** Custom component — 48x28px pill, blue (#3B82F6) when on, gray (#E7E5E4) when off, white circle knob. + +**Export To cards:** 88x88px, rounded-xl border, icon + label. Selected state has 2px black border. + +--- + +### Task 4: Create `SelectionHeader` component + +**New file:** `src/frontend/components/shared/SelectionHeader.tsx` + +Replaces the page header when in selection mode. + +```ts +interface SelectionHeaderProps { + count: number; + onCancel: () => void; + onSelectAll: () => void; +} +``` + +**Layout:** +- `Cancel` (red, left) | `{n} selected` (center, bold) | `Select All` (right) +- Same horizontal padding as existing headers (24px) +- Font: Red Hat Display, 15px, weight 500 (Cancel/Select All), 600 (count) +- Animated swap with the normal header using `AnimatePresence` + +--- + +### Task 5: Modify `NoteRow.tsx` for multi-select + +**File:** `src/frontend/pages/notes/NoteRow.tsx` + +**Changes:** +1. Add props: `isSelecting: boolean`, `isSelected: boolean`, `onLongPress: () => void`, `onToggleSelect: () => void` +2. When `isSelecting`: + - Show animated checkbox on the left (slide in from left, 40px width) + - Checkbox: 24x24 rounded-full, red fill (#DC2626) + white checkmark when selected, gray border (#D6D3D1) when unselected + - Disable swipe gestures (don't call `useSwipeToReveal` handlers) + - Tapping the row calls `onToggleSelect()` instead of `onSelect(note)` + - Row background: light red tint (`#FEF2F2`) when selected +3. When not `isSelecting`: + - Normal behavior (navigate on tap, swipe for actions) + - Attach long-press handlers from `useMultiSelect.longPressProps` + +**Animation:** Checkbox slides in with `motion.div` — `initial={{ width: 0, opacity: 0 }}`, `animate={{ width: 40, opacity: 1 }}`. Use `layout` prop on the content area for smooth shift. + +--- + +### Task 6: Modify `ConversationRow.tsx` for multi-select + +**File:** `src/frontend/pages/home/components/ConversationRow.tsx` + +**Same pattern as NoteRow** with one key difference: + +- Add `canSelect` prop derived from conversation status: + ```ts + const canSelect = conversation.status === "ended"; + ``` +- If `isSelecting && !canSelect`: row appears dimmed (opacity 0.4), checkbox hidden, not tappable +- Long-press on active/paused conversations does nothing (disabled in `longPressProps`) + +--- + +### Task 7: Modify `TranscriptList.tsx` for multi-select + +**File:** `src/frontend/pages/home/components/TranscriptList.tsx` + +**Changes:** +1. Add props: `isSelecting`, `selectedDates: Set`, `onLongPress: (date: string) => void`, `onToggleSelect: (date: string) => void` +2. When `isSelecting`: + - Show checkbox on left of each transcript row + - Tapping toggles selection instead of navigating + - Active/live transcript rows (today + recording) cannot be selected (dimmed) +3. When not selecting: normal behavior with long-press handlers attached + +--- + +### Task 8: Wire up `NotesPage.tsx` + +**File:** `src/frontend/pages/notes/NotesPage.tsx` + +**Changes:** +1. Import and use `useMultiSelect` hook +2. Conditionally render `SelectionHeader` vs normal header based on `isSelecting` +3. Pass selection props down to each `NoteRow` +4. Conditionally render `MultiSelectBar` vs bottom tab bar +5. Hide FAB menu during selection mode +6. Hide filter pills during selection mode + +**Action handlers:** +- **Export:** Open `ExportDrawer` with selected note IDs +- **Move:** Open existing folder picker (or a new simple drawer listing folders) +- **Favorite:** Batch call `session.notes.favouriteNote(id)` for each selected ID, then exit selection +- **Delete:** Batch call `session.notes.trashNote(id)` for each selected ID, then exit selection + +--- + +### Task 9: Wire up `HomePage.tsx` (Conversations tab) + +**File:** `src/frontend/pages/home/HomePage.tsx` + +**Changes:** +1. Import and use `useMultiSelect` hook +2. When conversations tab is active and `isSelecting`: + - Show `SelectionHeader` + - Show `MultiSelectBar` with Export / Favorite / Delete + - Hide FAB menu +3. Pass selection props to `ConversationList` → `ConversationRow` +4. Filter out active/paused conversations from `selectAll` + +**Action handlers:** +- **Export:** Open `ExportDrawer` with selected conversation IDs +- **Favorite:** Batch `session.conversation.favouriteConversation(id)` +- **Delete:** Batch `session.conversation.trashConversation(id)` + +--- + +### Task 10: Wire up `HomePage.tsx` (Transcripts tab) + +**File:** `src/frontend/pages/home/HomePage.tsx` + +**Changes:** +1. Use a separate `useMultiSelect` instance for transcripts (or share one with tab awareness) +2. Pass selection props to `TranscriptList` +3. Show `MultiSelectBar` with Export / Delete only + +**Action handlers:** +- **Export:** Open `ExportDrawer` with selected transcript dates +- **Delete:** Batch delete transcript files for selected dates + +--- + +### Task 11: Backend — Batch export RPC + +**File:** `src/backend/session/managers/NotesManager.ts` + +Add new RPC methods: + +```ts +@rpc +async batchTrashNotes(noteIds: string[]): Promise { + for (const id of noteIds) { + await this.trashNote(id); + } +} + +@rpc +async batchFavouriteNotes(noteIds: string[]): Promise { + for (const id of noteIds) { + await this.favouriteNote(id); + } +} + +@rpc +async batchMoveNotes(noteIds: string[], folderId: string): Promise { + for (const id of noteIds) { + await this.updateNote(id, { folderId }); + } +} + +@rpc +async exportNotesAsText(noteIds: string[], includeTranscript: boolean): Promise { + // Compile all selected notes into a single text block + // For each note: title, date, content (stripped HTML) + // If includeTranscript: append linked conversation transcript + // Return the combined string +} +``` + +**File:** `src/backend/session/managers/ConversationManager.ts` + +```ts +@rpc +async batchTrashConversations(ids: string[]): Promise { + for (const id of ids) { + await this.trashConversation(id); + } +} + +@rpc +async batchFavouriteConversations(ids: string[]): Promise { + for (const id of ids) { + await this.favouriteConversation(id); + } +} + +@rpc +async exportConversationsAsText(ids: string[], includeTranscript: boolean): Promise { + // Compile selected conversations into text + // Summary + optionally full transcript segments +} +``` + +--- + +### Task 12: Export logic (frontend) + +**File:** `src/frontend/components/shared/ExportDrawer.tsx` (inside `onExport` handler) + +**Clipboard:** +```ts +const text = await session.notes.exportNotesAsText(noteIds, includeTranscript); +await navigator.clipboard.writeText(text); +// Show toast: "Copied to clipboard" +``` + +**Text File:** +```ts +const text = await session.notes.exportNotesAsText(noteIds, includeTranscript); +const blob = new Blob([text], { type: "text/plain" }); +const url = URL.createObjectURL(blob); +const a = document.createElement("a"); +a.href = url; +a.download = `notes-export-${new Date().toISOString().split("T")[0]}.txt`; +a.click(); +URL.revokeObjectURL(url); +``` + +**Share (native):** +```ts +const text = await session.notes.exportNotesAsText(noteIds, includeTranscript); +if (navigator.share) { + await navigator.share({ title: "Exported Notes", text }); +} else { + // Fallback to clipboard + await navigator.clipboard.writeText(text); +} +``` + +--- + +## File Summary + +| Action | File | Description | +|--------|------|-------------| +| **Create** | `src/frontend/hooks/useMultiSelect.ts` | Long-press + selection state hook | +| **Create** | `src/frontend/components/shared/MultiSelectBar.tsx` | Bottom action bar | +| **Create** | `src/frontend/components/shared/ExportDrawer.tsx` | Export drawer with toggles + destinations | +| **Create** | `src/frontend/components/shared/SelectionHeader.tsx` | Cancel / count / Select All header | +| **Modify** | `src/frontend/pages/notes/NoteRow.tsx` | Add checkbox, long-press, select mode | +| **Modify** | `src/frontend/pages/notes/NotesPage.tsx` | Wire up multi-select + actions | +| **Modify** | `src/frontend/pages/home/components/ConversationRow.tsx` | Add checkbox, block active conversations | +| **Modify** | `src/frontend/pages/home/components/TranscriptList.tsx` | Add checkbox, long-press, select mode | +| **Modify** | `src/frontend/pages/home/HomePage.tsx` | Wire up both conversation + transcript select | +| **Modify** | `src/backend/session/managers/NotesManager.ts` | Batch RPCs + export text | +| **Modify** | `src/backend/session/managers/ConversationManager.ts` | Batch RPCs + export text | + +--- + +## Implementation Order + +1. **Task 1** — `useMultiSelect` hook (foundation, everything depends on this) +2. **Task 4** — `SelectionHeader` (simple, needed by pages) +3. **Task 2** — `MultiSelectBar` (needed by pages) +4. **Task 3** — `ExportDrawer` (needed by export action) +5. **Task 5** — `NoteRow` modifications +6. **Task 8** — `NotesPage` wiring (first full flow — test here) +7. **Task 11** — Backend batch RPCs +8. **Task 12** — Export logic +9. **Task 6** — `ConversationRow` modifications +10. **Task 9** — `HomePage` conversations wiring +11. **Task 7** — `TranscriptList` modifications +12. **Task 10** — `HomePage` transcripts wiring + +--- + +## Edge Cases + +- **Active/paused conversations** — cannot be selected, appear dimmed in selection mode +- **Live transcript (today + recording)** — cannot be selected +- **Empty selection** — auto-exits selection mode +- **Filter change during selection** — clear selection and exit selection mode +- **Tab switch during selection** — clear selection and exit selection mode +- **Navigating away** — clear selection (useEffect cleanup) +- **Single item export** — drawer shows item title as subtitle +- **Batch export** — drawer shows "{n} items selected" as subtitle +- **No transcript linked** — "Linked Transcript" toggle disabled (grayed out) with "No transcript linked" subtitle +- **Clipboard API unavailable** — fallback to `document.execCommand("copy")` or show error +- **`navigator.share` unavailable** — hide "Share" option, show only Clipboard + Text File diff --git a/issues/16-merge-conversations/PLAN.md b/issues/16-merge-conversations/PLAN.md new file mode 100644 index 0000000..7b6bca9 --- /dev/null +++ b/issues/16-merge-conversations/PLAN.md @@ -0,0 +1,211 @@ +# Issue #16: Merge Conversations + +## Overview + +Allow users to select multiple conversations and merge them into a single new conversation. This handles the case where the auto-conversation tracker splits what should be one continuous conversation into multiple pieces. + +## User Flow + +1. User enters multi-select mode on the Conversations tab +2. Selects 2+ conversations +3. Taps **Merge** button in the bottom bar +4. A **Merge Drawer** appears with: + - Title: "Merge {N} Conversations?" + - Description explaining what will happen + - Checkbox: "Move original conversations to trash after merge" (default: checked) + - **Merge** button (primary, dark) + - **Cancel** button +5. On confirm: + - Segments from all selected conversations are loaded and sorted by timestamp (oldest → newest) + - A new conversation is created with all accumulated segments + - AI generates a new summary and title for the merged conversation + - If "move to trash" is checked, original conversations are trashed (not permanently deleted) + - The linked notes on the original conversations are NOT carried over (treated as a fresh conversation) + - User exits selection mode, sees the new merged conversation in the list + +## Segment Ordering + +Segments are sorted chronologically across all source conversations: +- Oldest segments first, newest last +- If conversations span multiple dates (e.g., yesterday + today), segments from yesterday come before today's +- Within the same date, sort by timestamp + +## Edge Cases (Basic) + +| Case | Behavior | +|------|----------| +| Conversations have linked AI notes | Notes are ignored — merge creates a fresh conversation with no noteId | +| Conversations span multiple dates | New conversation uses the earliest date as its `date`, with `startTime` from earliest segment and `endTime` from latest | +| One conversation is active/paused | Active/paused conversations cannot be selected, so this can't happen | +| Only 1 conversation selected | Merge button is disabled (need 2+) | +| Conversations are from trash | Merge button hidden when in trash filter | +| Original conversations trashed | Optional via checkbox — moves to trash, not permanent delete | +| Merged conversation has 0 segments | Shouldn't happen since we require ended conversations with chunks, but fallback: use chunks text if segments fail to load | + +## Edge Cases (Deep Investigation) + +### HIGH RISK + +#### Chunk References & Orphaning +- Each `TranscriptChunk` has a `conversationId` field. When merging, all chunks from source conversations must be reassigned to the merged conversation's ID +- If any chunk is missed, it becomes orphaned — queryable only by time range, breaking conversation integrity +- `chunkIds` array must be merged in chronological order by `chunkIndex`/`startTime`, not insertion order +- If a chunk was already deleted (cleanup), the merged conversation will silently have fewer segments +- **Mitigation:** Reassign chunk `conversationId` in bulk via `TranscriptChunk.updateMany()`, deduplicate chunk IDs before merging + +#### Frontend Sync & State Mismatch +- The frontend syncs via `conversations.set()` which replaces the entire list +- If merging happens server-side without properly syncing, the frontend still has old conversation objects +- Clicking a trashed/deleted source conversation after merge crashes the UI +- **Mitigation:** Atomic sync — add the new conversation and remove/update the old ones in a single `conversations.mutate()` call + +#### Stale Embeddings (Semantic Search) +- Conversations store an `embedding` field (vector for semantic search), generated when `aiSummary` is set +- Merging changes the conversation's content, making old embeddings irrelevant +- If not regenerated, semantic search returns incorrect results +- **Mitigation:** Regenerate embedding after generating the new AI summary + +#### Concurrency & Race Conditions +- If a merge is in progress and new chunks are being added to one of the source conversations, the merge could miss chunks +- If a note is being generated from a source conversation simultaneously, it might fail or generate from partial data +- **Mitigation:** Only allow merging of "ended" conversations (already enforced). Add a merge lock to prevent concurrent merges on the same conversations + +### MEDIUM RISK + +#### Cross-Date Conversations +- A conversation's `date` field stores ONE date (start date in user's timezone) +- Merging conversations across different dates forces a choice — merged conversation can only belong to one date +- This breaks the assumption that conversations are grouped by day +- **Mitigation:** Use the earliest date. Show a note in the merge drawer if conversations span multiple dates + +#### Running Summary Word Count +- Each conversation has a `runningSummary` (compressed transcript text, max 300 words) +- Merging 3 conversations with 300-word summaries = 900 words, exceeding the compression target +- The `runningSummary` is used by the LLM for chunk classification and resumption checks +- **Mitigation:** Don't concatenate running summaries — regenerate from the AI summary of the merged conversation + +#### Orphaned Notes +- Source conversations may have linked notes (`noteId`). After merging: + - The notes still exist but now reference conversations that may be trashed + - The `From: conversation title` label on those notes becomes stale +- **Mitigation:** Don't delete notes. They remain as standalone notes. Their `From:` label might show a trashed conversation title — acceptable + +#### Segment Loading from Different Sources +- Segments come from a 3-tier fallback: in-memory → MongoDB → R2 +- Merging conversations from different dates may hit different sources +- If R2 data was deleted for one conversation (transcript deleted feature), those segments are silently lost +- **Mitigation:** Log a warning if any source conversation returns 0 segments. Show in the merge drawer if data is missing + +#### Title Decision +- Each source conversation has a different title +- **Mitigation:** Regenerate title from the merged AI summary (LLM call). Don't try to combine existing titles + +### LOW RISK + +#### Favourite/Archive/Trash Status Conflicts +- Source conversations may have different statuses (one favourited, one archived) +- **Mitigation:** New conversation starts as default (not favourited, not archived, not trashed). User can favourite after + +#### Resumption Metadata +- Conversations have `resumedFrom` field for lineage tracking +- Merging breaks the lineage chain +- **Mitigation:** Set `resumedFrom: null` on merged conversation. Not user-facing + +#### Metadata & Timestamps +- `createdAt`/`updatedAt` on the merged conversation should use current time +- Chunks have `metadata` field that may have conflicting data +- **Mitigation:** Use current time for `createdAt`. Chunk metadata is preserved as-is + +## Implementation Plan + +### Phase 1: Backend RPC + +**File:** `src/backend/session/managers/ConversationManager.ts` + +Add new RPC method: + +```typescript +@rpc +async mergeConversations( + conversationIds: string[], + trashOriginals: boolean +): Promise // returns new conversation ID +``` + +Steps: +1. Validate: all conversations exist, status is "ended", at least 2 IDs +2. Sort source conversations by `startTime` ascending (chronological order) +3. For each conversation, load segments via `getSegmentsForConversation()` +4. Merge all segments into one array, sort by `timestamp` ascending, deduplicate +5. Merge all `chunkIds` from source conversations, sorted chronologically +6. Reassign chunk `conversationId` to the new conversation ID via bulk update +7. Create new conversation record with: + - `date`: earliest conversation's date + - `startTime`: earliest segment/chunk timestamp + - `endTime`: latest segment/chunk timestamp + - `status`: "ended" + - `noteId`: null + - `isFavourite`: false, `isArchived`: false, `isTrashed`: false + - Combined `chunkIds` + - `runningSummary`: empty (will be regenerated) +8. Generate AI summary + title via existing `generateAISummary()` flow +9. Generate embedding from the new AI summary +10. If `trashOriginals`: call `trashConversation()` for each source (NOT delete — preserves data) +11. Sync to frontend: add new conversation, update trashed ones in a single `conversations.mutate()` +12. Return the new conversation ID + +### Phase 2: Frontend — Merge Button (DONE) + +**File:** `src/frontend/pages/home/HomePage.tsx` + +- ✅ `MergeIcon` added to `MultiSelectBar.tsx` +- ✅ Merge action added to `convSelectActions` +- TODO: Disable merge when < 2 conversations selected +- TODO: Wire onClick to open merge drawer + +### Phase 3: Frontend — Merge Drawer + +**File:** `src/frontend/pages/home/HomePage.tsx` (inline) + +Vaul Drawer with: +- Title: "Merge {N} Conversations?" +- List of conversation titles being merged (scrollable if many) +- Warning if conversations span multiple dates +- Warning if any source conversation has 0 loadable segments +- Checkbox: "Move originals to trash" (default checked) +- Merge button (calls the RPC, shows loading state) +- Cancel button + +### Phase 4: Type Updates + +**File:** `src/shared/types.ts` + +Add to `ConversationManagerI`: +```typescript +mergeConversations(conversationIds: string[], trashOriginals: boolean): Promise; +``` + +## Files to Modify + +| File | Change | +|------|--------| +| `src/shared/types.ts` | Add `mergeConversations` to `ConversationManagerI` | +| `src/backend/session/managers/ConversationManager.ts` | Add `mergeConversations` RPC | +| `src/backend/models/conversation.model.ts` | May need bulk chunk reassignment helper | +| `src/frontend/components/shared/MultiSelectBar.tsx` | ✅ `MergeIcon` added | +| `src/frontend/pages/home/HomePage.tsx` | Add merge drawer, handler, state | + +## Design Notes + +- Merge button uses a "combine" icon (two arrows merging into one line) +- Button sits between Export and Favorite in the bottom bar +- Only enabled when 2+ conversations are selected +- Drawer matches the existing delete confirmation drawer style (vaul, warm stone design) +- Merge is an async operation — show a loading spinner on the button while in progress +- After merge completes, auto-navigate to the new conversation detail page (optional) + +## Decisions + +1. **No auto-navigate after merge.** Spinner on merge button → generates → exits selection mode → merged conversation appears in the list naturally. +2. **Max 10 conversations** can be merged at once. Show error/disable if > 10 selected. +3. **No preview** in the merge drawer. Keep it simple — title, description, trash checkbox, merge button. diff --git a/issues/17-memory-leaks/FINDINGS.md b/issues/17-memory-leaks/FINDINGS.md new file mode 100644 index 0000000..6102e2c --- /dev/null +++ b/issues/17-memory-leaks/FINDINGS.md @@ -0,0 +1,275 @@ +# Issue #17: Memory Leak Investigation + +## Overview + +Memory grows steadily from ~350MB to ~900MB+ over 1-2 days, then drops sharply on server restart. Classic sawtooth pattern indicating objects allocated but never freed by the garbage collector. + +## Memory Graph Analysis + +``` +900MB ─────────────/│ /│ + / │ / │ +700MB ──────── / │ / │ + / │ / │ +350MB ──────/ │ / │── + restart restart restart + 3/16 3/18 3/20 +``` + +- Growth rate: ~25MB/hour (~500MB over 20 hours) +- Baseline after restart: ~150-350MB +- Peak before restart: ~900-1000MB +- Pattern is consistent across restarts — same leak sources every time + +--- + +## CRITICAL — Fix Immediately + +### 1. S3Client Created Per-Request (Not Shared) + +**Severity:** CRITICAL +**Estimated impact:** 40-60% of memory growth +**Files:** +- `src/backend/services/r2Upload.service.ts` (lines 73, 284, 340, 421) +- `src/backend/services/r2Fetch.service.ts` (line 38) + +**Problem:** Every R2 operation creates a brand new `S3Client` instance. S3 clients maintain internal connection pools, HTTP agents, and request buffers. These are heavyweight objects (~100-500KB each) designed to be long-lived singletons. + +With the app processing hundreds of transcript segments per day: +- `uploadPhotoToR2()` — new client per photo +- `uploadToR2()` — new client per batch upload +- `fetchExistingBatch()` — new client per fetch +- `fetchTranscriptFromR2()` — new client per historical transcript load +- `listR2TranscriptDates()` — new client per date listing +- `deleteFromR2()` — new client per deletion + +Over 24 hours with multiple users, this creates hundreds-thousands of S3Client instances. The AWS SDK v3 S3Client does not self-cleanup — connection pools persist until the process exits. + +**Fix:** +```typescript +// Before (in every function): +const s3Client = new S3Client({ ... }); + +// After (module-level singleton): +let _s3Client: S3Client | null = null; +function getS3Client(): S3Client { + if (!_s3Client) { + _s3Client = new S3Client({ + region: "auto", + endpoint: process.env.R2_ENDPOINT, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + }); + } + return _s3Client; +} +``` + +**Effort:** 1-2 hours +**Risk:** Low — singleton pattern is the AWS-recommended approach + +--- + +### 2. ConversationManager.segmentCache Never Cleared + +**Severity:** HIGH +**Estimated impact:** 20-30% of memory growth +**File:** `src/backend/session/managers/ConversationManager.ts` (line 42) + +```typescript +private segmentCache = new Map(); +``` + +**Problem:** This cache stores ALL transcript segments from R2 for each date that a conversation's segments are loaded. A single day's transcript can have 1000-3000+ segments, each with full text content. + +- Cache entries are added when `getSegmentsForConversation()` fetches from R2 (line ~926) +- Cache is NEVER cleared — not on session destroy, not on time expiry, never +- `destroy()` method (line 166) doesn't clear it +- Each date entry: 1000 segments * ~200 bytes = ~200KB per date +- User accessing 10 historical dates = 2MB cached per session +- Multiple users over 24 hours = 10-50MB+ in dead caches + +**Fix:** +1. Clear cache in `destroy()`: + ```typescript + destroy() { + this.segmentCache.clear(); + // ... existing cleanup + } + ``` +2. Add TTL-based eviction (optional): + ```typescript + // Clear entries older than 30 minutes + private segmentCacheTimestamps = new Map(); + ``` +3. Cap cache size (e.g., max 5 dates cached, LRU eviction) + +**Effort:** 30 minutes +**Risk:** Very low + +--- + +## SIGNIFICANT — Fix This Week + +### 3. Session Cleanup on Disconnect + +**Severity:** MEDIUM-HIGH +**File:** `src/backend/session/NotesSession.ts`, `src/backend/session/SessionManager.ts` + +**Problem:** When a user disconnects (tab close, network drop), the session and all its managers must be fully destroyed. If any manager holds references to large objects (segment arrays, chunk buffers, cached data), those objects persist in memory until the session is garbage collected. + +**What to verify:** +- Are all `setInterval`/`setTimeout` timers cleared in every manager's `destroy()`? +- Are all event listeners removed? +- Are all arrays/Maps/Sets emptied? +- Is the session removed from the SessionManager's session map? + +**Key managers to audit:** +- `ChunkBufferManager` — heartbeat interval (5s) must be cleared +- `SummaryManager` — rolling summary timer must be cleared +- `TranscriptManager` — pendingSegments must be flushed/cleared +- `R2Manager` — any batch timers must be cleared + +--- + +### 4. ChunkBufferManager Heartbeat Interval + +**Severity:** MEDIUM +**File:** `src/backend/session/managers/ChunkBufferManager.ts` + +**Problem:** The heartbeat runs every 5 seconds (`setInterval`). If not properly cleared on session disconnect, it keeps the entire ChunkBufferManager (and everything it references) alive in memory indefinitely. + +**What to check:** +- Is `clearInterval(heartbeat)` called in `destroy()`? +- Does the heartbeat callback reference `this` (keeping the manager alive via closure)? + +--- + +### 5. TranscriptManager.pendingSegments Race Condition + +**Severity:** MEDIUM +**File:** `src/backend/session/managers/TranscriptManager.ts` (lines 64, 375-382) + +**Problem:** Segments are added to `pendingSegments[]` and flushed to MongoDB on a 30-second timer. If MongoDB is slow or the persist call fails: +- Segments keep accumulating (3-5 segments/sec = 90-150 segments in 30s) +- Failed persist doesn't clear the array — it keeps growing +- At ~200 bytes per segment, a 10-minute MongoDB outage = 180KB of queued segments per user + +**Fix:** Add max queue size + error recovery: +```typescript +if (this.pendingSegments.length > 500) { + console.warn("[TranscriptManager] pendingSegments overflow, dropping oldest"); + this.pendingSegments = this.pendingSegments.slice(-200); +} +``` + +--- + +### 6. FileManager._operationInProgress Promise Chain + +**Severity:** MEDIUM +**File:** `src/backend/session/managers/FileManager.ts` (lines 71, 623-641) + +**Problem:** File operations (trash, archive, favourite) create a Promise that subsequent operations `await`. If an operation throws without calling `resolveOperation()`, the Promise never resolves — blocking all future file operations for that session AND keeping the Promise chain in memory. + +**Fix:** Ensure `resolveOperation()` is always called in `finally`: +```typescript +// Already done in most places, but verify ALL paths +try { + // ... operation +} finally { + resolveOperation!(); // Must always be called + this._operationInProgress = null; +} +``` + +--- + +### 7. provisionalTitleInFlight Set Leak + +**Severity:** LOW-MEDIUM +**File:** `src/backend/session/managers/ConversationManager.ts` (line 294) + +**Problem:** The `provisionalTitleInFlight` Set tracks conversation IDs currently generating titles. If a conversation is deleted/trashed while title generation is in-flight, the ID stays in the Set forever (small leak, ~50 bytes per ID). + +**Fix:** Add timeout-based cleanup: +```typescript +setTimeout(() => { + this.provisionalTitleInFlight.delete(convId); +}, 30000); // 30s max for title generation +``` + +--- + +## LOW PRIORITY — Nice to Have + +### 8. Mongoose Connection Pool + +**Severity:** LOW +**Problem:** MongoDB connections are pooled by Mongoose. Default pool size is 5. Under heavy load, pool may grow. Not a leak per se, but worth monitoring. + +### 9. Closure Chains in waitForSentenceBoundary + +**Severity:** LOW +**File:** `src/backend/session/managers/ChunkBufferManager.ts` (lines 182-212) + +**Problem:** Recursive `setTimeout` in `waitForSentenceBoundary` creates closure chains that capture buffer state. Each 500ms check creates a new closure referencing `initialText` and `this.buffer`. Over 8s max wait = 16 closures per chunk boundary check. + +**Fix:** Use a single timer that checks periodically instead of recursive timeouts. + +--- + +## Implementation Plan + +### Phase 1 — Quick Wins (1-2 hours, biggest impact) + +1. **Singleton S3Client** — Create module-level singleton in r2Upload.service.ts and r2Fetch.service.ts +2. **Clear segmentCache** — Add `this.segmentCache.clear()` to ConversationManager.destroy() +3. **Clear provisionalTitleInFlight** — Add `this.provisionalTitleInFlight.clear()` to destroy() + +### Phase 2 — Session Cleanup Audit (2-3 hours) + +4. Audit every manager's `destroy()` method for: + - Timers not cleared + - Event listeners not removed + - Arrays/Maps/Sets not emptied +5. Add `pendingSegments` overflow protection in TranscriptManager +6. Verify `_operationInProgress` Promise resolution in all FileManager paths + +### Phase 3 — Monitoring (1 hour) + +7. Add memory usage logging on session create/destroy: + ```typescript + console.log(`[Session] Memory: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`); + ``` +8. Add session count logging to track active sessions vs memory + +--- + +## Expected Impact + +| Fix | Memory Saved | Confidence | +|-----|-------------|------------| +| Singleton S3Client | 200-400MB/day | High | +| Clear segmentCache | 50-150MB/day | High | +| Session cleanup audit | 50-100MB/day | Medium | +| pendingSegments cap | 10-30MB/day | Medium | +| provisionalTitle cleanup | 1-5MB/day | Low | + +**Total estimated reduction: 300-600MB/day** — should keep steady-state memory under 500MB instead of climbing to 900MB+. + +--- + +## Files to Modify + +| File | Change | +|------|--------| +| `src/backend/services/r2Upload.service.ts` | Singleton S3Client | +| `src/backend/services/r2Fetch.service.ts` | Singleton S3Client | +| `src/backend/session/managers/ConversationManager.ts` | Clear segmentCache + provisionalTitleInFlight in destroy() | +| `src/backend/session/managers/TranscriptManager.ts` | Cap pendingSegments, clear in destroy() | +| `src/backend/session/managers/FileManager.ts` | Verify Promise resolution, clear in destroy() | +| `src/backend/session/managers/ChunkBufferManager.ts` | Verify heartbeat cleanup in destroy() | +| `src/backend/session/NotesSession.ts` | Add memory logging on create/destroy | diff --git a/issues/18-release-notes-v3/API-REFERENCE.md b/issues/18-release-notes-v3/API-REFERENCE.md new file mode 100644 index 0000000..ad00acd --- /dev/null +++ b/issues/18-release-notes-v3/API-REFERENCE.md @@ -0,0 +1,190 @@ +# Mentra Notes v3.0.0 — API Reference + +## Health & Status + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/health` | Server health check, returns active session count | +| GET | `/api/auth/status` | Authentication status check | +| GET | `/api/session/status` | Current session state (connected, recording, counts) | + +## Notes + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/notes` | Get all notes for authenticated user | +| GET | `/api/notes/:id` | Get specific note by ID | +| POST | `/api/notes` | Create manual note (title, content) | +| POST | `/api/notes/generate` | Generate AI note from transcript | +| PUT | `/api/notes/:id` | Update note (title, content) | +| DELETE | `/api/notes/:id` | Delete note | +| GET | `/api/notes/:id/download/:format` | Download note as PDF/TXT/DOCX (signed URL token required) | + +## Transcripts + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/transcripts/today` | Get today's transcript segments | +| GET | `/api/transcripts/:date` | Get historical transcript for specific date | +| DELETE | `/api/transcripts/today` | Clear today's transcript | + +## Settings + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/settings` | Get user settings | +| PUT | `/api/settings` | Update user settings | + +## Files (Date Management) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/files` | Get all files with optional filter query param | +| GET | `/api/files/:date` | Get specific date's file metadata | +| PATCH | `/api/files/:date` | Update file flags (archived, trashed, favourite) | + +## Photos + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/photos/:date/:filename` | Proxy R2 photo to browser | + +## Email + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/email/send` | Send notes email via Resend | +| POST | `/api/transcript/email` | Send transcript email via Resend | + +### POST /api/email/send — Request Body + +```json +{ + "to": "user@email.com", + "cc": ["cc@email.com"], + "sessionDate": "March 22, 2026", + "sessionStartTime": "9:00 AM", + "sessionEndTime": "5:00 PM", + "notes": [ + { + "noteId": "abc123", + "noteTimestamp": "9:30 AM", + "noteTitle": "Meeting Notes", + "noteContent": "

HTML content...

", + "noteType": "AI Generated" + } + ] +} +``` + +### POST /api/transcript/email — Request Body + +```json +{ + "to": "user@email.com", + "cc": ["cc@email.com"], + "userId": "user-id", + "date": "2026-03-22", + "sessionDate": "March 22, 2026", + "sessionStartTime": "9:00 AM", + "sessionEndTime": "5:00 PM", + "segments": [ + { "timestamp": "9:00 AM", "text": "Hello everyone..." } + ] +} +``` + +## Search + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/search?q=...&limit=10&userId=...` | Semantic search across notes and conversations | + +## Authentication + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/mentra/auth/init` | Initialize auth (exchange temp token / verify signed token) | + +## WebSocket + +| Endpoint | Description | +|----------|-------------| +| `ws://host/ws/sync?userId=...` | Real-time sync connection | + +### WebSocket Messages (Client → Server) + +```json +{ "type": "rpc", "manager": "notes", "method": "generateNote", "args": [...], "id": "uuid" } +``` + +### WebSocket Messages (Server → Client) + +```json +{ "type": "connected" } +{ "type": "snapshot", "state": { ... } } +{ "type": "state_change", "manager": "notes", "property": "notes", "value": [...] } +{ "type": "rpc_response", "id": "uuid", "result": { ... } } +``` + +## RPC Methods (via WebSocket) + +### TranscriptManager +- `getRecentSegments(count?)` — Get last N segments +- `getFullText()` — Get full transcript as text +- `clear()` — Clear transcript +- `loadDateTranscript(date)` — Load historical transcript +- `loadTodayTranscript()` — Switch back to today +- `removeDates(dates[])` — Remove dates from available list + +### NotesManager +- `generateNote(title?, startTime?, endTime?)` — AI-generate note +- `createManualNote(title, content)` — Create manual note +- `updateNote(noteId, updates)` — Update note +- `deleteNote(noteId)` — Delete note +- `favouriteNote(noteId)` / `unfavouriteNote(noteId)` +- `archiveNote(noteId)` / `unarchiveNote(noteId)` +- `trashNote(noteId)` / `untrashNote(noteId)` / `permanentlyDeleteNote(noteId)` +- `emptyNoteTrash()` — Empty notes trash +- `batchFavouriteNotes(ids[])` / `batchTrashNotes(ids[])` / `batchMoveNotes(ids[], folderId)` + +### ConversationManager +- `deleteConversation(id)` — Delete conversation +- `linkNoteToConversation(convId, noteId)` — Link note +- `loadConversationSegments(convId)` — Load transcript segments +- `favouriteConversation(id)` / `unfavouriteConversation(id)` +- `archiveConversation(id)` / `unarchiveConversation(id)` +- `trashConversation(id)` / `untrashConversation(id)` +- `emptyTrash()` — Empty conversations trash +- `mergeConversations(ids[], trashOriginals)` — Merge conversations +- `batchFavouriteConversations(ids[])` / `batchTrashConversations(ids[])` + +### ChatManager +- `sendMessage(content)` — Send chat message +- `clearHistory()` — Clear chat +- `loadDateChat(date)` — Load chat for specific date + +### SettingsManager +- `updateSettings(settings)` — Update user settings +- `getSettings()` — Get current settings + +### FileManager +- `refreshFiles()` — Force refresh file list +- `getFilesRpc(filter?)` — Get files with filter +- `setFilter(filter)` — Change active filter +- `archiveFile(date)` / `unarchiveFile(date)` +- `trashFile(date)` — Trash file (deletes R2 + MongoDB transcript data) +- `restoreFile(date)` — Restore from trash +- `favouriteFile(date)` / `unfavouriteFile(date)` +- `permanentlyDeleteFile(date)` — Permanent delete +- `purgeDate(date)` — Delete all data for a date +- `emptyTrash()` — Empty files trash + +### FoldersManager +- `createFolder(name, color)` — Create folder +- `updateFolder(folderId, updates)` — Update folder +- `deleteFolder(folderId)` — Delete folder + +### SummaryManager +- `generateHourSummary(hour)` — Generate summary for specific hour +- `loadSummariesForDate(date)` — Load hour summaries for a date diff --git a/issues/18-release-notes-v3/ARCHITECTURE.md b/issues/18-release-notes-v3/ARCHITECTURE.md new file mode 100644 index 0000000..77bb3b0 --- /dev/null +++ b/issues/18-release-notes-v3/ARCHITECTURE.md @@ -0,0 +1,179 @@ +# Mentra Notes v3.0.0 — Architecture + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ MentraOS Glasses │ +│ Microphone → Audio Stream → MentraOS SDK → WebSocket │ +└──────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Hono.js Server (Bun) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Auth MW │ │ API Routes │ │ WebSocket │ │ +│ │ (MentraOS) │ │ (REST) │ │ (Sync) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Session Manager │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Per-User Session │ │ │ +│ │ │ │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │ +│ │ │ │Transcript │ │Conversa- │ │ Notes │ │ │ │ +│ │ │ │ Manager │ │tion Mgr │ │Manager │ │ │ │ +│ │ │ └──────────┘ └──────────┘ └────────┘ │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │ +│ │ │ │ Summary │ │ Chat │ │Settings│ │ │ │ +│ │ │ │ Manager │ │ Manager │ │Manager │ │ │ │ +│ │ │ └──────────┘ └──────────┘ └────────┘ │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │ +│ │ │ │ File │ │ Folder │ │ R2 │ │ │ │ +│ │ │ │ Manager │ │ Manager │ │Manager │ │ │ │ +│ │ │ └──────────┘ └──────────┘ └────────┘ │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ │ │ │ +│ │ │ │ Chunk │ │ Input │ │ │ │ +│ │ │ │ Buffer │ │ Manager │ │ │ │ +│ │ │ └──────────┘ └──────────┘ │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Auto-Notes Pipeline │ │ +│ │ ChunkBuffer → TriageClassifier → ConvTracker │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ LLM Service │ │ R2 Service │ │ Email Service│ │ +│ │ (Gemini/ │ │ (Cloudflare)│ │ (Resend) │ │ +│ │ Claude/OAI)│ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + ┌────────┐ ┌────────┐ ┌────────┐ + │MongoDB │ │ R2 │ │ Resend │ + │ │ │(Photos │ │(Email) │ + │(Data) │ │+Trans) │ │ │ + └────────┘ └────────┘ └────────┘ +``` + +## Data Flow: Audio → Note + +``` +1. Glasses Mic → Audio Stream +2. MentraOS SDK → Transcription (Deepgram/Google) +3. Transcript Segment → TranscriptManager + ├── Persist to MongoDB (DailyTranscript) + ├── Sync to frontend (live display) + └── Feed to ChunkBufferManager +4. ChunkBuffer (40s window) → TranscriptChunk +5. TriageClassifier → MEANINGFUL / FILLER / AUTO-SKIPPED +6. ConversationTracker (state machine) + ├── IDLE: Wait for meaningful chunk + ├── PENDING: Buffer 3 chunks to confirm + ├── TRACKING: Group chunks into conversation + ├── PAUSED: Detect silence (7 chunks to end) + └── END: Trigger AI summary + note generation +7. NotesManager.generateNote() + ├── Load segments for conversation time range + ├── Build transcript text + ├── LLM generates structured HTML note + └── Link note to conversation +8. Sync to frontend → User sees note in list +``` + +## Frontend Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ React 19 App │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ Wouter Router │ │ +│ │ / → HomePage │ │ +│ │ /notes → NotesPage │ │ +│ │ /note/:id → NotePage (editor) │ │ +│ │ /conversation/:id → ConversationDetailPage │ │ +│ │ /conversation/:id/transcript → TranscriptP │ │ +│ │ /conversation/:id/generating → GeneratingP │ │ +│ │ /transcript/:date → TranscriptPage │ │ +│ │ /collections → CollectionsPage │ │ +│ │ /folder/:id → FolderPage │ │ +│ │ /search → SearchPage │ │ +│ │ /settings → SettingsPage │ │ +│ │ /onboarding → OnboardingPage │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ useSynced │ │ Shared Components │ │ +│ │ (WebSocket │ │ ExportDrawer │ │ +│ │ + RPC) │ │ EmailDrawer │ │ +│ │ │ │ MultiSelectBar │ │ +│ │ useMulti- │ │ SelectionHeader │ │ +│ │ Select │ │ FilterDrawers │ │ +│ │ │ │ BottomDrawer │ │ +│ │ useAutoScr- │ │ LoadingState │ │ +│ │ oll │ │ WaveIndicator │ │ +│ │ │ │ SkeletonLoader │ │ +│ │ useSwipeTo- │ │ FABMenu │ │ +│ │ Reveal │ │ DropdownMenu │ │ +│ └──────────────┘ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## Database Schema + +### MongoDB Collections + +| Collection | Key Fields | Purpose | +|-----------|-----------|---------| +| `usersettings` | userId, displayName, timezone, onboardingCompleted, role, company, priorities | User preferences | +| `notes` | userId, title, content, date, isAIGenerated, isFavourite, isArchived, isTrashed, folderId | Notes data | +| `conversations` | userId, date, title, status, startTime, endTime, chunkIds, aiSummary, noteId | Detected conversations | +| `transcriptchunks` | userId, date, text, startTime, endTime, chunkIndex, classification, conversationId | 40s audio chunks | +| `dailytranscripts` | userId, date, segments[] | Full day transcripts | +| `hoursummaries` | userId, date, hour, summary, segmentCount | Hourly AI summaries | +| `chatmessages` | userId, date, role, content | AI chat history | +| `files` | userId, date, hasTranscript, hasNotes, r2Key, segmentCount, hourCount | Date metadata | +| `folders` | userId, name, color | Note folders | +| `userstates` | userId, batchEndOfDay | R2 batch scheduling | + +### Cloudflare R2 Structure + +``` +transcripts/ + {userId}/ + {YYYY-MM-DD}/ + transcript.json ← Full day transcript (archived) + photos/ + photo-{timestamp}.jpg ← Captured photos +``` + +## Sync Protocol + +### Connection +1. Frontend opens WebSocket to `/ws/sync?userId=...` +2. Backend creates/gets session for user +3. Backend sends `{ type: "connected" }` +4. Backend sends `{ type: "snapshot", state: {...} }` with full state +5. Frontend renders from snapshot + +### State Updates +- Backend: `@synced` property changes → diff sent as `{ type: "state_change", manager, property, value }` +- Frontend: `useSynced()` hook receives changes, triggers React re-render + +### RPC Calls +- Frontend: `session.notes.generateNote(title, start, end)` +- Serialized as: `{ type: "rpc", manager: "notes", method: "generateNote", args: [...], id: "uuid" }` +- Backend executes, returns: `{ type: "rpc_response", id: "uuid", result: {...} }` +- Frontend resolves the Promise + +### Reconnection +- On visibility change (tab hidden → visible): check connection, reconnect if needed +- On reconnect: full snapshot re-sent (ensures consistency) +- `onboardingResolvedRef` prevents onboarding re-trigger on reconnect diff --git a/issues/18-release-notes-v3/CHANGELOG.md b/issues/18-release-notes-v3/CHANGELOG.md new file mode 100644 index 0000000..8fde970 --- /dev/null +++ b/issues/18-release-notes-v3/CHANGELOG.md @@ -0,0 +1,134 @@ +# Mentra Notes v3.0.0 — Changelog + +## New Features + +### Auto-Conversation Pipeline +- 3-stage pipeline: ChunkBuffer → TriageClassifier → ConversationTracker +- Automatic conversation detection from meaningful speech +- Provisional title generation every 3 chunks +- AI summary generation on conversation end +- Running summary maintained per conversation +- Configurable silence thresholds, word minimums, model tiers + +### Multi-Select & Batch Operations +- Long-press to enter selection mode on conversations, notes, and transcripts +- Selection header with count, Cancel, Select All +- Bottom action bar with contextual actions +- No text selection during selection mode (select-none) +- Instant checkbox appearance (no shift animation) + +### Conversation Merging +- Merge 2-10 ended conversations into one +- Fresh AI summary + title for merged conversation +- Chunks reassigned to merged conversation +- Option to trash originals after merge +- Merged conversation highlighted with 4-second red pulse animation +- Merge button grayed out when < 2 selected + +### Export & Email System +- Export drawer with content toggles and destination selection +- Clipboard export with formatted text + metadata +- Email export via Resend with HTML templates +- Notes: linked conversation + conversation transcript sub-toggles +- Conversations: linked transcript + linked AI note toggles +- Transcripts: per-date cards with segment tables +- Content toggle always on (not disableable) +- CC support with "Remember CC" checkbox + +### Transcript Deletion +- Permanently deletes from R2 + MongoDB (not soft delete) +- Warning drawer if conversations exist on those dates +- "Transcript deleted" indicator on conversation detail + transcript pages +- Removed from available dates on session reconnect + +### Redesigned Pages +- Settings page: warm stone design, user profile at top, Supabase avatar +- Email templates: warm stone colors, responsive, type badges +- Conversation rows: favourite star icon, "Generating title..." spinner +- Notes page: filter/view toggle visible on empty state + +### Mic Status Indicator +- Compact circle next to FAB button +- Red pulsing dot = mic on, gray icon = mic off +- Shown on HomePage and NotesPage + +## Improvements + +### Conversation Detection +- Requires 3 meaningful chunks to confirm (was 2) +- Requires 2 consecutive fillers to pause (was 1) +- Requires 7 silence chunks to end conversation (was 4, ~35s) +- Topic change goes through PENDING confirmation (was instant new conversation) +- Resumption classifier more lenient (only splits on completely unrelated subjects) +- Default to continuation on LLM error (was default to new topic) +- Background audio detection: TV, radio, podcasts classified as filler +- Fast-track threshold raised from 10 to 25 words + +### Note Generation +- Fallback to all segments when time filter matches 0 (timezone mismatch fix) +- Uses conversation's startTime/endTime as fallback when chunks are empty +- Summary generation uses chunkIds directly (not time range query) +- Merge-aware prompt for broader title generation +- Error logged to console for debugging + +### Scroll Behavior +- TranscriptTab: scroll unlocks on scroll up, re-locks near bottom (200px threshold) +- No auto-scroll on interim text updates (removed characterData from MutationObserver) +- useAutoScroll hook: same fix applied to ConversationTranscriptPage +- Initial scroll to bottom only fires once (not on every re-render) + +### Search +- AbortController cancels stale queries (prevents flash of old results) +- Minimum 2-second loading + actual query time +- No more "nothing found" → results flash + +### Performance +- Checkbox animations removed (instant show/hide, no framer-motion) +- ConversationList overflow: visible (not hidden) for selection highlight +- NotesPage: proper scroll container with min-h-0 + overflow-y-auto + +## Bug Fixes + +### Critical +- Fixed: Notes page couldn't scroll (missing overflow-y-auto + min-h-0) +- Fixed: Swipe-to-reveal broken by long-press handlers overwriting touch events (merged handlers) +- Fixed: Conversations empty state blocked transcripts tab (removed early return) +- Fixed: Transcript segments flashing between loading and "no transcript" (segmentsLoadedRef guard) +- Fixed: S3Client memory leak — hundreds of instances per day (singleton pattern) +- Fixed: ConversationManager segmentCache never cleared (clear in destroy) + +### Conversation +- Fixed: "uh-huh" immediately pausing conversations (now needs 2 consecutive fillers) +- Fixed: Topic change instantly creating new conversation (now goes through PENDING) +- Fixed: Note generation failing due to time range mismatch (fallback to all segments) +- Fixed: 0 minute duration display (minimum 1 minute) +- Fixed: "New Conversation" / "Untitled Conversation" placeholder (now shows spinner) +- Fixed: Merged conversation title same as source (merge-aware prompt + chunkIds-based query) +- Fixed: Merged conversation had 0 chunks (use chunkIds array, not DB query) +- Fixed: AI summary prompt mentioning "smart glasses" (removed) + +### UI +- Fixed: Text selectable during multi-select mode (select-none always applied) +- Fixed: Onboarding re-triggers on reconnect (onboardingResolvedRef guard) +- Fixed: Dark mode leaking to new users (forced light mode) +- Fixed: Favourites filter pill not showing in notes (added to condition) +- Fixed: "Nothing found" flash before conversations hydrate (guard on isConversationsHydrated) +- Fixed: Filter loading fake 3-second delay (removed, filters apply instantly) +- Fixed: Loading/empty states not centered (h-full instead of flex-1) + +### Memory Leaks +- Fixed: S3Client created per-request in r2Upload + r2Fetch (singleton) +- Fixed: ConversationManager.segmentCache never cleared +- Fixed: ConversationManager.provisionalTitleInFlight not cleared on destroy +- Fixed: ChunkBufferManager buffer/callbacks not cleared on destroy +- Fixed: TranscriptManager pendingSegments not cleared on destroy +- Fixed: FileManager had no destroy() method +- Fixed: ChatManager had no destroy() method +- Fixed: SummaryManager LLM provider not cleared on destroy +- Added: Memory logging on session create/destroy + +## Removed +- Dark mode toggle (always light mode, commented out for future re-enable) +- Text File export option (replaced by Email) +- Share export option (replaced by Email) +- Fake filter loading delays (3-second and 1-second timers removed) diff --git a/issues/18-release-notes-v3/CONFIGURATION.md b/issues/18-release-notes-v3/CONFIGURATION.md new file mode 100644 index 0000000..49b6532 --- /dev/null +++ b/issues/18-release-notes-v3/CONFIGURATION.md @@ -0,0 +1,108 @@ +# Mentra Notes v3.0.0 — Configuration + +## Environment Variables + +### Required + +| Variable | Description | +|----------|-------------| +| `MONGODB_URI` | MongoDB connection string | +| `CLOUDFLARE_R2_ENDPOINT` | R2 endpoint URL | +| `CLOUDFLARE_R2_ACCESS_KEY_ID` | R2 access key | +| `CLOUDFLARE_R2_SECRET_ACCESS_KEY` | R2 secret key | +| `CLOUDFLARE_R2_BUCKET_NAME` | R2 bucket name | + +### AI Provider (at least one required) + +| Variable | Description | +|----------|-------------| +| `GEMINI_API_KEY` | Google Gemini API key (primary) | +| `ANTHROPIC_API_KEY` | Anthropic Claude API key (fallback) | +| `OPENAI_API_KEY` | OpenAI API key (fallback) | + +### Optional + +| Variable | Description | Default | +|----------|-------------|---------| +| `RESEND_API_KEY` | Resend email API key | — | +| `PORT` | Server port | 3000 | +| `NODE_ENV` | Environment (development/production) | development | +| `POSTHOG_API_KEY` | PostHog analytics key | — | + +## Auto-Notes Pipeline Configuration + +Located in `src/backend/core/auto-conversation/config.ts`: + +### Buffer (Stage 1) + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `BUFFER_INTERVAL_MS` | 5,000 | How often to package buffer into chunks (ms) | +| `SENTENCE_BOUNDARY_MAX_WAIT_MS` | 3,000 | Max wait for sentence boundary after interval | + +### Triage (Stage 2) + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `PRE_FILTER_WORD_MIN` | 4 | Chunks under this are auto-skipped | +| `CONTEXT_LOOKBACK_CHUNKS` | 2 | Previous chunks for classification context | + +### Conversation Tracking (Stage 3) + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `MIN_CHUNKS_TO_CONFIRM` | 3 | Meaningful chunks needed to create conversation (~15s) | +| `PENDING_SILENCE_THRESHOLD` | 3 | Filler chunks in PENDING before discarding | +| `CONTEXT_PREAMBLE_CHUNKS` | 3 | Preceding chunks pulled into new conversation | +| `SILENCE_PAUSE_CHUNKS` | 2 | Consecutive fillers to pause conversation | +| `SILENCE_END_CHUNKS` | 7 | Consecutive silence chunks to end conversation (~35s) | +| `SUMMARY_MAX_WORDS` | 300 | Max words for running summary | +| `SUMMARY_COMPRESSION_INTERVAL` | 3 | Chunks before compressing summary | +| `RESUMPTION_WINDOW_MS` | 1,800,000 | Resume window (30 minutes) | + +### LLM Tiers + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `TRIAGE_MODEL_TIER` | "fast" | Model for triage classification | +| `TRACKER_MODEL_TIER` | "fast" | Model for conversation tracking | +| `NOTE_GENERATION_MODEL_TIER` | "smart" | Model for note generation | +| `SUMMARY_MODEL_TIER` | "fast" | Model for summary compression | + +### Token Limits + +| Parameter | Value | +|-----------|-------| +| `TRIAGE_MAX_TOKENS` | 64 | +| `TRACKER_MAX_TOKENS` | 128 | +| `NOTE_GENERATION_MAX_TOKENS` | 4,096 | +| `SUMMARY_MAX_TOKENS` | 512 | + +## Feature Flags (PostHog) + +| Flag | Default | Description | +|------|---------|-------------| +| `frontend-onboard` | true | Show onboarding flow for new users | + +## Porter Deployment Configuration + +```yaml +name: mentra-notes +services: + - name: mentra-notes + type: web + runtime: docker + plan: starter-2 # 2 CPU cores + ram: 5120 # 5GB RAM + port: 3000 + healthcheck: /api/health + domains: + - general.mentra.glass +``` + +## Merge Limits + +| Parameter | Value | Description | +|-----------|-------|-------------| +| Min conversations to merge | 2 | Must select at least 2 | +| Max conversations to merge | 10 | Performance limit | diff --git a/issues/18-release-notes-v3/FEATURES.md b/issues/18-release-notes-v3/FEATURES.md new file mode 100644 index 0000000..4c34244 --- /dev/null +++ b/issues/18-release-notes-v3/FEATURES.md @@ -0,0 +1,273 @@ +# Mentra Notes v3.0.0 — Complete Feature List + +## 1. Real-Time Transcription + +### Always-On Recording +- Continuous microphone capture from MentraOS smart glasses +- Final vs. interim text handling (real-time updates as user speaks) +- Force-finalize at 50-word threshold to prevent buffer overflow +- Transcription pause/resume toggle (settings + FAB menu) + +### Multi-Speaker Support +- Speaker ID detection and tracking +- Color-coded speaker labels in transcript view (6 colors) +- Speaker-aware segments for conversation transcripts + +### Photo Capture +- Photos captured at wake word time (parallel with user speaking) +- Uploaded to Cloudflare R2 with metadata (timestamp, timezone) +- Embedded in AI-generated notes when relevant +- LLM-powered photo description for context + +### Transcript Organization +- Per-day organization (YYYY-MM-DD folders) +- Available dates tracking across MongoDB + R2 +- Historical transcript loading with date navigation +- Hour-by-hour segment grouping with collapsible sections +- Segment count per hour/day + +## 2. Auto-Notes Pipeline + +### Stage 1: ChunkBuffer +- Accumulates transcript into 40-second chunks +- 5-second heartbeat interval +- Sentence boundary detection (waits up to 3 seconds for natural pause) +- Silence signal emission when buffer empty + not speaking +- Configurable via `AUTO_NOTES_CONFIG` + +### Stage 2: TriageClassifier +- **Auto-skip**: Chunks under 4 words with no high-signal keywords +- **Fast-track**: Chunks with 25+ words marked meaningful immediately +- **LLM classification**: 4-24 word chunks classified by AI as MEANINGFUL or FILLER +- Filler detection: background audio, TV/radio, podcasts, one-word acknowledgments +- Context-aware: uses 2 previous chunks for classification context +- Domain profile support (general, product manager, etc.) + +### Stage 3: ConversationTracker +- State machine: `IDLE → PENDING → TRACKING → PAUSED → END` +- **PENDING**: Requires 3 meaningful chunks (~15 seconds) before creating a conversation +- **TRACKING**: Monitors for topic continuation, new topics, or filler +- **PAUSED**: Requires 2 consecutive fillers to pause, 7 silence chunks to end +- **Resumption**: LLM checks if new speech continues the paused conversation (lenient — only splits on completely unrelated subjects) +- **Topic change**: Goes through PENDING confirmation instead of instant new conversation +- Running summary maintained per conversation (compressed every 3 chunks, max 300 words) + +### Note Generation +- Triggered automatically when conversation ends +- Uses Gemini 2.5 Flash for structured note generation +- HTML output with `

` headings, bullet lists, `` emphasis +- 100-500 word target +- Photo embedding when relevant (LLM decides relevance) +- Safety pass for content review +- Linked back to source conversation via `noteId` + +## 3. Notes Management + +### CRUD Operations +- **Create**: Manual (rich text editor) or AI-generated from conversation +- **Read**: Full note view with TipTap editor, metadata display +- **Update**: Title + content editing, folder assignment +- **Delete**: Three-tier — trash → permanent delete, or empty trash + +### Organization +- **Folders**: Create with name + color (red, gray, blue), move notes between folders +- **Favorites**: Quick-access flagging with star icon +- **Archive**: Hide without deleting +- **Trash**: Soft delete with recovery option, empty trash for permanent deletion +- **Filter pills**: All, Manual, AI Generated +- **Filter drawer**: Sort (recent/oldest), show filter (all/favourites/archived/trash) + +### Rich Text Editor (TipTap) +- Headings (H1-H3) +- Bold, italic, underline +- Bullet and numbered lists +- Code blocks +- Image embedding (from photos) +- Link insertion + +### Batch Operations (Multi-Select) +- Long-press to enter selection mode +- Select All / Cancel buttons +- Actions: Export, Move to Folder, Favorite, Trash +- Selection count display +- No text selection during selection mode (select-none) + +## 4. Conversations + +### Auto-Detection +- Conversations automatically detected from meaningful speech chunks +- Provisional title generated every 3 chunks (LLM) +- AI summary generated when conversation ends +- Speaker-aware segments with color coding + +### Conversation Detail +- Title (auto-generated, updates as conversation progresses) +- Time range + duration (minimum 1 minute display) +- AI summary section +- Transcript section with speaker labels +- "View full transcript" expand toggle (for 8+ segments) +- Generate Note button (if no note linked) +- Go to Note button (if note exists) +- "Generating title..." spinner when title not yet available +- "Transcript deleted" indicator if transcript data was removed + +### Conversation Actions +- **Favorite** with star icon (visible in list) +- **Archive / Unarchive** via swipe +- **Trash** via swipe or multi-select +- **Export**: clipboard or email with conversation summary + transcript + linked AI note +- **Merge**: Combine 2-10 conversations into one with fresh AI summary +- **Generate Note**: Create AI note from conversation segments + +### Merge Feature +- Select 2-10 ended conversations +- Merge drawer shows titles of conversations being merged +- "Move originals to trash" checkbox (default: checked) +- Creates new conversation with: + - Combined chunks from all sources (chronologically sorted) + - Fresh AI-generated title and summary (merge-aware prompt) + - Positioned after latest source conversation in list +- Merged conversation highlighted with subtle red pulse for 4 seconds +- Merge button grayed out when < 2 selected + +## 5. Transcripts Tab + +### Transcript List +- Shows all available transcript dates with segment counts +- Today highlighted with live recording indicator +- "X days of transcripts" subtitle +- Multi-select for batch export/delete + +### Transcript Page (Per-Day View) +- Date header with back navigation +- Hour-by-hour collapsible sections +- Each hour shows: + - Hour label (9 AM, 2 PM, etc.) + - Conversation banner (linked summary title) + - Preview segments (first 2) with "+N more" expand + - Full expanded view with all segments + - Collapse button +- Compact mode: single-line per hour with expand affordance +- "Transcript deleted" full-page indicator if data was removed +- Export and email via action menu + +### Transcript Deletion +- Multi-select → Trash button +- Confirmation drawer with warning if conversations exist on those dates +- Permanently deletes from R2 + MongoDB (not recoverable) +- Removed from available dates list +- "Transcript deleted" shown on conversation detail and transcript pages + +## 6. Search + +### Semantic Search +- Search across notes and conversations +- Jina integration for semantic relevance +- Results grouped by type (Notes, Conversations) +- Score-based ranking + +### Search UX +- Debounced input (400ms) +- Minimum 2-second loading state + actual query time +- Abort controller for canceling stale queries +- Recent searches in localStorage +- Filter pills: All, Notes, Conversations +- "Nothing found" empty state with suggestions + +## 7. Export & Sharing + +### Clipboard Export +- Notes: content + metadata (date, type, from conversation) +- Conversations: summary + transcript segments (timestamped) + linked AI note +- Transcripts: date headers + timestamped segments per day + +### Email Export +- Rich HTML emails via Resend +- Note cards with type badges (AI Generated / Manual / Conversation / Transcript) +- Download buttons (PDF, TXT, Word) with signed URLs +- Session info header (date, time range) +- Responsive layout (620px desktop, full-width mobile) +- CC support with "Remember CC" checkbox +- Conversation email: includes summary, transcript table, linked notes +- Transcript email: date-separated cards with segment tables + +### Export Drawer +- Content toggle (always on, not disableable) +- Linked Conversation toggle (notes) → sub-toggle for Conversation Transcript +- Linked Transcript toggle (conversations) +- Linked AI Note toggle (conversations) +- Warning for items without linked data +- Destination: Clipboard or Email + +## 8. Settings + +### User Profile +- Display name, role, company (from onboarding) +- Avatar from Supabase storage (falls back to initials) + +### Recording +- Persistent transcription toggle + +### Onboarding +- Reset onboarding option (restarts tutorial) + +### Timezone +- Auto-detected from glasses +- Displayed in settings + +### Mic Status Indicator +- Compact circle next to FAB button +- Red pulsing dot = mic on +- Gray muted-mic icon = mic off +- Shown on both HomePage and NotesPage + +## 9. Onboarding (9 Steps) + +1. Welcome screen +2. About You (name, role, company) +3. Priorities selection +4. Contacts & Topics input +5. Tutorial: Always-On recording +6. Tutorial: AI Does the Work +7. Tutorial: Stay Organized +8. Tutorial: Swipe to Manage +9. You're All Set (completion) + +- Feature flag controlled (`FRONTEND_ONBOARD`) +- Won't re-trigger on reconnect (ref-guarded) +- Settings persisted to MongoDB + +## 10. Glasses Integration (MentraOS) + +### Connection +- Auto-detect glasses connection +- Full mode when glasses connected (mic + display) +- Timezone extraction from glasses settings + +### Display Modes +- Live transcript on glasses +- Hour summaries +- Key points (future) + +### Input +- Button press listeners +- Touch input handling +- Photo capture trigger + +## 11. Real-Time Sync + +### Architecture +- Custom sync library (`@synced` decorator) +- WebSocket connection per user +- RPC pattern: frontend calls backend methods +- Automatic state sync on property changes +- Snapshot on connect, incremental updates after +- Reconnect handling with visibility change detection +- Multi-tab support (multiple clients per session) + +### Session Management +- One session per user (shared across tabs/devices) +- 11+ managers per session +- Automatic hydration from MongoDB on session create +- Graceful cleanup on disconnect +- Memory logging on create/destroy diff --git a/issues/18-release-notes-v3/OVERVIEW.md b/issues/18-release-notes-v3/OVERVIEW.md new file mode 100644 index 0000000..7674fe2 --- /dev/null +++ b/issues/18-release-notes-v3/OVERVIEW.md @@ -0,0 +1,76 @@ +# Mentra Notes v3.0.0 — Release Overview + +## What is Mentra Notes? + +Mentra Notes is an always-on transcription and AI-powered note-taking application for MentraOS smart glasses. It continuously listens through the glasses microphone, automatically detects conversations, generates structured notes, and organizes everything by day — all without the user lifting a finger. + +## What's New in v3 + +### Auto-Conversation Detection +The app now automatically detects when a real conversation is happening vs. background noise. It uses a 3-stage pipeline — **Buffer → Triage → Track** — to classify audio chunks, group them into conversations, and trigger AI note generation when a conversation ends. No manual "start recording" button needed. + +### Multi-Select & Batch Operations +Long-press any conversation, note, or transcript to enter selection mode. Select multiple items and perform batch actions: +- **Export** to clipboard or email +- **Merge** conversations into one (conversations only) +- **Favorite** / **Trash** in bulk +- **Move** notes to folders + +### Conversation Merging +Select 2-10 conversations and merge them into a single conversation with a fresh AI-generated summary. Useful when the auto-detector splits what should be one continuous discussion. Option to trash the originals after merge. + +### Export & Email Sharing +Export notes, conversations, or transcripts via: +- **Clipboard** — formatted plain text with metadata +- **Email** — rich HTML email with styled cards, timestamps, and download links (PDF/TXT/Word) + +Export options are contextual: +- Notes: include linked conversation + conversation transcript +- Conversations: include summary, linked transcript, linked AI note +- Transcripts: include full segment-by-segment content + +### Redesigned Settings Page +Warm stone design matching the rest of the app. Shows user profile (name, role, company, avatar) at top, followed by recording settings, preferences, and onboarding reset. + +### Redesigned Email Templates +Both notes and transcript email templates updated to match the warm stone design: +- Light beige background (#FAFAF9) +- Stone-colored typography and borders +- Responsive layout (full-width on mobile, 620px centered on desktop) +- Note type badges: AI Generated (red), Manual (gray), Conversation (gray), Transcript (gray) + +### Transcript Deletion with Warnings +Deleting transcripts now permanently removes data from both MongoDB and Cloudflare R2. If conversations exist on those dates, a warning shows how many will lose their linked transcript. Deleted transcripts show a clear "Transcript deleted" indicator on both the conversation detail and transcript pages. + +### Conversation End Detection Improvements +- Requires 2 consecutive filler chunks before pausing (was 1) — "uh-huh" alone won't pause +- Requires 7 silence chunks to end (was 4) — ~35 seconds instead of ~20 +- Topic change goes through PENDING confirmation (needs 3 chunks, not instant) +- Resumption classifier is more lenient — only splits on completely unrelated subjects +- Background audio detection improved (TV, radio, podcasts classified as filler) + +### Memory Leak Fixes +- S3Client singleton pattern (was creating new client per R2 operation) +- ConversationManager segment cache cleared on session destroy +- All manager destroy() methods audited and fixed +- Memory logging on session create/destroy for monitoring + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Runtime | Bun | +| Server | Hono.js | +| Frontend | React 19 + Tailwind CSS 4 | +| Editor | TipTap | +| Animations | Framer Motion | +| Routing | Wouter | +| Database | MongoDB + Mongoose | +| Object Storage | Cloudflare R2 | +| AI/LLM | Gemini 2.5 Flash (primary), Claude, OpenAI | +| Email | Resend | +| Auth | MentraOS SDK | +| Search | Jina (semantic) | +| Analytics | PostHog | +| Sync | Custom WebSocket + RPC library | +| Deployment | Docker via Porter | diff --git a/issues/19-componentize-frontend/PLAN.md b/issues/19-componentize-frontend/PLAN.md new file mode 100644 index 0000000..beee5d6 --- /dev/null +++ b/issues/19-componentize-frontend/PLAN.md @@ -0,0 +1,97 @@ +# Issue 19: Componentize Frontend — Extract SVGs & Split Large Components + +Pure frontend cleanup. No backend changes. No logic changes. Just moving code into proper components. + +--- + +## Part 1: Shared Icon Library + +Create `/src/frontend/components/icons/` with reusable icon components. Each icon takes `size` (default 20) and `className` props. + +### Icons to extract (6 patterns, 33+ duplicates): + +| Icon | Occurrences | Files | +|------|-------------|-------| +| **StarIcon** | 7 | ConversationFilterDrawer, NotesFilterDrawer, NotePage, CollectionsPage, NotesPage, TutorialAINotes, ConversationDetailPage | +| **CloseIcon** (X) | 8+ | ExportDrawer, HomePage (×3), SearchPage, SkipDialog (×2), ContactsStep | +| **MicrophoneIcon** | 10 | FABMenu (×2), TranscriptList (×3), HomePage, SearchPage, CollectionsPage, NotesPage (×2) | +| **NoteIcon** (document) | 6 | NotesFilterDrawer, FABMenu, NotesPage, NotesFABMenu, WelcomeStep, ConversationDetailPage | +| **FolderIcon** | 5 | MultiSelectBar, FolderPicker, FolderPage, NotesFABMenu | +| **ArchiveIcon** / **TrashIcon** | 4+ | CollectionsPage, TutorialSwipe | + +### Steps: +1. Create `components/icons/index.ts` barrel export +2. Create each icon component (StarIcon.tsx, CloseIcon.tsx, etc.) +3. Find-and-replace inline SVGs file by file — verify visually after each file + +--- + +## Part 2: Split Large Components + +### P0 — HomePage.tsx (1,469 lines → ~400 lines) + +The biggest offender. Has 3 view modes, multi-select, export, and 12+ inline SVGs all in one file. + +**Extract:** +- `components/ConversationListView.tsx` — conversation list with filters, sorting, date grouping +- `components/TranscriptListView.tsx` — transcript list with its own filters +- `components/HomeToolbar.tsx` — multi-select action bar (export, merge, favorite, delete) +- `components/HomeHeader.tsx` — top bar with search, filter toggle, view switcher + +HomePage becomes an orchestrator: manages view state, passes handlers down. + +### P1 — NotesPage.tsx (887 lines → ~400 lines) + +**Extract:** +- `components/NoteFilterPanel.tsx` — show filter, pill filter, search input +- `components/FilteredNotesList.tsx` — renders filtered/sorted note rows +- `components/NotesToolbar.tsx` — multi-select bar (export, move to folder, delete) +- `hooks/useNoteFilters.ts` — filter state logic as a custom hook + +### P1 — TranscriptTab.tsx (816 lines → ~400 lines) + +**Extract:** +- `components/HourSection.tsx` — collapsible hour group with sticky header +- `components/TranscriptBanner.tsx` — interim text vs summary vs preview rendering +- `hooks/useHourGrouping.ts` — segment grouping + collapse state logic + +### P2 — NotePage.tsx (782 lines → ~400 lines) + +**Extract:** +- `components/NoteEditor.tsx` — TipTap editor with content +- `components/EditorToolbar.tsx` — formatting toolbar (bold, italic, lists, etc.) +- `components/NoteHeader.tsx` — title, timestamps, favorite/share/delete actions + +### P2 — SearchPage.tsx (504 lines) + +**Extract:** +- `components/SearchResultsList.tsx` — result rendering +- `components/RecentSearches.tsx` — recent search chips/list + +### P3 — ConversationDetailPage.tsx (478 lines) + +**Extract:** +- `components/ConversationHeader.tsx` — title, actions bar +- `components/ConversationActions.tsx` — favorite, delete, export actions + +### P3 — ConversationsTab.tsx (491 lines) + +**Extract:** +- `components/ConversationCard.tsx` — individual conversation card with expand/collapse + +--- + +## Part 3: Onboarding SVG Cleanup + +The onboarding components (WelcomeStep, TutorialAlwaysOn, TutorialAINotes, TutorialOrganize, TutorialSwipe, TutorialComplete, ContactsStep, SkipDialog) have ~40 inline SVGs total. + +**Approach:** These are mostly unique illustrations, not shared icons. Extract only the ones that overlap with the shared icon library (star, close, mic, note icons). Leave tutorial-specific decorative SVGs inline — they're used once and extracting them adds indirection without reducing duplication. + +--- + +## Rules + +- No logic changes. No backend changes. Behavior stays identical. +- Follow existing architecture: page-specific components go in that page's `components/` folder. Only truly shared icons go in `components/icons/`. +- Verify visually after each extraction — no broken icons or missing props. +- Do Part 1 (icons) first since it touches many files. Then split components top-down by priority. diff --git a/issues/2-interim-fallback/PLAN.md b/issues/2-interim-fallback/PLAN.md new file mode 100644 index 0000000..8c3f325 --- /dev/null +++ b/issues/2-interim-fallback/PLAN.md @@ -0,0 +1,213 @@ +# Interim Transcript Fallback — Implementation Plan + +Owner: Aryan +Priority: Medium — Reliability fix for poor microphone scenarios + +--- + +## Problem + +The chunk buffer only accepts **final** transcript segments (`NotesSession.ts:162`). If the speech recognition engine struggles (bad mic, background noise, accent issues), it may produce a stream of interim results that never finalize. In this case: + +1. `markSpeaking(true)` keeps firing (interims flowing) +2. `addText()` is never called (no finals arrive) +3. Heartbeat sees empty buffer + `isSpeaking = true` → skips silently +4. **Result: Speech is lost. No chunks, no notes, no error.** + +--- + +## Solution: Interim Timeout Fallback + +If interim transcripts have been flowing for N seconds without a single final arriving, take the **latest interim text** and promote it to a final — feeding it into the chunk buffer. + +This keeps finals as the primary (accurate) source while preventing silent data loss. + +--- + +## Files to Modify + +``` +src/backend/session/NotesSession.ts — track interim state, add timeout logic +src/backend/services/auto-notes/config.ts — add INTERIM_FALLBACK_TIMEOUT_MS parameter +``` + +No new files needed. + +--- + +## Implementation Steps + +### Step 1 — Add Config Parameter + +In `config.ts`, add: + +``` +INTERIM_FALLBACK_TIMEOUT_MS: 15_000 // 15 seconds of interims with no final → promote latest interim +``` + +Why 15 seconds: Most speech recognition engines finalize within 5-10 seconds. 15s gives plenty of room for slow finalization while still catching the "never finalizes" case before the user loses a meaningful amount of speech. + +### Step 2 — Track Interim State in NotesSession + +Add to `NotesSession`: + +- `private _lastInterimText: string = ""` — stores the most recent interim transcript text +- `private _lastInterimTime: number = 0` — timestamp of when interims started flowing (without a final in between) +- `private _interimFallbackTimer: ReturnType | null = null` + +### Step 3 — Modify `onTranscription()` in NotesSession + +Current logic: +```ts +onTranscription(text: string, isFinal: boolean, speakerId?: string): void { + this.transcript.addSegment(text, isFinal, speakerId); + this.chunkBuffer.markSpeaking(!isFinal); + if (isFinal && text.trim()) { + this.chunkBuffer.addText(text); + } +} +``` + +New logic: +```ts +onTranscription(text: string, isFinal: boolean, speakerId?: string): void { + this.transcript.addSegment(text, isFinal, speakerId); + this.chunkBuffer.markSpeaking(!isFinal); + + if (isFinal && text.trim()) { + // Got a real final — feed it to chunk buffer and reset interim tracking + this.chunkBuffer.addText(text); + this.clearInterimFallback(); + } else if (!isFinal && text.trim()) { + // Interim result — track it and start fallback timer if not already running + this._lastInterimText = text; + this.startInterimFallbackIfNeeded(); + } +} +``` + +### Step 4 — Implement Fallback Timer Methods + +```ts +private startInterimFallbackIfNeeded(): void { + // Timer already running — just update the stored text (done in onTranscription) + if (this._interimFallbackTimer) return; + + this._interimFallbackTimer = setTimeout(() => { + this.promoteInterim(); + }, AUTO_NOTES_CONFIG.INTERIM_FALLBACK_TIMEOUT_MS); +} + +private promoteInterim(): void { + if (this._lastInterimText.trim()) { + console.warn( + `[NotesSession] No final transcript in ${AUTO_NOTES_CONFIG.INTERIM_FALLBACK_TIMEOUT_MS}ms — promoting interim: "${this._lastInterimText.substring(0, 50)}..."` + ); + this.chunkBuffer.addText(this._lastInterimText); + } + this.clearInterimFallback(); +} + +private clearInterimFallback(): void { + if (this._interimFallbackTimer) { + clearTimeout(this._interimFallbackTimer); + this._interimFallbackTimer = null; + } + this._lastInterimText = ""; +} +``` + +### Step 5 — Clean Up on Disconnect + +In `clearAppSession()`, add: +```ts +this.clearInterimFallback(); +``` + +This prevents a stale timer from firing after glasses disconnect. + +--- + +## Edge Cases + +### 1. Interim text keeps changing — which version do we use? +We always store the **latest** interim (`_lastInterimText` gets overwritten on each interim event). By the time the 15s timer fires, we have the most refined version the speech engine produced. This is the best guess available. + +### 2. Final arrives just before the timer fires +`clearInterimFallback()` is called on every final, which cancels the timer. No double-feeding occurs. The final takes priority as intended. + +### 3. Rapid alternating interim → final → interim → final +Normal behavior. Each final cancels the timer, each new interim-only streak restarts it. The fallback only triggers during a sustained 15-second gap with no finals. + +### 4. Interim text overlaps with a late-arriving final +Speech recognition engines replace interim text with the final. If a final arrives for the same utterance after we already promoted the interim, we'd get **duplicate text** in the buffer. + +**Mitigation:** After promoting an interim, set a short "cooldown" flag. If a final arrives within 2 seconds of a promotion and its text substantially overlaps with the promoted text (e.g., >70% word overlap), skip that final to avoid duplication. + +Add to config: +``` +INTERIM_PROMOTION_COOLDOWN_MS: 2_000 +``` + +Implementation: +```ts +private _lastPromotionTime: number = 0; +private _lastPromotedText: string = ""; + +// In the final handling path: +if (isFinal && text.trim()) { + if (this.shouldSkipDuplicateFinal(text)) { + console.log(`[NotesSession] Skipping duplicate final after interim promotion`); + this.clearInterimFallback(); + return; // skip addText, still clear the timer + } + this.chunkBuffer.addText(text); + this.clearInterimFallback(); +} + +private shouldSkipDuplicateFinal(finalText: string): boolean { + if (!this._lastPromotedText) return false; + const elapsed = Date.now() - this._lastPromotionTime; + if (elapsed > AUTO_NOTES_CONFIG.INTERIM_PROMOTION_COOLDOWN_MS) return false; + + // Simple word overlap check + const promotedWords = new Set(this._lastPromotedText.toLowerCase().split(/\s+/)); + const finalWords = finalText.toLowerCase().split(/\s+/); + const overlap = finalWords.filter(w => promotedWords.has(w)).length; + return overlap / finalWords.length > 0.7; +} +``` + +### 5. User goes silent (stops speaking) — interims stop, timer is still running +If the user stops speaking, interims stop flowing and `markSpeaking(false)` is called (from the last final or silence detection). The timer may still fire with stale interim text. + +**Mitigation:** In `promoteInterim()`, check `this.chunkBuffer._isSpeaking`. If false (no speech activity), skip promotion — the user already stopped talking and the text was likely already captured via finals or is truly silence. + +### 6. Very short interims (single words like "um", "uh") +These would be promoted if 15 seconds pass. This is acceptable — the triage classifier downstream already filters chunks under 4 words as `auto-skipped`. No special handling needed. + +### 7. Session dispose while timer is pending +Add to `dispose()`: +```ts +this.clearInterimFallback(); +``` + +--- + +## Testing Checklist + +- [ ] Normal mic: finals flow normally, fallback timer never fires +- [ ] Simulate bad mic: send only interims for 20s → verify interim gets promoted at 15s +- [ ] Send interims for 14s then a final → verify timer cancels, final is used +- [ ] Send interims, promote happens, then late final arrives → verify dedup works +- [ ] Disconnect glasses while timer is running → verify timer is cleaned up +- [ ] Session dispose while timer is running → verify no errors + +--- + +## Config Summary + +| Parameter | Value | Purpose | +|-----------|-------|---------| +| `INTERIM_FALLBACK_TIMEOUT_MS` | 15,000 ms | How long to wait for a final before promoting interim | +| `INTERIM_PROMOTION_COOLDOWN_MS` | 2,000 ms | Window after promotion where duplicate finals are skipped | diff --git a/issues/3-semantic-search/PLAN.md b/issues/3-semantic-search/PLAN.md new file mode 100644 index 0000000..887edd0 --- /dev/null +++ b/issues/3-semantic-search/PLAN.md @@ -0,0 +1,375 @@ +# Semantic Search — Phase 1 & Phase 3 Implementation Plan + +## Overview + +Implement semantic search over notes and conversations (Phase 1), then add AI quick answers synthesized from search results (Phase 3). Phase 2 (transcript ingestion from R2) and Phase 4 (conversational AI) are out of scope. + +--- + +## Phase 1: Semantic Search over Notes & Conversations + +### Task 1: Embedding Service + +**New file:** `src/backend/services/embedding.service.ts` + +Create a thin wrapper around OpenAI's embeddings API (already in `package.json` as `openai`). Use `text-embedding-3-small` — cheap, fast, 1536 dimensions, good enough for this use case. + +```ts +// Exports: +export async function generateEmbedding(text: string): Promise +export async function generateEmbeddings(texts: string[]): Promise +``` + +- Use the existing `OPENAI_API_KEY` env var. +- For notes: strip HTML tags from content, then concatenate `title + " " + stripped_content`. +- For conversations: concatenate `title + " " + aiSummary` (only embed conversations that have an `aiSummary`). +- Keep it simple — no batching beyond what OpenAI supports in a single call. + +--- + +### Task 2: Add `embedding` Field to Note & Conversation Models + +**Files:** +- `src/backend/models/note.model.ts` +- `src/backend/models/conversation.model.ts` + +Add to both schemas: + +```ts +embedding: { type: [Number], default: [] } +``` + +Add to both interfaces: + +```ts +embedding: number[]; +``` + +No migration needed — existing docs will just have `embedding: []` (empty), and won't show up in vector search results until they get backfilled. + +--- + +### Task 3: Generate Embeddings on Create/Update + +**Files:** +- `src/backend/models/note.model.ts` — update `createNote()` and `updateNote()` helpers +- `src/backend/models/conversation.model.ts` — update `createConversation()` and `updateConversation()` + +For notes: +- In `createNote()`: after creation, strip HTML from content, generate embedding from `title + " " + stripped_content`, and save it. +- In `updateNote()`: if `title` or `content` changed, strip HTML, regenerate embedding. +- HTML stripping: simple regex (`text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()`) is sufficient — no need for a DOM parser. + +For conversations: +- In `updateConversation()`: if `aiSummary` is being set (conversation ended), generate embedding from `title + " " + aiSummary` and save it. +- **Only embed conversations that have an `aiSummary`.** Active/paused conversations without `aiSummary` are skipped entirely — no embedding, won't appear in search. +- Backfill script also skips conversations where `aiSummary` is empty. + +Fire-and-forget pattern: don't block the response on embedding generation. Use `.then()` / `.catch()` to update async. + +--- + +### Task 4: Create MongoDB Atlas Vector Search Indexes + +Two indexes needed (created via Atlas UI or CLI, not in app code): + +**Index 1 — notes collection:** +```json +{ + "type": "vectorSearch", + "definition": { + "fields": [ + { + "type": "vector", + "path": "embedding", + "numDimensions": 1536, + "similarity": "cosine" + }, + { + "type": "filter", + "path": "userId" + } + ] + } +} +``` + +**Index 2 — conversations collection:** +Same structure, same field paths. + +Index names: `notes_vector_index`, `conversations_vector_index`. + +Add a script or document the Atlas CLI commands to create these indexes. Consider adding a setup script at `src/scripts/create-vector-indexes.ts`. + +--- + +### Task 5: Search Service + +**New file:** `src/backend/services/search.service.ts` + +```ts +interface SearchResult { + id: string; + type: "note" | "conversation"; + title: string; + summary: string; + date: string; + score: number; + content?: string; // For notes +} + +export async function semanticSearch( + userId: string, + query: string, + limit?: number, +): Promise +``` + +Implementation: +1. Generate embedding for the query using `generateEmbedding()`. +2. Run two `$vectorSearch` aggregation pipelines in parallel (one per collection): + ```ts + collection.aggregate([ + { + $vectorSearch: { + index: "notes_vector_index", + path: "embedding", + queryVector: queryEmbedding, + numCandidates: 50, + limit: limit || 10, + filter: { userId }, + }, + }, + { + $project: { + title: 1, + summary: 1, + content: 1, + date: 1, + score: { $meta: "vectorSearchScore" }, + }, + }, + ]); + ``` +3. Merge results from both collections, sort by score descending, return top N. + +--- + +### Task 6: Search API Endpoint + +**File:** `src/backend/api/router.ts` + +Add a new authenticated endpoint: + +``` +GET /api/search?q=&limit= +``` + +- Requires `authMiddleware`. +- Calls `semanticSearch(userId, query, limit)`. +- Returns `{ results: SearchResult[] }`. + +--- + +### Task 7: Backfill Script + +**New file:** `src/scripts/backfill-embeddings.ts` + +A one-time script to generate embeddings for all existing notes and conversations that have `embedding: []`. + +- Process in batches of 20 (OpenAI embeddings API supports batch input). +- Log progress. +- Run with `bun run src/scripts/backfill-embeddings.ts`. + +--- + +### Task 8: Search Page (Frontend) + +**New file:** `src/frontend/pages/search/SearchPage.tsx` + +Layout: +- Search bar at top (shadcn `Input` with search icon). +- Results list below, each result is a clickable card showing: + - Type badge ("Note" or "Conversation") + - Title + - Summary (truncated) + - Date + - Relevance score (optional, subtle) +- Empty state when no query / no results. +- Loading state while searching. + +Use existing patterns: +- `useSynced` for auth context / userId. +- `fetch()` to call `/api/search?q=...`. +- shadcn `Input`, `Badge`, `Card` components. +- `motion` for list animations (consistent with other pages). + +--- + +### Task 9: Add Search to Navigation + +**File:** `src/frontend/components/layout/Shell.tsx` + +Add a search icon (`Search` from lucide-react) to the bottom nav: +- **Mobile:** Add as 4th item in bottom bar (between Home and Settings, or as a dedicated icon). +- **Desktop:** Add to the sidebar icon list. + +Route: `/search`. + +**File:** `src/frontend/router.tsx` + +Add route: +```tsx + +``` + +--- + +### Task 10: Click-Through from Search Results + +When a user clicks a search result: +- **Note result:** Navigate to `/note/:id` (existing route). +- **Conversation result:** Navigate to `/day/:date?tab=conversations&conversationId=`. The DayPage reads the query params, switches to the Conversations tab, scrolls to the matching conversation, and auto-expands it. + +**DayPage changes needed:** +- Read `tab` and `conversationId` from URL query params on mount. +- If `tab=conversations`, set the active tab to Conversations. +- If `conversationId` is present, scroll that conversation into view and expand/open it. +- Use `scrollIntoView({ behavior: "smooth" })` after the conversation list renders. + +**SearchResult type update:** +- Conversation results must include the `conversationId` so the frontend can build the URL. + +--- + +## Phase 3: AI Quick Answers + +### Task 11: Answer Generation Service + +**New file:** `src/backend/services/answer.service.ts` + +```ts +export async function generateAnswer( + query: string, + searchResults: SearchResult[], +): Promise +``` + +Implementation: +- Take the top 5 search results. +- Build a prompt with the search results as context. +- Call the existing LLM provider (`createProviderFromEnv()`) with tier `"fast"`. +- System prompt: "You are a helpful assistant. Answer the user's question based ONLY on the provided context. If the context doesn't contain enough information, say so. Be concise — 1-3 sentences." +- Return the generated answer string. + +--- + +### Task 12: Add AI Answer to Search Endpoint + +**File:** `src/backend/api/router.ts` + +Extend the search endpoint with an optional `ai=true` query param: + +``` +GET /api/search?q=&limit=10&ai=true +``` + +When `ai=true`: +1. Run semantic search as before. +2. Pass results to `generateAnswer()`. +3. Return `{ answer: string, results: SearchResult[] }`. + +This keeps the AI answer opt-in so the base search stays fast. + +--- + +### Task 13: AI Answer UI + +**File:** `src/frontend/pages/search/SearchPage.tsx` + +Add an AI answer section above the results list: +- Shows a card with the AI-generated answer. +- Has a subtle "AI Answer" label. +- Appears with a fade-in animation after search results load. +- Loading state: skeleton or shimmer while generating. +- Toggle or auto-enabled — start with always-on when results exist. + +--- + +## Implementation Order + +| # | Task | Phase | Depends On | +|----|------|-------|------------| +| 1 | Embedding service | 1 | — | +| 2 | Add embedding field to models | 1 | — | +| 3 | Generate embeddings on create/update | 1 | 1, 2 | +| 4 | Create vector search indexes | 1 | 2 | +| 5 | Search service | 1 | 1, 4 | +| 6 | Search API endpoint | 1 | 5 | +| 7 | Backfill script | 1 | 1, 2 | +| 8 | Search page (frontend) | 1 | 6 | +| 9 | Add search to nav | 1 | 8 | +| 10 | Click-through from results | 1 | 8 | +| 11 | Answer generation service | 3 | 5 | +| 12 | Add AI answer to endpoint | 3 | 6, 11 | +| 13 | AI answer UI | 3 | 8, 12 | + +Tasks 1, 2, and 4 can be done in parallel. Tasks 8, 9, 10 can be done in parallel once 6 is done. Phase 3 tasks (11-13) are sequential and depend on Phase 1 being complete. + +--- + +## Edge Cases & Type Safety + +### Types that must NOT change +- `Note` in `src/shared/types.ts` — shared with frontend, no `embedding` field. Leave it alone. +- `NoteData` in `NotesManager.ts` — same, frontend-facing. No `embedding`. +- `Conversation` in `src/shared/types.ts` — frontend type, no `embedding`. +- `ConversationManagerI` in `src/shared/types.ts` — no changes. + +### Types that DO change (backend only) +- `NoteI` in `note.model.ts` — add `embedding: number[]`. +- `ConversationI` in `conversation.model.ts` — add `embedding: number[]`. +- These are Mongoose interfaces, never sent to the frontend directly. `NotesManager.hydrate()` already cherry-picks fields into `NoteData`, so the `embedding` field is naturally excluded. + +### `updateNote()` model helper +- Current signature only accepts `title`, `content`, `summary`, `isStarred`. It does NOT accept `embedding`. +- **Do NOT widen that function** — it's used by the frontend-facing RPC and we don't want embeddings passed through there. +- Instead, embedding updates use a separate direct Mongoose call: `Note.updateOne({ _id }, { $set: { embedding } })`. Keep embedding logic isolated in the embedding service or a dedicated helper. + +### `$project` in vector search must exclude `embedding` +- The `$project` stage in the `$vectorSearch` pipeline must NOT return the `embedding` field. Embeddings are 1536 floats — sending them over the wire to the frontend would be wasteful and leak internal data. + +### HTML stripping edge cases +- Notes can contain `` tags with photo URLs. Strip those too — they're not searchable text. +- Empty content after stripping (e.g., a note that's only photos) → embed just the title. If title is also empty, skip embedding. +- `&`, `<`, etc. — decode HTML entities after stripping tags. + +### Conversations without `aiSummary` +- Active and paused conversations have no `aiSummary`. They are NOT embedded and will NOT appear in search results. This is intentional. +- The backfill script must filter: `{ aiSummary: { $ne: "" }, embedding: { $size: 0 } }` (or check for missing `embedding` field). + +### Empty/short content +- If a note's stripped text (title + content) is less than ~5 characters, skip embedding. It won't produce meaningful search results. +- Same for conversations — if `title + aiSummary` is trivially short, skip. + +### Embedding failures +- Fire-and-forget: if OpenAI embedding call fails (rate limit, network), the document just doesn't get an embedding. It won't appear in search. +- Log the error but don't throw — never block note creation/update on embedding failure. +- The backfill script can be re-run to catch any documents that were missed. + +### Search with zero results +- If both `$vectorSearch` queries return empty (user has no embedded content yet), return `{ results: [] }` — not an error. +- Frontend shows a "No results found" empty state. + +### Concurrent embedding updates +- If a user rapidly edits a note, multiple embedding calls could fire. Last-write-wins is fine — the final embedding will reflect the latest content. No locking needed. + +--- + +## Key Decisions + +- **Embedding model:** OpenAI `text-embedding-3-small` (1536 dims). Cheap ($0.02/1M tokens), fast, good quality. OpenAI SDK is already a dependency. +- **Inline embeddings:** Stored directly on note/conversation documents. Simple, no extra collections for Phase 1. +- **Fire-and-forget embedding generation:** Don't block CRUD operations on embedding calls. If embedding fails, document just won't appear in search until next update. +- **No external vector DB:** MongoDB Atlas Vector Search handles everything. No Pinecone/Weaviate dependency. +- **AI answers use existing LLM provider:** Reuses the `createProviderFromEnv()` infrastructure. No new LLM dependency. diff --git a/issues/4-share-feature/PLAN.md b/issues/4-share-feature/PLAN.md new file mode 100644 index 0000000..03c36c1 --- /dev/null +++ b/issues/4-share-feature/PLAN.md @@ -0,0 +1,767 @@ +# Production Readiness Plan — Notes-Isaiah + +## Overview + +This plan addresses all issues found during the production audit of the diff against `origin/dev`. Issues are grouped into implementation tasks ordered by priority. + +--- + +## Task 1: Add auth middleware to email & download endpoints + +**Why:** Email send endpoints (`POST /email/send`, `POST /transcript/email`) and download endpoints (`GET /notes/:id/download/:format`, `GET /transcripts/:transcriptId/download/:format`) have NO authentication. Anyone can send emails through our Resend account or download any user's notes/transcripts. + +**File:** `src/backend/api/router.ts` + +### 1a. Add `authMiddleware` to email endpoints + +**Line 684** — Change: +```ts +api.post("/email/send", async (c) => { +``` +To: +```ts +api.post("/email/send", authMiddleware, async (c) => { +``` + +**Line 829** — Change: +```ts +api.post("/transcript/email", async (c) => { +``` +To: +```ts +api.post("/transcript/email", authMiddleware, async (c) => { +``` + +### 1b. Add signed token system for download URLs + +Download links are clicked from emails (no auth cookie), so we need a different approach: **HMAC-signed URLs**. + +**Create new file:** `src/backend/services/signedUrl.service.ts` + +```ts +import { createHmac } from "crypto"; + +const SECRET = process.env.COOKIE_SECRET || process.env.MENTRAOS_API_KEY || "fallback-secret"; + +// Token expires after 7 days +const EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Generate a signed token for a download URL. + * Format: {expiresAt_hex}.{hmac_hex} + */ +export function generateDownloadToken(resourceId: string): string { + const expiresAt = Date.now() + EXPIRY_MS; + const payload = `${resourceId}:${expiresAt}`; + const hmac = createHmac("sha256", SECRET).update(payload).digest("hex"); + return `${expiresAt.toString(16)}.${hmac}`; +} + +/** + * Verify a signed download token. + * Returns true if valid and not expired. + */ +export function verifyDownloadToken(resourceId: string, token: string): boolean { + try { + const [expiresHex, hmac] = token.split("."); + if (!expiresHex || !hmac) return false; + + const expiresAt = parseInt(expiresHex, 16); + if (Date.now() > expiresAt) return false; + + const payload = `${resourceId}:${expiresAt}`; + const expected = createHmac("sha256", SECRET).update(payload).digest("hex"); + return hmac === expected; + } catch { + return false; + } +} +``` + +**In `src/backend/services/resend.service.ts`:** + +Import and use `generateDownloadToken` when building download URLs: + +```ts +import { generateDownloadToken } from "./signedUrl.service"; +``` + +**Notes email** — download URLs are built entirely in `buildNoteCardHtml` (JS, no template placeholder), so just append the token: + +In `buildNoteCardHtml` (line 34), change: +```ts +const downloadBase = `${BASE_URL}/api/notes/${note.noteId}/download`; +``` +To: +```ts +const token = generateDownloadToken(note.noteId); +const downloadBase = `${BASE_URL}/api/notes/${note.noteId}/download`; +``` + +Then update the 3 download links in the return template to append `?token=${token}`: +``` +href="${downloadBase}/pdf?token=${token}" +href="${downloadBase}/txt?token=${token}" +href="${downloadBase}/docx?token=${token}" +``` + +**Transcript email** — download URLs use `{{downloadBase}}` as an HTML template placeholder in `transcript-email.html`. Since the token needs to be per-format URL, replace the single `{{downloadBase}}` with 3 separate placeholders in the HTML template: + +In `src/public/resend-email-template/transcript-email.html`, change the 3 download links from: +```html +PDF +... +TXT +... +Word +``` +To: +```html +PDF +... +TXT +... +Word +``` + +Then in `buildTranscriptEmailHtml` (line 180), replace: +```ts +const downloadBase = `${BASE_URL}/api/transcripts/${transcriptId}/download`; +``` +With: +```ts +const token = generateDownloadToken(transcriptId); +const downloadBase = `${BASE_URL}/api/transcripts/${transcriptId}/download`; +const downloadPdf = `${downloadBase}/pdf?token=${token}`; +const downloadTxt = `${downloadBase}/txt?token=${token}`; +const downloadDocx = `${downloadBase}/docx?token=${token}`; +``` + +And update the template replacements from: +```ts +.replaceAll("{{downloadBase}}", downloadBase) +``` +To: +```ts +.replaceAll("{{downloadPdf}}", downloadPdf) +.replaceAll("{{downloadTxt}}", downloadTxt) +.replaceAll("{{downloadDocx}}", downloadDocx) +``` + +**In `src/backend/api/router.ts` download endpoints:** + +Add token verification at the start of each download handler: + +For note download (line 720): +```ts +api.get("/notes/:id/download/:format", async (c) => { + try { + const noteId = c.req.param("id"); + const format = c.req.param("format"); + const token = c.req.query("token"); + + // Verify signed token + const { verifyDownloadToken } = await import("../services/signedUrl.service"); + if (!token || !verifyDownloadToken(noteId, token)) { + return c.json({ error: "Invalid or expired download link" }, 403); + } + // ... rest of handler +``` + +For transcript download (line 868): +```ts +api.get("/transcripts/:transcriptId/download/:format", async (c) => { + try { + const transcriptId = c.req.param("transcriptId"); + const format = c.req.param("format"); + const token = c.req.query("token"); + + const { verifyDownloadToken } = await import("../services/signedUrl.service"); + if (!token || !verifyDownloadToken(transcriptId, token)) { + return c.json({ error: "Invalid or expired download link" }, 403); + } + // ... rest of handler +``` + +### 1c. Scope note download session search to authenticated user + +**Line 732-740** in `router.ts` — The note download loops through ALL active sessions. Since we're using signed tokens (not auth), we keep the loop but this is acceptable because the token gates access. However, if we later add auth, scope to: +```ts +// Only check the user's own session, not all sessions +const session = sessions.get(userId); +if (session) { + const found = session.notes.notes.find((n: any) => n.id === noteId); + if (found) noteData = found; +} +``` + +For now with signed URLs, the current approach is acceptable. + +--- + +## Task 2: Derive BASE_URL from request + update env.example + +**Why:** The current `BASE_URL` defaults to a dev ngrok URL (`https://general.dev.tpa.ngrok.app`). The server can't auto-detect its public URL (it only sees `localhost:3000`), but we can derive it from the `Origin` or `Host` header of the incoming request when the email endpoint is called — the browser already knows the correct origin. + +**File:** `src/backend/services/resend.service.ts` + +Remove the module-level `BASE_URL` constant (line 7): +```ts +// DELETE THIS LINE: +const BASE_URL = process.env.BASE_URL || "https://general.dev.tpa.ngrok.app"; +``` + +Instead, **pass `baseUrl` as a parameter** to the email builder functions. Derive it in the router from the request. + +Update `buildNoteCardHtml` signature to accept `baseUrl`: +```ts +function buildNoteCardHtml(note: NoteItem, baseUrl: string): string { +``` + +Update `buildNotesEmailHtml` to accept and pass `baseUrl`: +```ts +function buildNotesEmailHtml({ + sessionDate, sessionStartTime, sessionEndTime, notes, baseUrl, +}: Omit & { baseUrl: string }) { + const noteCards = notes.map((n) => buildNoteCardHtml(n, baseUrl)).join("\n"); + // ... +} +``` + +Same for `sendNotesEmail`, `buildTranscriptEmailHtml`, `sendTranscriptEmail` — add `baseUrl` parameter. + +**File:** `src/backend/api/router.ts` + +Add a helper to extract the base URL from the request: +```ts +function getBaseUrl(c: any): string { + // Try Origin header first (set by browser on same-origin requests) + const origin = c.req.header("origin"); + if (origin) return origin; + + // Fallback to Host header + protocol + const host = c.req.header("host"); + const proto = c.req.header("x-forwarded-proto") || "https"; + if (host) return `${proto}://${host}`; + + // Last resort fallback + return process.env.BASE_URL || "https://localhost:3000"; +} +``` + +Then in each email endpoint, pass it through: +```ts +const baseUrl = getBaseUrl(c); +const result = await sendNotesEmail({ to, cc, sessionDate, sessionStartTime, sessionEndTime, notes, baseUrl }); +``` + +**File:** `env.example` + +`BASE_URL` is no longer required, but keep it as an optional override. Add the missing env vars: + +```env +# ============================================================================= +# Cloudflare R2 - For storing transcripts and photos +# ============================================================================= + +CLOUDFLARE_R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com +CLOUDFLARE_R2_BUCKET_NAME=mentra-notes +CLOUDFLARE_R2_ACCESS_KEY_ID=your_r2_access_key +CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_r2_secret_key + +# ============================================================================= +# Email (Resend) - For sending notes and transcripts via email +# ============================================================================= + +RESEND_API_KEY=re_your_resend_api_key + +# Optional: Override the base URL for download links in emails. +# If not set, automatically derived from the request Origin/Host header. +# BASE_URL=https://your-app-domain.com +``` + +--- + +## Task 3: Remove error details leak (all endpoints) + +**Why:** `details: String(err)` and `err.message` in error responses can leak internal stack traces, file paths, or API keys to the client. + +**File:** `src/backend/api/router.ts` + +**Line 709** — Remove `details`: +```ts +return c.json({ error: err.message || "Failed to send email", details: String(err) }, 500); +``` +To: +```ts +return c.json({ error: "Failed to send email" }, 500); +``` + +**Line 818** — Download endpoint: +```ts +return c.json({ error: err.message || "Failed to generate download" }, 500); +``` +To: +```ts +return c.json({ error: "Failed to generate download" }, 500); +``` + +**Line 860** — Remove `details`: +```ts +return c.json({ error: err.message || "Failed to send email", details: String(err) }, 500); +``` +To: +```ts +return c.json({ error: "Failed to send email" }, 500); +``` + +**Line 1009** — Download endpoint: +```ts +return c.json({ error: err.message || "Failed to generate download" }, 500); +``` +To: +```ts +return c.json({ error: "Failed to generate download" }, 500); +``` + +Keep the `console.error` calls — those log to server only. + +--- + +## Task 4: Validate & sanitize email inputs + +**Why:** The `notes` array from the request body is passed directly to `sendNotesEmail` without validation. Malformed input could inject arbitrary HTML into emails. Transcript `seg.text` is also interpolated raw into email HTML with no escaping. + +**File:** `src/backend/api/router.ts` + +### 4a. Validate notes array + +After line 693 (the `notes.length === 0` check), add: + +```ts +// Validate each note item +for (const note of notes) { + if (!note.noteId || !note.noteTitle || typeof note.noteContent !== "string") { + return c.json({ error: "Each note must have noteId, noteTitle, and noteContent" }, 400); + } +} +``` + +### 4b. Sanitize note content (proper HTML sanitization) + +Script-tag-only stripping is insufficient — email HTML injection can happen via ``, ``, ` {options.map((option, index) => { if (isDivider(option)) { - return ( -
- ); + return
; } - const Icon = option.icon; - return ( + + ))} + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (input.trim()) addEmail(input); }} + placeholder={emails.length === 0 ? placeholder : ""} + className="flex-1 min-w-30 bg-transparent text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none text-sm py-0.5" + /> +
+ ); +} + +// ── Drawer ─────────────────────────────────────────────────────────── + +interface EmailDrawerProps { + isOpen: boolean; + onClose: () => void; + onSend: (to: string, cc: string) => Promise; + defaultEmail: string; + itemLabel: string; +} + +export function EmailDrawer({ + isOpen, + onClose, + onSend, + defaultEmail, + itemLabel, +}: EmailDrawerProps) { + const [to, setTo] = useState(""); + const [ccEmails, setCcEmails] = useState([]); + const [showCc, setShowCc] = useState(false); + const [rememberCc, setRememberCc] = useState(false); + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!isOpen) return; + // To — always restore + setTo(loadString(STORAGE_KEY_TO, defaultEmail)); + // CC — only restore if "remember" is on + const remember = loadBool(STORAGE_KEY_REMEMBER_CC); + setRememberCc(remember); + if (remember) { + const stored = loadEmails(STORAGE_KEY_CC); + setCcEmails(stored); + setShowCc(stored.length > 0); + } else { + setCcEmails([]); + setShowCc(false); + } + setError(""); + setIsSending(false); + }, [isOpen, defaultEmail]); + + // Persist To + const handleToChange = (value: string) => { + setTo(value); + try { localStorage.setItem(STORAGE_KEY_TO, value.trim()); } catch {} + }; + + // Persist CC (only if remember is on) + const updateCcEmails = (emails: string[]) => { + setCcEmails(emails); + if (rememberCc) { + try { localStorage.setItem(STORAGE_KEY_CC, JSON.stringify(emails)); } catch {} + } + }; + + // Toggle remember + const toggleRememberCc = () => { + const next = !rememberCc; + setRememberCc(next); + try { localStorage.setItem(STORAGE_KEY_REMEMBER_CC, String(next)); } catch {} + if (next) { + // Save current CC emails + try { localStorage.setItem(STORAGE_KEY_CC, JSON.stringify(ccEmails)); } catch {} + } else { + // Clear stored CC + try { localStorage.removeItem(STORAGE_KEY_CC); } catch {} + } + }; + + const handleSend = async () => { + if (!to.trim()) { + setError("Email address is required"); + return; + } + if (!EMAIL_REGEX.test(to.trim())) { + setError("Please enter a valid email address"); + return; + } + setError(""); + setIsSending(true); + try { + await onSend(to.trim(), ccEmails.join(",")); + onClose(); + } catch (err: any) { + setError(err?.message || "Failed to send email"); + } finally { + setIsSending(false); + } + }; + + return ( + !open && onClose()}> + + + + {/* Handle */} +
+ + {/* Header */} +
+ + Send {itemLabel} + + + Enter email addresses to send {itemLabel} + +
+ + {/* Content */} +
+ {/* To */} + + + handleToChange(e.target.value)} + placeholder="email@example.com" + autoFocus + className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white transition-shadow" + /> + + + {/* CC */} + + {showCc ? ( + +
+
+ + +
+ + {/* Remember CC toggle */} + +
+
+ ) : ( + + + + )} +
+ + {/* Error */} + + {error && ( + + {error} + + )} + + + {/* Send */} + + {isSending ? ( + <> + + Sending... + + ) : ( + <> + + Send + + )} + +
+ + {/* Safe area */} +
+ + + + ); +} diff --git a/src/frontend/components/shared/ExportDrawer.tsx b/src/frontend/components/shared/ExportDrawer.tsx new file mode 100644 index 0000000..f42bc1d --- /dev/null +++ b/src/frontend/components/shared/ExportDrawer.tsx @@ -0,0 +1,293 @@ +/** + * ExportDrawer — Bottom sheet for exporting notes/conversations/transcripts + * + * Shows content toggles and export destination options (Clipboard, Email). + * Uses vaul Drawer under the hood. + */ + +import { useState, useCallback } from "react"; +import { Drawer } from "vaul"; + +export interface ExportOptions { + includeContent: boolean; + includeTranscript: boolean; + includeLinkedNote: boolean; + destination: "clipboard" | "email"; +} + +interface ExportDrawerProps { + isOpen: boolean; + onClose: () => void; + /** "note" | "conversation" | "transcript" */ + itemType: "note" | "conversation" | "transcript"; + /** Title of single item, or summary for batch */ + itemLabel: string; + /** Number of items being exported */ + count: number; + /** Callback when export is triggered */ + onExport: (options: ExportOptions) => Promise; + /** How many selected conversations have no linked AI note (shown as warning) */ + missingNoteCount?: number; +} + +export function ExportDrawer({ + isOpen, + onClose, + itemType, + itemLabel, + count, + onExport, + missingNoteCount = 0, +}: ExportDrawerProps) { + const includeContent = true; // Always included + const [includeTranscript, setIncludeTranscript] = useState(false); + const [includeLinkedNote, setIncludeLinkedNote] = useState(false); + const [destination, setDestination] = useState("clipboard"); + const [isExporting, setIsExporting] = useState(false); + + const typeLabel = itemType === "note" ? "Note" : itemType === "conversation" ? "Conversation" : "Transcript"; + const title = count === 1 ? `Export ${typeLabel}` : `Export ${count} ${typeLabel}s`; + + const contentToggleLabel = itemType === "note" ? "Note Content" : itemType === "conversation" ? "Conversation Summary" : "Transcript Content"; + const contentToggleDesc = itemType === "note" ? "Summary, decisions, and action items" : itemType === "conversation" ? "AI-generated summary" : "Full transcript text"; + + const handleExport = useCallback(async () => { + setIsExporting(true); + try { + await onExport({ includeContent, includeTranscript, includeLinkedNote, destination }); + onClose(); + } catch (err) { + console.error("Export failed:", err); + } finally { + setIsExporting(false); + } + }, [onExport, includeContent, includeTranscript, includeLinkedNote, destination, onClose]); + + return ( + !open && onClose()}> + + + + {/* Drag handle */} +
+
+
+ + {title} + Export options + +
+ {/* Header */} +
+ + {title} + + +
+ + {/* Subtitle */} +
+ {itemLabel} +
+ + {/* Section: Included in export */} +
+ Included in export +
+ + {/* Content — always included (non-toggleable) */} +
+
+ + {contentToggleLabel} + + + {contentToggleDesc} + +
+ {}} /> +
+ + {/* Notes: Linked Conversation toggle → sub-toggle for Conversation Transcript */} + {itemType === "note" && ( + <> +
+
+ + Linked Conversation + + + Conversation summary from this note + + {includeLinkedNote && missingNoteCount > 0 && ( + + {missingNoteCount} {missingNoteCount === 1 ? "note has" : "notes have"} no linked conversation + + )} +
+ +
+ {includeLinkedNote && ( +
+
+ + Conversation Transcript + + + Full conversation with speaker labels + +
+ +
+ )} + + )} + + {/* Conversations: Linked Transcript toggle + Linked AI Note toggle */} + {itemType === "conversation" && ( + <> +
+
+ + Linked Transcript + + + Full conversation with speaker labels + +
+ +
+
+
+ + Linked AI Note + + + AI-generated note from conversation + + {includeLinkedNote && missingNoteCount > 0 && ( + + {missingNoteCount} {missingNoteCount === 1 ? "conversation has" : "conversations have"} no AI note — will be skipped + + )} +
+ +
+ + )} + + {/* Section: Export to */} +
+ Export to +
+ +
+ } + label="Clipboard" + selected={destination === "clipboard"} + onClick={() => setDestination("clipboard")} + /> + } + label="Email" + selected={destination === "email"} + onClick={() => setDestination("email")} + /> +
+ + {/* Export button */} + +
+ + {/* Safe area */} +
+ + + + ); +} + +// ── Toggle Switch ── + +function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +// ── Destination Card ── + +function DestinationCard({ + icon, + label, + selected, + onClick, +}: { + icon: React.ReactNode; + label: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// ── Icons ── + +function ClipboardIcon({ selected }: { selected: boolean }) { + const color = selected ? "#1C1917" : "#78716C"; + return ( + + + + + ); +} + +function EmailIcon({ selected }: { selected: boolean }) { + const color = selected ? "#1C1917" : "#78716C"; + return ( + + + + + ); +} diff --git a/src/frontend/components/shared/LoadingState.tsx b/src/frontend/components/shared/LoadingState.tsx new file mode 100644 index 0000000..049d2b5 --- /dev/null +++ b/src/frontend/components/shared/LoadingState.tsx @@ -0,0 +1,116 @@ +/** + * LoadingState - Reusable loading animation with fun messages + * + * Combines a random DotSpinner variant with a witty loading message. + * Easy to drop in anywhere with simple props. + * + * @example + * // Random spinner + random message + * // Custom message + * // Bigger spinner + * // Custom message pool + * // Custom color + * // Force specific spinner + */ + +import { useState, useEffect } from "react"; +import { + DotBurstSpinner, + DotWaveSpinner, + DotGridSpinner, + DotSpiralSpinner, +} from "./DotBurstSpinner"; + + +const SPINNERS = [DotBurstSpinner, DotWaveSpinner, DotGridSpinner, DotSpiralSpinner]; +const SPINNER_KEYS = ["burst", "wave", "grid", "spiral"] as const; +type SpinnerVariant = (typeof SPINNER_KEYS)[number]; + +const DEFAULT_MESSAGES = [ + "Vibing with your data...", + "Consulting the oracles...", + "Rummaging through memories...", + "Connecting the dots...", + "Summoning the results...", + "Thinking really hard...", + "Unfolding the universe...", + "Warming up the neurons...", + "Asking the smart glasses...", + "Dusting off the archives...", + "Reading between the lines...", + "Brewing something good...", + "Doing the brain thing...", + "Almost there, probably...", + "Sifting through brilliance...", + "Wrangling the data gnomes...", + "Decoding your thoughts...", + "Channeling big brain energy...", + "Hold tight, magic in progress...", + "Marinating on it...", +]; + +function pickRandom(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +interface LoadingStateProps { + /** Fixed message. If omitted, picks a random fun message. */ + message?: string; + /** Pool of messages to randomly pick from. Defaults to built-in fun messages. */ + messages?: string[]; + /** Spinner size in pixels. Defaults to 80. */ + size?: number; + /** Dot color. Defaults to "#D94F3B". */ + color?: string; + /** Force a specific spinner variant. If omitted, picks randomly. */ + variant?: SpinnerVariant; + /** Additional CSS classes on the container. */ + className?: string; + /** Whether to cycle the message every few seconds. Defaults to true. */ + cycleMessages?: boolean; + /** How often to cycle messages (ms). Defaults to 3000. */ + cycleInterval?: number; +} + +export function LoadingState({ + message, + messages = DEFAULT_MESSAGES, + size = 80, + color = "#D94F3B", + variant, + className, + cycleMessages = true, + cycleInterval = 3000, +}: LoadingStateProps) { + const [currentMessage, setCurrentMessage] = useState( + message || pickRandom(messages), + ); + const [spinnerIdx] = useState(() => + variant ? SPINNER_KEYS.indexOf(variant) : Math.floor(Math.random() * SPINNERS.length), + ); + + // Cycle through messages + useEffect(() => { + if (message || !cycleMessages) return; // Fixed message, don't cycle + const id = setInterval(() => { + setCurrentMessage(pickRandom(messages)); + }, cycleInterval); + return () => clearInterval(id); + }, [message, messages, cycleMessages, cycleInterval]); + + // Update if fixed message changes + useEffect(() => { + if (message) setCurrentMessage(message); + }, [message]); + + const Spinner = SPINNERS[spinnerIdx]; + + return ( +
+ + + {currentMessage} + +
+ ); +} diff --git a/src/frontend/components/shared/MultiSelectBar.tsx b/src/frontend/components/shared/MultiSelectBar.tsx new file mode 100644 index 0000000..dfe10b3 --- /dev/null +++ b/src/frontend/components/shared/MultiSelectBar.tsx @@ -0,0 +1,100 @@ +/** + * MultiSelectBar — Fixed bottom action bar during multi-select mode + * + * Replaces the tab bar. Shows contextual actions (Export, Move, Favorite, Delete). + * Actions vary by context: notes get all 4, conversations get 3 (no Move), + * transcripts get 2 (Export + Delete only). + */ + +import { motion } from "motion/react"; +import type { ReactNode } from "react"; + +export interface MultiSelectAction { + icon: ReactNode; + label: string; + onClick: () => void; + variant?: "default" | "danger"; + disabled?: boolean; +} + +interface MultiSelectBarProps { + actions: MultiSelectAction[]; +} + +export function MultiSelectBar({ actions }: MultiSelectBarProps) { + return ( + + {actions.map((action) => ( + + ))} + + ); +} + +// ── Pre-built icon components for actions ── + +export function ExportIcon() { + return ( + + + + + + ); +} + +export function MoveIcon() { + return ( + + + + ); +} + +export function MergeIcon() { + return ( + + + + + + + ); +} + +export function FavoriteIcon() { + return ( + + + + ); +} + +export function DeleteIcon() { + return ( + + + + + ); +} diff --git a/src/frontend/components/shared/NotesFilterDrawer.tsx b/src/frontend/components/shared/NotesFilterDrawer.tsx new file mode 100644 index 0000000..f3a54cd --- /dev/null +++ b/src/frontend/components/shared/NotesFilterDrawer.tsx @@ -0,0 +1,189 @@ +/** + * NotesFilterDrawer - Filter & sort options for notes + * + * Renders inside BottomDrawer with: + * - Sort by (Most recent / Oldest first) + * - Show filters (All notes / Favourites / Archived / Trash) + */ + +import { useState, useEffect, type ReactNode } from "react"; +import { BottomDrawer } from "./BottomDrawer"; + +export type NoteSortBy = "recent" | "oldest"; +export type NoteShowFilter = "all" | "favourites" | "archived" | "trash"; + +export interface NoteFilters { + sortBy: NoteSortBy; + showFilter: NoteShowFilter; +} + +interface NotesFilterDrawerProps { + isOpen: boolean; + onClose: () => void; + sortBy: NoteSortBy; + showFilter: NoteShowFilter; + onApply: (filters: NoteFilters) => void; +} + +const SORT_OPTIONS: { value: NoteSortBy; label: string }[] = [ + { value: "recent", label: "Most recent" }, + { value: "oldest", label: "Oldest first" }, +]; + +const SHOW_OPTIONS: { value: NoteShowFilter; label: string; icon: ReactNode }[] = [ + { + value: "all", + label: "All notes", + icon: ( + + + + + ), + }, + { + value: "favourites", + label: "Favourites", + icon: ( + + + + ), + }, + { + value: "archived", + label: "Archived", + icon: ( + + + + + ), + }, + { + value: "trash", + label: "Trash", + icon: ( + + + + + + ), + }, +]; + +export function NotesFilterDrawer({ + isOpen, + onClose, + sortBy: initialSortBy, + showFilter: initialShowFilter, + onApply, +}: NotesFilterDrawerProps) { + const [sortBy, setSortBy] = useState(initialSortBy); + const [showFilter, setShowFilter] = useState(initialShowFilter); + const [sortOpen, setSortOpen] = useState(false); + + useEffect(() => { + if (isOpen) { + setSortBy(initialSortBy); + setShowFilter(initialShowFilter); + setSortOpen(false); + } + }, [isOpen]); + + const applySortBy = (value: NoteSortBy) => { + setSortBy(value); + setSortOpen(false); + onApply({ sortBy: value, showFilter }); + }; + + const applyShowFilter = (value: NoteShowFilter) => { + setShowFilter(value); + onApply({ sortBy, showFilter: value }); + onClose(); + }; + + return ( + + {/* Header */} +
+
+ Filter & sort +
+ +
+ + {/* Sort by */} +
+ + {sortOpen && ( +
+ {SORT_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ + {/* Show */} +
+ Show +
+ {SHOW_OPTIONS.map((option) => { + const isSelected = showFilter === option.value; + return ( + + ); + })} +
+ ); +} diff --git a/src/frontend/components/shared/QuickActionsDrawer.tsx b/src/frontend/components/shared/QuickActionsDrawer.tsx index 04c6641..122d4cd 100644 --- a/src/frontend/components/shared/QuickActionsDrawer.tsx +++ b/src/frontend/components/shared/QuickActionsDrawer.tsx @@ -24,11 +24,13 @@ import type { SessionI } from "../../../shared/types"; interface QuickActionsDrawerProps { isOpen: boolean; onClose: () => void; + dateString?: string; } export function QuickActionsDrawer({ isOpen, onClose, + dateString, }: QuickActionsDrawerProps) { const { userId } = useMentraAuth(); const { session } = useSynced(userId || ""); @@ -37,9 +39,21 @@ export function QuickActionsDrawer({ const [showTimeRangePicker, setShowTimeRangePicker] = useState(false); const [startTime, setStartTime] = useState(""); const [endTime, setEndTime] = useState(""); + const [error, setError] = useState(""); const generating = session?.notes?.generating ?? false; + // Determine label: "today" or formatted date like "Feb 27" + const dateLabel = (() => { + if (!dateString) return "today"; + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + if (dateString === todayStr) return "today"; + const [y, m, d] = dateString.split("-").map(Number); + const date = new Date(y, m - 1, d); + return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); + })(); + // Set default times to current hour when opening time picker useEffect(() => { if (showTimeRangePicker) { @@ -57,6 +71,7 @@ export function QuickActionsDrawer({ useEffect(() => { if (!isOpen) { setShowTimeRangePicker(false); + setError(""); } }, [isOpen]); @@ -74,26 +89,25 @@ export function QuickActionsDrawer({ const handleGenerateNote = async () => { if (!session?.notes?.generateNote) return; + setError(""); try { - const now = new Date(); const [startHour, startMin] = startTime.split(":").map(Number); const [endHour, endMin] = endTime.split(":").map(Number); - const startDate = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - startHour, - startMin, - ); - const endDate = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - endHour, - endMin, - ); + // Use the dateString if on a DayPage, otherwise default to today + let year: number, month: number, day: number; + if (dateString) { + [year, month, day] = dateString.split("-").map(Number); + } else { + const now = new Date(); + year = now.getFullYear(); + month = now.getMonth() + 1; + day = now.getDate(); + } + + const startDate = new Date(year, month - 1, day, startHour, startMin); + const endDate = new Date(year, month - 1, day, endHour, endMin); const note = await session.notes.generateNote( undefined, @@ -104,8 +118,14 @@ export function QuickActionsDrawer({ if (note?.id) { setLocation(`/note/${note.id}`); } - } catch (err) { + } catch (err: any) { console.error("[QuickActionsDrawer] Failed to generate note:", err); + const msg = err?.message || String(err); + if (msg.includes("No transcript content")) { + setError("No transcription available for this time period. Please select a different range."); + } else { + setError("Failed to generate note. Please try again."); + } } }; @@ -118,10 +138,13 @@ export function QuickActionsDrawer({
{/* Header */} -
+
{showTimeRangePicker ? "Generate Summary" : "Quick Actions"} + + {dateLabel === "today" ? `Today, ${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}` : dateLabel} + {showTimeRangePicker ? "Select a time range to generate a summary" @@ -177,10 +200,17 @@ export function QuickActionsDrawer({
+ {/* Error message */} + {error && ( +

+ {error} +

+ )} + {/* Actions */}
- Add notes for today + Add note Create a new blank note @@ -246,7 +276,7 @@ export function QuickActionsDrawer({
- Generate notes AI for today + Generate AI note AI summary from your transcript diff --git a/src/frontend/components/shared/SelectionHeader.tsx b/src/frontend/components/shared/SelectionHeader.tsx new file mode 100644 index 0000000..441004c --- /dev/null +++ b/src/frontend/components/shared/SelectionHeader.tsx @@ -0,0 +1,28 @@ +/** + * SelectionHeader — Replaces page header during multi-select mode + * + * Shows Cancel (red) | {n} selected (bold center) | Select All (right) + * Matches the warm stone design system. + */ + +interface SelectionHeaderProps { + count: number; + onCancel: () => void; + onSelectAll: () => void; +} + +export function SelectionHeader({ count, onCancel, onSelectAll }: SelectionHeaderProps) { + return ( +
+ + + {count} selected + + +
+ ); +} diff --git a/src/frontend/components/shared/SplashScreen.tsx b/src/frontend/components/shared/SplashScreen.tsx index abea35d..e1ed512 100644 --- a/src/frontend/components/shared/SplashScreen.tsx +++ b/src/frontend/components/shared/SplashScreen.tsx @@ -1,151 +1,56 @@ /** - * SplashScreen - Hand-drawn cursive "notes" animation - * - * Shows a full-screen light gradient with a hand-drawn SVG animation - * that writes "notes" in cursive, stroke by stroke. + * SplashScreen - Full-screen loading overlay with random dot animation + fun message */ -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; +import { LoadingState } from "./LoadingState"; interface SplashScreenProps { visible?: boolean; + /** Text shown below the spinner. If omitted, picks a random fun message. */ + message?: string; + /** Auto-dismiss after this many ms. If omitted, stays until `visible` becomes false. */ + duration?: number; + /** Called when the splash finishes (either duration elapsed or visible set to false) */ + onDone?: () => void; } -// Stroke definitions: [id, strokeWidth, duration(ms), startDelay(ms)] -const STROKES: [string, number, number, number][] = [ - ["n1", 4.2, 320, 100], - ["o1", 4.0, 280, 380], - ["t1", 3.6, 220, 600], - ["t2", 2.4, 100, 780], - ["e1", 4.0, 260, 850], - ["s1", 3.8, 240, 1060], - ["s2", 2.0, 140, 1260], -]; - -function getLen(el: SVGPathElement): number { - try { - return Math.ceil(el.getTotalLength()) + 10; - } catch { - return 900; - } -} - -function NotesAnimation() { - const svgRef = useRef(null); +export function SplashScreen({ + visible = true, + message, + duration, + onDone, +}: SplashScreenProps) { + const [show, setShow] = useState(visible); + // Sync with external visible prop useEffect(() => { - const svg = svgRef.current; - if (!svg) return; - - STROKES.forEach(([id, sw, dur, delay]) => { - const el = svg.getElementById(id) as SVGPathElement | null; - if (!el) return; - const len = getLen(el); - - el.style.transition = "none"; - el.style.opacity = "1"; - el.style.strokeWidth = sw + "px"; - el.style.strokeDasharray = len + "px"; - el.style.strokeDashoffset = len + "px"; - - setTimeout(() => { - el.style.transition = `stroke-dashoffset ${dur}ms cubic-bezier(0.38,0,0.18,1)`; - el.style.strokeDashoffset = "0"; - }, delay); - }); - }, []); + setShow(visible); + }, [visible]); - return ( - - {/* n */} - - {/* o */} - - {/* t stem */} - - {/* t crossbar */} - - {/* e */} - - {/* s upper */} - - {/* s exit flourish */} - - - ); -} + // Auto-dismiss after duration + useEffect(() => { + if (!visible || duration == null) return; + const timer = setTimeout(() => setShow(false), duration); + return () => clearTimeout(timer); + }, [visible, duration]); -export function SplashScreen({ visible = true }: SplashScreenProps) { return ( - - {visible && ( + + {show && ( - {/* Hand-drawn cursive "notes" animation */} -
- - - Powered by Mentra - -
- - {/* Version */} - - v2.0.0 - +
)}
diff --git a/src/frontend/components/shared/WaveIndicator.tsx b/src/frontend/components/shared/WaveIndicator.tsx new file mode 100644 index 0000000..f615050 --- /dev/null +++ b/src/frontend/components/shared/WaveIndicator.tsx @@ -0,0 +1,71 @@ +/** + * WaveIndicator - Three-bar sound wave animation for live transcription. + * + * Runs at 9 FPS via setInterval (matching DotBurstSpinner's low-fps aesthetic). + * Middle bar is tallest. Bars animate up/down in a staggered wave pattern. + * + * @example + * + * + */ + +import { useEffect, useState, memo } from "react"; + +const FPS = 7; +const FRAME_MS = 1000 / FPS; +// 8-step wave cycle per bar: scaleY values (0→1 range) +const WAVE = [0.35, 0.45, 0.6, 0.8, 1.0, 0.8, 0.6, 0.45, 0.45]; + +interface WaveIndicatorProps { + /** Color of the bars. Defaults to "#EF4444". */ + color?: string; + /** Max height of the tallest bar in px. Defaults to 10. */ + height?: number; + /** Bar width in px. Defaults to 2. */ + barWidth?: number; + /** Gap between bars in px. Defaults to 1. */ + gap?: number; +} + +export const WaveIndicator = memo(function WaveIndicator({ + color = "#EF4444", + height = 10, + barWidth = 2, + gap = 1, +}: WaveIndicatorProps) { + const [frame, setFrame] = useState(0); + useEffect(() => { + const id = setInterval(() => setFrame((f) => (f + 1) % WAVE.length), FRAME_MS); + + return () => clearInterval(id); + }, []); + + // Each bar is offset by a different phase in the wave cycle + const offsets = [0, 3, 6]; // short / tall / medium phase offsets + // Outer bars capped at 70% of middle + const maxHeights = [height * 0.7, height, height * 0.7]; + + return ( +
+ {offsets.map((offset, i) => { + const scale = WAVE[(frame + offset) % WAVE.length]; + return ( +
+ ); + })} +
+ ); +}); diff --git a/src/frontend/components/shared/index.ts b/src/frontend/components/shared/index.ts index f82cb72..f9d734d 100644 --- a/src/frontend/components/shared/index.ts +++ b/src/frontend/components/shared/index.ts @@ -18,6 +18,9 @@ export { type SkeletonLoaderProps, } from './SkeletonLoader'; +export { DotBurstSpinner, DotWaveSpinner, DotGridSpinner, DotSpiralSpinner } from './DotBurstSpinner'; +export { LoadingState } from './LoadingState'; + export { ErrorState, ErrorMessage, diff --git a/src/frontend/hooks/useAutoScroll.ts b/src/frontend/hooks/useAutoScroll.ts new file mode 100644 index 0000000..d02771a --- /dev/null +++ b/src/frontend/hooks/useAutoScroll.ts @@ -0,0 +1,105 @@ +/** + * useAutoScroll - Smart auto-scroll for live content (transcripts, chat, etc.) + * + * Behavior: + * - On mount: scrolls to bottom instantly + * - New content (DOM child additions): auto-scrolls to bottom if locked + * - User scrolls up (away from bottom): unlocks auto-scroll, shows button + * - User scrolls back near bottom (within 200px): re-locks auto-scroll + * - Button tap: re-locks and scrolls to bottom + * + * Returns: + * - scrollContainerRef: attach to the scrollable container element + * - showScrollButton: whether to show the "jump to bottom" button + * - scrollToBottom: call this from the button's onClick + */ + +import { useEffect, useRef, useState, useCallback, type RefObject } from "react"; + +interface UseAutoScrollOptions { + /** Re-initialize when this value changes (e.g., loading state) */ + deps?: unknown[]; + /** Disable the MutationObserver auto-scroll (useful for non-live views) */ + disableAutoScroll?: boolean; +} + +interface UseAutoScrollReturn { + scrollContainerRef: RefObject; + showScrollButton: boolean; + scrollToBottom: () => void; +} + +export function useAutoScroll(options: UseAutoScrollOptions = {}): UseAutoScrollReturn { + const { deps = [], disableAutoScroll = false } = options; + const scrollContainerRef = useRef(null); + const [showScrollButton, setShowScrollButton] = useState(false); + const lockedRef = useRef(true); + const initialDone = useRef(false); + + // Initial scroll to bottom (once per deps change) + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + if (initialDone.current) return; + initialDone.current = true; + lockedRef.current = true; + setShowScrollButton(false); + container.scrollTo({ top: container.scrollHeight, behavior: "instant" }); + }, deps); + + // Reset initial flag when deps change + useEffect(() => { + initialDone.current = false; + }, deps); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const isNearBottom = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + return scrollHeight - scrollTop - clientHeight < 200; + }; + + // Detect scroll position — works for touch, mouse, keyboard + const handleScroll = () => { + if (isNearBottom()) { + lockedRef.current = true; + setShowScrollButton(false); + } else { + lockedRef.current = false; + setShowScrollButton(true); + } + }; + + // Auto-scroll on new child elements only when locked + // No characterData — avoids interim text causing scroll jank + let observer: MutationObserver | null = null; + if (!disableAutoScroll) { + observer = new MutationObserver(() => { + if (!lockedRef.current) return; + requestAnimationFrame(() => { + container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); + }); + }); + observer.observe(container, { childList: true, subtree: true }); + } + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + observer?.disconnect(); + }; + }, deps); + + const scrollToBottom = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + lockedRef.current = true; + setShowScrollButton(false); + container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); + }, []); + + return { scrollContainerRef, showScrollButton, scrollToBottom }; +} diff --git a/src/frontend/hooks/useMultiSelect.ts b/src/frontend/hooks/useMultiSelect.ts new file mode 100644 index 0000000..cda4e17 --- /dev/null +++ b/src/frontend/hooks/useMultiSelect.ts @@ -0,0 +1,112 @@ +/** + * useMultiSelect — Long-press to enter selection mode, toggle items, batch actions + * + * Manages multi-select state for notes, conversations, and transcripts lists. + * Long-press (500ms hold) on a row enters selection mode and selects that item. + * Once in selection mode, tapping toggles selection. Auto-exits when selection is empty. + */ + +import { useCallback, useRef, useState } from "react"; + +const LONG_PRESS_MS = 500; + +export interface UseMultiSelectReturn { + /** Whether selection mode is active */ + isSelecting: boolean; + /** Set of selected item IDs */ + selectedIds: Set; + /** Number of selected items */ + count: number; + /** Enter selection mode and select the first item */ + startSelecting: (id: string) => void; + /** Toggle an item's selection state */ + toggleItem: (id: string) => void; + /** Select all items from a given list */ + selectAll: (allIds: string[]) => void; + /** Exit selection mode and clear selection */ + cancel: () => void; + /** Long-press handler factory — returns touch props for a row */ + longPressProps: (id: string, disabled?: boolean) => { + onTouchStart: (e: React.TouchEvent) => void; + onTouchEnd: () => void; + onTouchMove: () => void; + }; +} + +export function useMultiSelect(): UseMultiSelectReturn { + const [isSelecting, setIsSelecting] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const timerRef = useRef | null>(null); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const startSelecting = useCallback((id: string) => { + setIsSelecting(true); + setSelectedIds(new Set([id])); + }, []); + + const toggleItem = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + // Auto-exit if nothing selected + if (next.size === 0) { + setIsSelecting(false); + } + return next; + }); + }, []); + + const selectAll = useCallback((allIds: string[]) => { + setSelectedIds(new Set(allIds)); + }, []); + + const cancel = useCallback(() => { + clearTimer(); + setIsSelecting(false); + setSelectedIds(new Set()); + }, [clearTimer]); + + const longPressProps = useCallback( + (id: string, disabled = false) => ({ + onTouchStart: (e: React.TouchEvent) => { + if (disabled) return; + clearTimer(); + timerRef.current = setTimeout(() => { + // Haptic feedback if available + if (navigator.vibrate) navigator.vibrate(20); + startSelecting(id); + }, LONG_PRESS_MS); + }, + onTouchEnd: () => { + clearTimer(); + }, + onTouchMove: () => { + // Cancel long-press if finger moves (scrolling) + clearTimer(); + }, + }), + [clearTimer, startSelecting], + ); + + return { + isSelecting, + selectedIds, + count: selectedIds.size, + startSelecting, + toggleItem, + selectAll, + cancel, + longPressProps, + }; +} diff --git a/src/frontend/hooks/useSwipeToReveal.ts b/src/frontend/hooks/useSwipeToReveal.ts new file mode 100644 index 0000000..029ef27 --- /dev/null +++ b/src/frontend/hooks/useSwipeToReveal.ts @@ -0,0 +1,177 @@ +/** + * useSwipeToReveal — Native touch-based swipe gesture hook + * + * Replaces Framer Motion drag for swipe-to-reveal action buttons. + * Uses touch events directly to avoid conflicts between drag/animate/snapToOrigin. + */ + +import { useMotionValue, animate as motionAnimate } from "motion/react"; +import { useRef, useState, useEffect, useCallback } from "react"; + +interface UseSwipeToRevealOptions { + openDistance?: number; + threshold?: number; + deadZone?: number; + autoCloseDelay?: number; +} + +type GesturePhase = "idle" | "deciding" | "tracking" | "settling"; + +export function useSwipeToReveal({ + openDistance = 146, + threshold = 0.3, + deadZone = 10, + autoCloseDelay = 6000, +}: UseSwipeToRevealOptions = {}) { + const x = useMotionValue(0); + const [isSwiped, setIsSwiped] = useState(false); + + const phaseRef = useRef("idle"); + const startXRef = useRef(0); + const startYRef = useRef(0); + const startTranslateRef = useRef(0); + const isDraggingRef = useRef(false); + const autoCloseTimerRef = useRef | null>(null); + + const clearAutoClose = useCallback(() => { + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = null; + } + }, []); + + const startAutoClose = useCallback(() => { + clearAutoClose(); + autoCloseTimerRef.current = setTimeout(() => { + motionAnimate(x, 0, { type: "tween", duration: 0.2, ease: "easeOut" }); + setIsSwiped(false); + }, autoCloseDelay); + }, [clearAutoClose, autoCloseDelay, x]); + + useEffect(() => { + return () => clearAutoClose(); + }, [clearAutoClose]); + + const close = useCallback(() => { + clearAutoClose(); + motionAnimate(x, 0, { type: "tween", duration: 0.2, ease: "easeOut" }); + setIsSwiped(false); + }, [clearAutoClose, x]); + + const onTouchStart = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0]; + startXRef.current = touch.clientX; + startYRef.current = touch.clientY; + startTranslateRef.current = x.get(); + phaseRef.current = "deciding"; + clearAutoClose(); + }, [x, clearAutoClose]); + + const onTouchMove = useCallback((e: React.TouchEvent) => { + if (phaseRef.current === "idle" || phaseRef.current === "settling") return; + + const touch = e.touches[0]; + const deltaX = touch.clientX - startXRef.current; + const deltaY = touch.clientY - startYRef.current; + + if (phaseRef.current === "deciding") { + const totalMove = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + if (totalMove < deadZone) return; + + // Decide axis + if (Math.abs(deltaY) > Math.abs(deltaX)) { + // Vertical — abort, let scroll happen + phaseRef.current = "idle"; + return; + } + + // Horizontal — lock in + phaseRef.current = "tracking"; + isDraggingRef.current = true; + } + + if (phaseRef.current === "tracking") { + e.preventDefault(); + const rawX = startTranslateRef.current + deltaX; + const clamped = Math.max(-openDistance, Math.min(0, rawX)); + x.set(clamped); + } + }, [deadZone, openDistance, x]); + + const onTouchEnd = useCallback((e: React.TouchEvent) => { + if (phaseRef.current === "idle" || phaseRef.current === "settling") { + phaseRef.current = "idle"; + return; + } + + if (phaseRef.current === "deciding") { + // Never exceeded dead zone — this is a tap + phaseRef.current = "idle"; + isDraggingRef.current = false; + return; + } + + // We were tracking + phaseRef.current = "settling"; + + const touch = e.changedTouches[0]; + const deltaX = touch.clientX - startXRef.current; + const currentX = x.get(); + const thresholdPx = openDistance * threshold; + + // Calculate velocity from last movement + const wasOpenBefore = startTranslateRef.current < -thresholdPx; + + let shouldOpen: boolean; + + if (wasOpenBefore) { + // Was open — close if dragged right past threshold + shouldOpen = currentX < -(openDistance - thresholdPx); + } else { + // Was closed — open if dragged left past threshold + shouldOpen = currentX < -thresholdPx; + } + + const target = shouldOpen ? -openDistance : 0; + + motionAnimate(x, target, { + type: "tween", + duration: 0.2, + ease: "easeOut", + onComplete: () => { + phaseRef.current = "idle"; + }, + }); + + setIsSwiped(shouldOpen); + if (shouldOpen) { + startAutoClose(); + } + + // Debounce isDragging flag so onClick doesn't fire + setTimeout(() => { + isDraggingRef.current = false; + }, 50); + }, [x, openDistance, threshold, startAutoClose]); + + const handleClick = useCallback((onSelect: () => void) => { + if (isDraggingRef.current) return; + if (isSwiped) { + close(); + } else { + onSelect(); + } + }, [isSwiped, close]); + + return { + x, + isSwiped, + close, + handlers: { + onTouchStart, + onTouchMove, + onTouchEnd, + }, + handleClick, + }; +} diff --git a/src/frontend/hooks/useSynced.ts b/src/frontend/hooks/useSynced.ts index a00f425..61dc799 100644 --- a/src/frontend/hooks/useSynced.ts +++ b/src/frontend/hooks/useSynced.ts @@ -25,13 +25,18 @@ class SyncClient { private rpcIdCounter = 0; private listeners: Set<() => void> = new Set(); private _isConnected = false; + private _isReconnecting = false; + private _hasConnectedOnce = false; private userId: string; private reconnectTimer: ReturnType | null = null; private _version = 0; + private _notifyScheduled = false; + private _visibilityHandler: (() => void) | null = null; constructor(userId: string) { this.userId = userId; this.connect(); + this.setupVisibilityHandler(); } private connect(): void { @@ -53,6 +58,10 @@ class SyncClient { this.ws.onclose = () => { console.log("[Synced] Disconnected"); this._isConnected = false; + // If we had connected before, mark as reconnecting so the UI can show loading state + if (this._hasConnectedOnce) { + this._isReconnecting = true; + } this._version++; this.notifyListeners(); @@ -78,8 +87,10 @@ class SyncClient { break; case "snapshot": - console.log("[Synced] Snapshot received"); + console.log("[Synced] Snapshot received", this._isReconnecting ? "(reconnect)" : "(initial)"); this.state = message.state; + this._hasConnectedOnce = true; + this._isReconnecting = false; this._version++; this.notifyListeners(); // Auto-detect and sync user timezone on connection @@ -87,10 +98,10 @@ class SyncClient { break; case "state_change": - console.log( - `[Synced] state_change: ${message.manager}.${message.property} =`, - message.value, - ); + // console.log( + // `[Synced] state_change: ${message.manager}.${message.property} =`, + // message.value, + // ); // For session-level state (hasGlassesConnected, isRecording, etc.), // store at top level to match snapshot format if (message.manager === "session") { @@ -104,8 +115,9 @@ class SyncClient { [message.property]: message.value, }; } - this._version++; - this.notifyListeners(); + // Batched: coalesces rapid sequential state changes (e.g. interimText="" + // + segments=[...]) into a single React re-render to prevent layout jumps + this.scheduleNotify(); break; case "rpc_response": @@ -180,6 +192,22 @@ class SyncClient { } } + /** + * Schedule a batched notify — coalesces multiple state_change messages + * that arrive in the same microtask into a single React re-render. + * This prevents layout jumping when the backend sends e.g. interimText="" + * and segments=[...] as two rapid broadcasts for the same event. + */ + private scheduleNotify(): void { + this._version++; + if (this._notifyScheduled) return; + this._notifyScheduled = true; + queueMicrotask(() => { + this._notifyScheduled = false; + this.notifyListeners(); + }); + } + reconnect(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.ws?.close(); @@ -188,14 +216,42 @@ class SyncClient { dispose(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this._visibilityHandler) { + document.removeEventListener("visibilitychange", this._visibilityHandler); + } this.ws?.close(); this.listeners.clear(); } + private setupVisibilityHandler(): void { + if (typeof document === "undefined") return; + this._visibilityHandler = () => { + if (document.hidden) { + // Going to background — close WebSocket cleanly so we don't + // accumulate a stale connection the OS will kill anyway + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + this.ws?.close(); + } else { + // Coming back to foreground — reconnect immediately if not connected + if (!this._isConnected) { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + this.connect(); + } + } + }; + document.addEventListener("visibilitychange", this._visibilityHandler); + } + get isConnected(): boolean { return this._isConnected; } + get isReconnecting(): boolean { + return this._isReconnecting; + } + get currentState(): Record { return this.state; } @@ -218,6 +274,7 @@ const clientCache = new Map>(); export interface UseSyncedResult { session: T | null; isConnected: boolean; + isReconnecting: boolean; reconnect: () => void; } @@ -331,6 +388,7 @@ export function useSynced(userId: string): UseSyncedResult { return { session, isConnected: client?.isConnected ?? false, + isReconnecting: client?.isReconnecting ?? false, reconnect, }; } diff --git a/src/frontend/index.css b/src/frontend/index.css index 33731ab..7727d2e 100644 --- a/src/frontend/index.css +++ b/src/frontend/index.css @@ -1,6 +1,12 @@ @import "tailwindcss"; @import "./styles/sega.css"; + +@theme { + --font-sans: "Red Hat Display", ui-sans-serif, system-ui, sans-serif; + --font-red-hat: "Red Hat Display", ui-sans-serif, system-ui, sans-serif; +} + /* Splash screen hand-drawn stroke style */ .splash-stroke { fill: none; @@ -10,6 +16,12 @@ opacity: 0; } +/* Wave bar animation for "Transcribing now" badge */ +@keyframes wave { + from { transform: scaleY(0.4); opacity: 0.7; } + to { transform: scaleY(1); opacity: 1; } +} + /* Shimmer animation for transcribing indicator */ @keyframes shimmer { 0% { @@ -40,6 +52,31 @@ animation: segment-fade-in 0.3s ease-out forwards; } + + +/* Shake animation for note selection mode */ +@keyframes note-shake { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-0.4deg); } + 75% { transform: rotate(0.4deg); } +} + +.animate-note-shake { + animation: note-shake 0.3s ease-in-out infinite; +} + +/* Merge highlight — subtle red pulse that fades out */ +@keyframes merge-highlight { + 0% { background-color: transparent; } + 15% { background-color: #FEE2E24D; } + 85% { background-color: #FEE2E24D; } + 100% { background-color: transparent; } +} + +.animate-merge-highlight { + animation: merge-highlight 4s ease-in-out forwards; +} + /* Masonry grid - ensure items fill column width */ [data-masonry] > div { width: 100% !important; diff --git a/src/frontend/index.html b/src/frontend/index.html index c24265c..7dedf97 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -17,12 +17,22 @@ name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> - - - + Notes - All-day transcription diff --git a/src/frontend/index.prod.html b/src/frontend/index.prod.html index c8c7689..3c08ae2 100644 --- a/src/frontend/index.prod.html +++ b/src/frontend/index.prod.html @@ -17,12 +17,22 @@ name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> - - - + Notes - All-day transcription diff --git a/src/frontend/lib/posthog.ts b/src/frontend/lib/posthog.ts new file mode 100644 index 0000000..ff0ba73 --- /dev/null +++ b/src/frontend/lib/posthog.ts @@ -0,0 +1,6 @@ +/** + * @deprecated Use `../services/posthog` instead. + * Re-exports for backwards compatibility. + */ + +export { useFeatureFlag, PostHog as default } from "../services/posthog"; diff --git a/src/frontend/pages/conversation/ConversationDetailPage.tsx b/src/frontend/pages/conversation/ConversationDetailPage.tsx new file mode 100644 index 0000000..8841cc3 --- /dev/null +++ b/src/frontend/pages/conversation/ConversationDetailPage.tsx @@ -0,0 +1,478 @@ +/** + * ConversationDetailPage - Full conversation view with summary + transcript + * + * Shows: + * - Header with title, time range, duration + * - AI summary section + * - Generate Note button + * - Transcript with speaker-colored chunks + * - "View full transcript" expand toggle + */ + +import { useMemo, useState, useEffect, useRef } from "react"; +import { useLocation, useParams } from "wouter"; +import { useMentraAuth } from "@mentra/react"; +import { format } from "date-fns"; +import { useSynced } from "../../hooks/useSynced"; +import { WaveIndicator } from "../../components/shared/WaveIndicator"; +import type { SessionI, Conversation, ConversationChunk, ConversationSegment, TranscriptSegment } from "../../../shared/types"; +import { DropdownMenu, type DropdownMenuOption } from "../../components/shared/DropdownMenu"; +import { LoadingState } from "../../components/shared/LoadingState"; + +/** Stable speakerId string → sequential color index (first seen = 0, second = 1, …) */ +function buildSpeakerMap(segments: (TranscriptSegment | ConversationSegment)[]): Map { + const map = new Map(); + for (const seg of segments) { + const id = seg.speakerId ?? "default"; + if (!map.has(id)) map.set(id, map.size); + } + return map; +} + + +// Speaker color palette (cycles for multiple speakers) +const SPEAKER_COLORS = [ + { bg: "bg-[#EFF6FF]", text: "text-[#3B82F6]" }, // Blue + { bg: "bg-[#FFF7ED]", text: "text-[#F97316]" }, // Orange + { bg: "bg-[#F0FDF4]", text: "text-[#22C55E]" }, // Green + { bg: "bg-[#FDF4FF]", text: "text-[#A855F7]" }, // Purple + { bg: "bg-[#FEF2F2]", text: "text-[#EF4444]" }, // Red +]; + +function getDurationMinutes(conv: Conversation): number | null { + if (!conv.endTime) return null; + const start = new Date(conv.startTime).getTime(); + const end = new Date(conv.endTime).getTime(); + return Math.max(1, Math.round((end - start) / 60000)); +} + +function formatTimeRange(conv: Conversation): string { + const start = format(new Date(conv.startTime), "h:mm a"); + if (!conv.endTime) return `${start} – now`; + const end = format(new Date(conv.endTime), "h:mm a"); + return `${start} – ${end}`; +} + +function getTotalDuration(chunks: ConversationChunk[]): string { + if (chunks.length === 0) return "0:00"; + const first = new Date(chunks[0].startTime).getTime(); + const last = new Date(chunks[chunks.length - 1].endTime).getTime(); + const totalSec = Math.round((last - first) / 1000); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}:${String(sec).padStart(2, "0")}`; +} + +export function ConversationDetailPage() { + const { id } = useParams<{ id: string }>(); + const { userId } = useMentraAuth(); + const { session } = useSynced(userId || ""); + const [, setLocation] = useLocation(); + const [showFullTranscript, setShowFullTranscript] = useState(false); + const transcriptBottomRef = useRef(null); + + // Find conversation from session state + const conversation = useMemo(() => { + const conversations = session?.conversation?.conversations ?? []; + return conversations.find((c) => c.id === id) ?? null; + }, [session?.conversation?.conversations, id]); + + const isActive = conversation?.status === "active" || conversation?.status === "paused"; + + // Live segments filtered to this conversation's time range + const liveSegments = useMemo(() => { + if (!conversation || !isActive) return []; + const allSegments: TranscriptSegment[] = session?.transcript?.segments ?? []; + const start = new Date(conversation.startTime).getTime(); + return allSegments.filter( + (s) => s.isFinal && s.type !== "photo" && new Date(s.timestamp).getTime() >= start, + ); + }, [session?.transcript?.segments, conversation?.startTime, isActive]); + + // Stable speaker ID → color index map for live segments + const liveSpeakerMap = useMemo(() => buildSpeakerMap(liveSegments), [liveSegments]); + + // Preview: last 2 live segments shown on detail page + const previewSegments = useMemo(() => liveSegments.slice(-2), [liveSegments]); + + // No auto-scroll on detail page — active state is non-scrollable + useEffect(() => { void transcriptBottomRef; }, []); + + // Load segments on-demand for ended conversations (not loaded during hydration) + const [loadingSegments, setLoadingSegments] = useState(false); + const segmentsLoadedRef = useRef(null); + useEffect(() => { + if (!conversation || !session?.conversation?.loadConversationSegments) return; + if (conversation.status === "active" || conversation.status === "paused") return; + if (conversation.segments && conversation.segments.length > 0) return; + // Don't re-attempt if we already loaded for this conversation (may have 0 segments) + if (segmentsLoadedRef.current === conversation.id) return; + segmentsLoadedRef.current = conversation.id; + setLoadingSegments(true); + session.conversation.loadConversationSegments(conversation.id) + .catch(() => {}) + .finally(() => setLoadingSegments(false)); + }, [conversation?.id, conversation?.status, conversation?.segments?.length, session?.conversation]); + + if (!session || !conversation) { + return ( +
+
+ {!session ? "Loading..." : "Conversation not found"} +
+
+ ); + } + + const duration = getDurationMinutes(conversation); + const timeRange = formatTimeRange(conversation); + const chunks = conversation.chunks ?? []; + const totalDuration = getTotalDuration(chunks); + + // Segments with speaker IDs for ended conversations + const endedSegments = conversation.segments ?? []; + const displaySegments = showFullTranscript ? endedSegments : endedSegments.slice(0, 8); + + // Speaker map for ended conversation segments + const endedSpeakerMap = useMemo(() => buildSpeakerMap(endedSegments), [endedSegments]); + + const linkedNote = conversation.noteId + ? (session?.notes?.notes ?? []).find((n) => n.id === conversation.noteId) + : null; + + // Check if transcript was deleted (file exists but no transcript data) + const convDate = useMemo(() => { + if (!conversation?.startTime) return ""; + const d = new Date(conversation.startTime); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + }, [conversation?.startTime]); + const transcriptDeleted = useMemo(() => { + if (!convDate || isActive) return false; + const files = session?.file?.files ?? []; + const file = files.find((f) => f.date === convDate); + // Transcript was deleted if file is trashed or has no transcript + return file ? (file.isTrashed || !file.hasTranscript) : false; + }, [convDate, isActive, session?.file?.files]); + + const handleBack = () => { + setLocation("/"); + }; + + const handleGenerateNote = () => { + setLocation(`/conversation/${conversation.id}/generating`); + }; + + return ( +
+ {/* Header */} +
+ +
+ {conversation.title ? ( +
+ {conversation.title} +
+ ) : ( +
+ + + + + + Generating title... + +
+ )} +
+ {timeRange} + {duration !== null ? ` · ${duration} min` : ""} +
+
+ + {/* Share + More buttons */} +
+ {/* */} + { + const isFav = conversation.isFavourite ?? false; + const isArchived = conversation.isArchived ?? false; + const isTrashed = conversation.isTrashed ?? false; + const convManager = session?.conversation; + + const items: DropdownMenuOption[] = [ + { + id: "favourite", + label: isFav ? "Unfavourite" : "Favourite", + icon: ( + + + + ), + onClick: async () => { + if (!convManager) return; + if (isFav) { + await convManager.unfavouriteConversation(conversation.id); + } else { + await convManager.favouriteConversation(conversation.id); + } + }, + }, + { + id: "archive", + label: isArchived ? "Unarchive" : "Archive", + icon: ( + + + + + + ), + onClick: async () => { + if (!convManager) return; + if (isArchived) { + await convManager.unarchiveConversation(conversation.id); + } else { + await convManager.archiveConversation(conversation.id); + } + }, + }, + { type: "divider" }, + { + id: "trash", + label: isTrashed ? "Untrash" : "Trash", + danger: !isTrashed, + icon: ( + + + + + + ), + onClick: async () => { + if (!convManager) return; + if (isTrashed) { + await convManager.untrashConversation(conversation.id); + } else { + await convManager.trashConversation(conversation.id); + setLocation("/"); + } + }, + }, + ]; + return items; + })()} + /> +
+
+ + {/* Content — non-scrollable when active, scrollable when ended */} +
+ {/* Summary section */} +
+
+
+ Summary +
+
+ {/* + + */} +
+ AI +
+
+
+ + {isActive ? ( +
+ +

+ Waiting for conversation to end{"\n"}to generate AI summary +

+
+ ) : conversation.generatingSummary ? ( +
+
+
+
+ Generating summary... +
+ ) : conversation.aiSummary ? ( +
+ {conversation.aiSummary} +
+ ) : ( +
+ No summary available +
+ )} + + {/* Generate Note / Go to Note button — only when conversation has ended */} + {!isActive && ( + conversation.noteId && linkedNote ? ( + + ) : ( + + ) + )} +
+ + {/* Transcript section — live segments when active, chunks when ended */} +
+
+
+ Transcript +
+ {!isActive && chunks.length > 0 && ( +
+ {totalDuration} total +
+ )} +
+ + {isActive ? ( +
+ {previewSegments.length > 0 ? ( + previewSegments.map((seg) => { + const speakerIdx = (liveSpeakerMap.get(seg.speakerId ?? "default") ?? 0) % SPEAKER_COLORS.length; + const color = SPEAKER_COLORS[speakerIdx]; + return ( +
+
+ + {speakerIdx + 1} + +
+
+ + Speaker {speakerIdx + 1} · {format(new Date(seg.timestamp), "h:mm")} + + + {seg.text} + +
+
+ ); + }) + ) : ( + Listening... + )} + {/* View transcript link — always shown when active */} + +
+ ) : endedSegments.length > 0 ? ( + <> + {displaySegments.map((seg) => { + const speakerIdx = (endedSpeakerMap.get(seg.speakerId ?? "default") ?? 0) % SPEAKER_COLORS.length; + const color = SPEAKER_COLORS[speakerIdx]; + const segTime = format(new Date(seg.timestamp), "h:mm"); + return ( +
+
+
+ {speakerIdx + 1} +
+
+
+
+ Speaker {speakerIdx + 1} · {segTime} +
+
+ {seg.text} +
+
+
+ ); + })} + {endedSegments.length > 8 && ( + + )} + + ) : loadingSegments ? ( +
+ +
+ ) : transcriptDeleted ? ( +
+ + + + + + + Transcript deleted + +
+ ) : ( +
+ No transcript recorded +
+ )} +
+
+
+ ); +} diff --git a/src/frontend/pages/conversation/ConversationTranscriptPage.tsx b/src/frontend/pages/conversation/ConversationTranscriptPage.tsx new file mode 100644 index 0000000..fc374f7 --- /dev/null +++ b/src/frontend/pages/conversation/ConversationTranscriptPage.tsx @@ -0,0 +1,221 @@ +/** + * ConversationTranscriptPage - Live transcript view for an active conversation + * + * Shows: + * - Live incoming segments (final) with speaker avatars + * - Interim text (currently being spoken) — muted, at the bottom + * - Auto-scrolls to latest segment + * - Bottom bar: elapsed timer + Stop button (ends transcription + conversation) + */ + +import { useMemo, useEffect, useState } from "react"; +import { useLocation, useParams } from "wouter"; +import { useMentraAuth } from "@mentra/react"; +import { format } from "date-fns"; +import { useSynced } from "../../hooks/useSynced"; +import { useAutoScroll } from "../../hooks/useAutoScroll"; +import { WaveIndicator } from "../../components/shared/WaveIndicator"; +import type { SessionI, TranscriptSegment } from "../../../shared/types"; +import { ArrowDown } from "lucide-react"; +import { StopTranscriptionDialog } from "../home/components/StopTranscriptionDialog"; + +/** Stable speakerId string → sequential color index */ +function buildSpeakerMap(segments: TranscriptSegment[]): Map { + const map = new Map(); + for (const seg of segments) { + const id = seg.speakerId ?? "default"; + if (!map.has(id)) map.set(id, map.size); + } + return map; +} + +const SPEAKER_COLORS = [ + { bg: "bg-[#EFF6FF]", text: "text-[#3B82F6]" }, + { bg: "bg-[#FFF7ED]", text: "text-[#F97316]" }, + { bg: "bg-[#F0FDF4]", text: "text-[#22C55E]" }, + { bg: "bg-[#FDF4FF]", text: "text-[#A855F7]" }, + { bg: "bg-[#FEF2F2]", text: "text-[#EF4444]" }, +]; + +function formatElapsed(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +export function ConversationTranscriptPage() { + const { id } = useParams<{ id: string }>(); + const { userId } = useMentraAuth(); + const { session } = useSynced(userId || ""); + const [, setLocation] = useLocation(); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const { scrollContainerRef, showScrollButton, scrollToBottom } = useAutoScroll({ + deps: [!!session], + }); + + const conversation = useMemo(() => { + return (session?.conversation?.conversations ?? []).find((c) => c.id === id) ?? null; + }, [session?.conversation?.conversations, id]); + + const isActive = conversation?.status === "active" || conversation?.status === "paused"; + const interimText = session?.transcript?.interimText ?? ""; + + // Live final segments for this conversation + const liveSegments = useMemo(() => { + if (!conversation) return []; + const start = new Date(conversation.startTime).getTime(); + return (session?.transcript?.segments ?? []).filter( + (s) => s.isFinal && s.type !== "photo" && new Date(s.timestamp).getTime() >= start, + ); + }, [session?.transcript?.segments, conversation?.startTime]); + + const speakerMap = useMemo(() => buildSpeakerMap(liveSegments), [liveSegments]); + + // Elapsed timer from conversation startTime + useEffect(() => { + if (!conversation?.startTime) return; + const start = new Date(conversation.startTime).getTime(); + const tick = () => setElapsedSeconds(Math.floor((Date.now() - start) / 1000)); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [conversation?.startTime]); + + + const [showStopDrawer, setShowStopDrawer] = useState(false); + + const handleStop = () => { + session?.settings?.updateSettings({ transcriptionPaused: true }); + setShowStopDrawer(false); + setLocation(`/conversation/${id}`); + }; + + if (!session || !conversation) { + return ( +
+ + {!session ? "Loading..." : "Conversation not found"} + +
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+
+ {conversation.title || "Live Transcript"} +
+
+ {format(new Date(conversation.startTime), "h:mm a")} – now +
+
+
+ + {/* Segments */} +
+ {liveSegments.length === 0 && !interimText ? ( +
+ + Listening... +
+ ) : ( +
+ {liveSegments.map((seg) => { + const speakerIdx = (speakerMap.get(seg.speakerId ?? "default") ?? 0) % SPEAKER_COLORS.length; + const color = SPEAKER_COLORS[speakerIdx]; + return ( +
+
+ + {speakerIdx + 1} + +
+
+ + Speaker {speakerIdx + 1} · {format(new Date(seg.timestamp), "h:mm")} + + + {seg.text} + +
+
+ ); + })} + + {/* Interim text — currently being spoken */} + {interimText.trim() && ( +
+
+ 1 +
+
+ + Speaking... + + + {interimText} + +
+
+ )} + +
+
+ )} +
+ + {/* Jump to bottom button */} + {showScrollButton && liveSegments.length > 0 && ( + + )} + + + + {/* Bottom bar */} +
+
+
+ {isActive ? :
} + + {isActive ? "Transcribing" : "Paused"} + +
+ + {formatElapsed(elapsedSeconds)} elapsed + +
+ + {/* Stop button — mic icon */} + +
+ + setShowStopDrawer(false)} + onConfirm={handleStop} + /> +
+ ); +} diff --git a/src/frontend/pages/conversation/GeneratingNotePage.tsx b/src/frontend/pages/conversation/GeneratingNotePage.tsx new file mode 100644 index 0000000..a416c0a --- /dev/null +++ b/src/frontend/pages/conversation/GeneratingNotePage.tsx @@ -0,0 +1,255 @@ +/** + * GeneratingNotePage - Shows progress while generating an AI note from a conversation + * + * Displays: + * - Back button + conversation date header + * - Step-by-step progress card (Buffer, Triage, Track, Generate, Safety Pass) + * - Live preview card with conversation title and summary preview + */ + +import { useState, useEffect, useMemo, useRef } from "react"; +import { useLocation, useParams } from "wouter"; +import { useMentraAuth } from "@mentra/react"; +import { format } from "date-fns"; +import { useSynced } from "../../hooks/useSynced"; +import type { SessionI } from "../../../shared/types"; + +type StepStatus = "done" | "active" | "pending"; + +interface Step { + label: string; + subtitle: string; + status: StepStatus; +} + +export function GeneratingNotePage() { + const { id } = useParams<{ id: string }>(); + const { userId } = useMentraAuth(); + const { session } = useSynced(userId || ""); + const [, setLocation] = useLocation(); + const [activeStep, setActiveStep] = useState(0); + const generationStartedRef = useRef(false); + const [progressPercent, setProgressPercent] = useState(0); + + const conversation = useMemo(() => { + const conversations = session?.conversation?.conversations ?? []; + return conversations.find((c) => c.id === id) ?? null; + }, [session?.conversation?.conversations, id]); + + const chunks = conversation?.chunks ?? []; + + // Format the date for header + const dateLabel = useMemo(() => { + if (!conversation) return ""; + try { + const [year, month, day] = conversation.date.split("-").map(Number); + return format(new Date(year, month - 1, day), "MMMM d, yyyy"); + } catch { + return conversation.date; + } + }, [conversation?.date]); + + + // Track whether the API has finished so checkmarks can catch up + const [generateDone, setGenerateDone] = useState(false); + const generatedNoteId = useRef(null); + + // Start generation immediately on mount (in parallel with checkmark animations) + useEffect(() => { + if (!session?.notes || !conversation || generationStartedRef.current) return; + generationStartedRef.current = true; + + // Use chunks for time range, fall back to conversation start/end times + const firstChunk = chunks[0]; + const lastChunk = chunks[chunks.length - 1]; + const startTime = firstChunk ? new Date(firstChunk.startTime) : conversation.startTime ? new Date(conversation.startTime) : undefined; + const endTime = lastChunk ? new Date(lastChunk.endTime) : conversation.endTime ? new Date(conversation.endTime) : undefined; + + (async () => { + try { + const note = await session.notes.generateNote( + conversation.title || undefined, + startTime, + endTime, + ); + if (note?.id && session?.conversation) { + await session.conversation.linkNoteToConversation(conversation.id, note.id); + } + generatedNoteId.current = note?.id || null; + setGenerateDone(true); + } catch (err) { + console.error("[GeneratingNotePage] Note generation failed:", err); + setLocation(`/conversation/${id}`); + } + })(); + }, [session, conversation, chunks, id, setLocation]); + + // Animate steps forward — random delay between 500ms and 2s per step + // Steps 0-2 animate on their own schedule; step 3 waits for API to finish + useEffect(() => { + if (activeStep >= 5) return; + // Step 3 (Generate) — only advance once API is done + if (activeStep === 3) { + if (!generateDone) return; + const timer = setTimeout(() => setActiveStep(4), 400); + return () => clearTimeout(timer); + } + // Step 4 (Safety Pass) — quick finish then navigate + if (activeStep === 4) { + const timer = setTimeout(() => { + setProgressPercent(100); + setActiveStep(5); + if (generatedNoteId.current) { + setTimeout(() => setLocation(`/note/${generatedNoteId.current}`), 600); + } + }, 600); + return () => clearTimeout(timer); + } + // Steps 0-2 — cosmetic animation + const delay = Math.random() * 1500 + 500; // 500ms – 2000ms + const timer = setTimeout(() => setActiveStep((s) => s + 1), delay); + return () => clearTimeout(timer); + }, [activeStep, generateDone, setLocation]); + + // Animate progress bar — starts immediately + useEffect(() => { + const interval = setInterval(() => { + setProgressPercent((p) => { + const cap = generateDone ? 100 : 90; + if (p >= cap) { + clearInterval(interval); + return cap; + } + return p + Math.random() * 8 + 2; + }); + }, 300); + return () => clearInterval(interval); + }, [generateDone]); + + if (!session || !conversation) { + return ( +
+
+ {!session ? "Loading..." : "Conversation not found"} +
+
+ ); + } + + const steps: Step[] = [ + { label: "Buffer", subtitle: `${chunks.length} chunks collected`, status: activeStep > 0 ? "done" : activeStep === 0 ? "active" : "pending" }, + { label: "Triage", subtitle: "Speech detected", status: activeStep > 1 ? "done" : activeStep === 1 ? "active" : "pending" }, + { label: "Track", subtitle: "Conversation ended", status: activeStep > 2 ? "done" : activeStep === 2 ? "active" : "pending" }, + { label: "Generate", subtitle: activeStep >= 3 ? "Creating structured note..." : "Pending", status: activeStep > 3 ? "done" : activeStep === 3 ? "active" : "pending" }, + { label: "Safety Pass", subtitle: activeStep > 4 ? "Complete" : "Pending", status: activeStep > 4 ? "done" : activeStep === 4 ? "active" : "pending" }, + ]; + + return ( +
+ {/* Header */} +
+ +
+ {dateLabel} +
+
+ + {/* Progress card */} +
+ {/* Card header */} +
+ + + +
+ {activeStep > 4 ? "Note generated" : "Generating note..."} +
+
+ {conversation.title || "Conversation"} +
+
+ + {/* Steps */} +
+ {steps.map((step) => ( +
+ {/* Icon */} +
+ {step.status === "done" ? ( + + + + ) : step.status === "active" ? ( +
+ ) : ( +
+ )} +
+ + {/* Label */} +
+ {step.label} +
+ + {/* Subtitle */} +
+ {step.subtitle} +
+
+ ))} +
+
+ + {/* Preview card */} +
+
+ {activeStep > 4 ? "Preview — complete" : "Preview — generating"} +
+ +
+
+ {conversation.title || "Untitled Conversation"} +
+
+ + {/* Summary preview */} + {conversation.aiSummary && ( +
+
+ SUMMARY +
+
+ {conversation.aiSummary.slice(0, 120)}{conversation.aiSummary.length > 120 ? "..." : ""} +
+
+ )} + + {/* Progress bar */} +
+
+
+
+ + ~{Math.round(Math.min(progressPercent, 100))}% + +
+
+
+ ); +} diff --git a/src/frontend/pages/day/DayPage.tsx b/src/frontend/pages/day/DayPage.tsx deleted file mode 100644 index 4e5c8fb..0000000 --- a/src/frontend/pages/day/DayPage.tsx +++ /dev/null @@ -1,392 +0,0 @@ -/** - * DayPage - Day detail view with tabs - * - * Displays a specific day's content with tabs for: - * - Notes: List of notes for this day - * - Transcript: Transcription segments grouped by hour - * - Audio: Audio recordings (future) - * - AI: AI chat interface for this day's content - */ - -import { useState, useMemo, useEffect, useRef, useTransition } from "react"; -import { useParams, useLocation } from "wouter"; -import { useMentraAuth } from "@mentra/react"; -import { format, parse } from "date-fns"; -import { clsx } from "clsx"; -import { AnimatePresence, motion } from "motion/react"; -import { - ChevronLeft, - Star, - FileText, - MessageSquare, - // Headphones, // TODO: Enable when audio feature is implemented - Sparkles, - Archive, - ArchiveRestore, - Trash2, - RotateCcw, - ListCollapse, - AlignJustify, -} from "lucide-react"; -import { useSynced } from "../../hooks/useSynced"; -import type { - SessionI, - Note, - TranscriptSegment, - HourSummary, -} from "../../../shared/types"; -import { NotesTab } from "./components/tabs/NotesTab"; -import { TranscriptTab } from "./components/tabs/TranscriptTab"; -// import { AudioTab } from "./components/tabs/AudioTab"; // TODO: Enable when audio feature is implemented -import { AITab } from "./components/tabs/AITab"; -import { TranscribingIndicator } from "../../components/shared/TranscribingIndicator"; -import { DropdownMenu, type DropdownMenuOption } from "../../components/shared/DropdownMenu"; -import { DayPageSkeleton } from "../../components/shared/SkeletonLoader"; - -type TabType = "notes" | "transcript" | "audio" | "ai"; - -interface TabConfig { - id: TabType; - label: string; - icon: typeof FileText; -} - -const tabs: TabConfig[] = [ - { id: "transcript", label: "Transcript", icon: MessageSquare }, - { id: "notes", label: "Notes", icon: FileText }, - // { id: "audio", label: "Audio", icon: Headphones }, // TODO: Enable when audio feature is implemented - { id: "ai", label: "AI", icon: Sparkles }, -]; - -export function DayPage() { - const params = useParams<{ date: string }>(); - const [, setLocation] = useLocation(); - const { userId } = useMentraAuth(); - const { session, isConnected } = useSynced(userId || ""); - - const [activeTab, setActiveTab] = useState("transcript"); - const lastLoadedDateRef = useRef(null); - const [isLoadingTranscript, setIsLoadingTranscript] = useState(false); - // Snapshot segment count when a historical date finishes loading, - // so new live segments don't bleed into the old file's view - const historicalSegmentCountRef = useRef(null); - - // Super collapsed mode from persisted settings - const isCompactMode = session?.settings?.superCollapsed ?? false; - const [, startTransition] = useTransition(); - const setIsCompactMode = (value: boolean) => { - startTransition(() => { - session?.settings?.updateSettings({ superCollapsed: value }); - }); - }; - - // Parse the date from URL params - const dateString = params.date || ""; - const date = useMemo(() => { - try { - return parse(dateString, "yyyy-MM-dd", new Date()); - } catch { - return new Date(); - } - }, [dateString]); - - // Check if this is today - const today = new Date(); - const todayString = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; - const isToday = dateString === todayString; - - // Get data from session - const allNotes = session?.notes?.notes ?? []; - const allSegments = session?.transcript?.segments ?? []; - const hourSummaries = session?.transcript?.hourSummaries ?? []; - const interimText = session?.transcript?.interimText ?? ""; - const isRecording = session?.transcript?.isRecording ?? false; - const isSyncingPhoto = session?.transcript?.isSyncingPhoto ?? false; - const loadedDate = session?.transcript?.loadedDate ?? ""; - const files = session?.file?.files ?? []; - - // Data is loading when the server hasn't confirmed this date's data yet. - // loadedDate is the source of truth for which date's segments are loaded. - // isLoadingTranscript gates the transition period when switching dates. - const isLoadingHistory = session?.transcript?.isLoadingHistory ?? false; - const dateMatchesServer = loadedDate === dateString; - const isActivelyLoading = isLoadingHistory || isLoadingTranscript; - const isDataLoading = isActivelyLoading || !dateMatchesServer; - - // Find the file for this date to get favourite status - const currentFile = useMemo(() => { - return files.find((f) => f.date === dateString); - }, [files, dateString]); - const isStarred = currentFile?.isFavourite ?? false; - const isArchived = currentFile?.isArchived ?? false; - const isTrashed = currentFile?.isTrashed ?? false; - - // Get current hour for determining which hour is "in progress" - const currentHour = new Date().getHours(); - - // Load historical transcript when viewing a past date - useEffect(() => { - if (!session?.transcript?.loadDateTranscript) return; - if (!dateString) return; - - // Skip if we already loaded this date - if (lastLoadedDateRef.current === dateString) return; - - // Skip if it's already the loaded date on the server - if (loadedDate === dateString) { - lastLoadedDateRef.current = dateString; - return; - } - - // Load the transcript for this date - console.log(`[DayPage] Loading transcript for ${dateString}`); - lastLoadedDateRef.current = dateString; - setIsLoadingTranscript(true); - - if (dateString === todayString) { - // Switch back to today - session.transcript.loadTodayTranscript() - .catch((err) => { - console.error("[DayPage] Failed to load today's transcript:", err); - }) - .finally(() => setIsLoadingTranscript(false)); - } else { - // Load historical date - session.transcript.loadDateTranscript(dateString) - .catch((err) => { - console.error( - `[DayPage] Failed to load transcript for ${dateString}:`, - err, - ); - }) - .finally(() => setIsLoadingTranscript(false)); - } - }, [dateString, todayString, loadedDate, session?.transcript]); - - // Filter notes for this day using the note's date field - // The date field stores the folder date (YYYY-MM-DD) that the note belongs to - const dayNotes = useMemo(() => { - return allNotes.filter((note) => { - // Use the note's date field if available, otherwise fallback to createdAt for backward compatibility - if (note.date) { - return note.date === dateString; - } - // Fallback for old notes without date field - const noteDate = note.createdAt ? new Date(note.createdAt) : new Date(); - const noteDateString = `${noteDate.getFullYear()}-${String(noteDate.getMonth() + 1).padStart(2, "0")}-${String(noteDate.getDate()).padStart(2, "0")}`; - return noteDateString === dateString; - }); - }, [allNotes, dateString]); - - // Snapshot segment count when a historical date finishes loading. - // This prevents live transcription segments from bleeding into the old file's view. - useEffect(() => { - if (!isDataLoading && loadedDate === dateString) { - if (isToday) { - historicalSegmentCountRef.current = null; // no cap for today - } else { - historicalSegmentCountRef.current = allSegments.length; - } - } - }, [isDataLoading, loadedDate, dateString, isToday, allSegments.length]); - - // Reset snapshot when navigating to a new date - useEffect(() => { - historicalSegmentCountRef.current = null; - }, [dateString]); - - // Filter transcript segments for this day - const daySegments = useMemo(() => { - // While loading, return empty to prevent stale data from flashing - if (isDataLoading) return []; - - // Server loaded data for this date - if (loadedDate === dateString) { - // For historical dates, cap to the snapshot count to prevent - // live transcription segments from appearing in old files - if (!isToday && historicalSegmentCountRef.current !== null) { - return allSegments.slice(0, historicalSegmentCountRef.current); - } - return allSegments; - } - - // Fallback: filter by extracting YYYY-MM-DD from the UTC ISO timestamp - return allSegments.filter((segment) => { - if (!segment.timestamp) return false; - const iso = segment.timestamp instanceof Date - ? segment.timestamp.toISOString() - : String(segment.timestamp); - return iso.slice(0, 10) === dateString; - }); - }, [allSegments, dateString, loadedDate, isDataLoading, isToday]); - - if (!session) { - return ; - } - - const handleBack = () => { - setLocation("/"); - }; - - const formatHeaderDate = () => { - return format(date, "MMMM d, yyyy"); - }; - - return ( -
- {/* Header */} -
- {/* Top row with back button and actions */} -
- - -

- {formatHeaderDate()} -

- -
- - {/* Only show options menu for past dates, not today */} - {!isToday && ( - { - if (!session?.file) return; - if (isArchived) { - session.file.unarchiveFile(dateString); - } else { - session.file.archiveFile(dateString); - } - }, - }, - { type: "divider" }, - { - id: "trash", - label: isTrashed ? "Restore" : "Move to Trash", - icon: isTrashed ? RotateCcw : Trash2, - danger: !isTrashed, - onClick: () => { - if (!session?.file) return; - if (isTrashed) { - session.file.restoreFile(dateString); - } else { - session.file.trashFile(dateString); - } - }, - }, - ] as DropdownMenuOption[]} - /> - )} -
-
- - {/* Tabs */} -
- {tabs.map((tab) => ( - - ))} - - {/* Compact mode toggle - only shown on transcript tab */} - {activeTab === "transcript" && ( - - )} -
- - {/* Recording indicator */} - {isToday && isRecording && ( -
- -
- )} -
- - {/* Tab Content */} -
- - - {activeTab === "notes" && ( - - )} - {activeTab === "transcript" && ( - - )} - {/* {activeTab === "audio" && } */} - {activeTab === "ai" && } - - -
-
- ); -} diff --git a/src/frontend/pages/day/components/NoteCard.tsx b/src/frontend/pages/day/components/NoteCard.tsx index 9abe1cb..7ed18b4 100644 --- a/src/frontend/pages/day/components/NoteCard.tsx +++ b/src/frontend/pages/day/components/NoteCard.tsx @@ -11,11 +11,14 @@ */ import { clsx } from "clsx"; +import { Check } from "lucide-react"; import type { Note } from "../../../../shared/types"; interface NoteCardProps { note: Note; onClick: () => void; + selectionMode?: boolean; + isSelected?: boolean; } /** @@ -81,7 +84,7 @@ function getPreviewText(note: Note): string { return plainText; } -export function NoteCard({ note, onClick }: NoteCardProps) { +export function NoteCard({ note, onClick, selectionMode = false, isSelected = false }: NoteCardProps) { // Use the isAIGenerated field, fallback to checking transcriptRange for old notes const isAIGenerated = note.isAIGenerated ?? !!note.transcriptRange; const previewText = getPreviewText(note); @@ -116,8 +119,30 @@ export function NoteCard({ note, onClick }: NoteCardProps) { return (
+ {/* Selection circle */} + {selectionMode && ( +
+ {isSelected && } +
+ )} + {/* Title */}

{note.title || "Untitled Note"} diff --git a/src/frontend/pages/day/components/tabs/AITab.tsx b/src/frontend/pages/day/components/tabs/AITab.tsx index 93e48a7..d164012 100644 --- a/src/frontend/pages/day/components/tabs/AITab.tsx +++ b/src/frontend/pages/day/components/tabs/AITab.tsx @@ -132,7 +132,7 @@ export function AITab({ date, isLoading = false }: AITabProps) { {/* Chat Area */}
{/* Welcome message if no messages */} {messages.length === 0 && ( diff --git a/src/frontend/pages/day/components/tabs/ConversationsTab.tsx b/src/frontend/pages/day/components/tabs/ConversationsTab.tsx new file mode 100644 index 0000000..e749b5f --- /dev/null +++ b/src/frontend/pages/day/components/tabs/ConversationsTab.tsx @@ -0,0 +1,491 @@ +/** + * ConversationsTab - Displays auto-detected conversations for a specific day + * + * Shows: + * - List of conversation cards with status badges (Live/Paused/Ended) + * - Expandable cards showing summary + transcript + * - Empty state when no conversations detected + */ + +import { useState, useRef, useMemo, useEffect, memo } from "react"; +import { clsx } from "clsx"; +import { AnimatePresence, motion, useMotionValue, useTransform, animate, type PanInfo } from "motion/react"; +import { + MessagesSquare, + ChevronDown, + ChevronUp, + Trash2, + Loader2, + Sparkles, +} from "lucide-react"; +import type { Conversation, TranscriptSegment } from "../../../../../shared/types"; + +interface ConversationsTabProps { + conversations: Conversation[]; + segments: TranscriptSegment[]; + isLoading?: boolean; + onDeleteConversation?: (conversationId: string) => void; + initialExpandedId?: string | null; +} + +export function ConversationsTab({ + conversations, + segments, + isLoading = false, + onDeleteConversation, + initialExpandedId = null, +}: ConversationsTabProps) { + const [expandedIds, setExpandedIds] = useState>( + initialExpandedId ? new Set([initialExpandedId]) : new Set(), + ); + const scrollTargetRef = useRef(null); + + // Scroll to the initially expanded conversation + useEffect(() => { + if (initialExpandedId && scrollTargetRef.current) { + scrollTargetRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, [initialExpandedId]); + + // Loading state + if (isLoading) { + return ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + // Empty state + if (conversations.length === 0) { + return ( +
+
+ +
+

+ No conversations detected yet +

+

+ Conversations will appear here as they're automatically detected from + your transcript. +

+
+ ); + } + + return ( +
+
+ {/* Conversation cards */} + + {conversations.map((conversation) => ( + + + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(conversation.id)) next.delete(conversation.id); + else next.add(conversation.id); + return next; + }) + } + onDelete={onDeleteConversation} + /> + + ))} + +
+
+ ); +} + +// ============================================================================= +// ConversationCard +// ============================================================================= + +interface ConversationCardProps { + conversation: Conversation; + segments: TranscriptSegment[]; + isExpanded: boolean; + onToggle: () => void; + onDelete?: (conversationId: string) => void; +} + +const ConversationCard = memo(function ConversationCard({ + conversation, + segments, + isExpanded, + onToggle, + onDelete, +}: ConversationCardProps) { + const [activeSection, setActiveSection] = useState<"summary" | "transcript">( + "summary", + ); + const x = useMotionValue(0); + const deleteOpacity = useTransform(x, [-120, -60], [1, 0]); + const isDragging = useRef(false); + + const timeRange = formatTimeRange( + conversation.startTime, + conversation.endTime, + ); + + const previewText = conversation.runningSummary || ""; + const preview = + previewText.length > 120 + ? previewText.substring(0, 120) + "..." + : previewText; + + const snapBackTimeout = useRef | null>(null); + + const handleDragEnd = (_: unknown, info: PanInfo) => { + if (info.offset.x < -100 && onDelete) { + // Slide card off-screen, then delete + animate(x, -window.innerWidth, { duration: 0.25, ease: "easeIn" }).then(() => { + onDelete(conversation.id); + }); + } else { + // Stay in place briefly, then slide back smoothly + if (snapBackTimeout.current) clearTimeout(snapBackTimeout.current); + snapBackTimeout.current = setTimeout(() => { + x.set(0); + }, 1500); + } + }; + + return ( +
+ {/* Delete background */} + {onDelete && ( + + + + )} + + { isDragging.current = true; }} + onDragEnd={(_, info) => { + isDragging.current = false; + handleDragEnd(_, info); + }} + className="relative rounded-xl bg-zinc-50 dark:bg-zinc-900 overflow-hidden"> + {/* Card header — always visible */} +
{ + if (isDragging.current) return; + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) return; + onToggle(); + }} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggle(); } }} + className="w-full text-left p-4 flex flex-col gap-2 cursor-pointer select-text" + > +
+
+

+ {conversation.title + ? conversation.title + : conversation.status === "ended" && !conversation.generatingSummary && !conversation.aiSummary + ? "Untitled Conversation" + : <> + + + {conversation.generatingSummary ? "Generating title..." : "Capturing Conversation..."} + + + } +

+

+ {timeRange} +

+
+ +
+ {conversation.status !== "ended" && } + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Preview — only when collapsed */} + {!isExpanded && preview && ( +

+ {preview} +

+ )} +
+ + {/* Expanded content */} + {isExpanded && ( +
+ {/* Section toggle */} +
+ + +
+ +
+ {activeSection === "summary" ? ( + + ) : ( + + )} +
+ +
+ )} +
+
+ ); +}); + +// ============================================================================= +// StatusBadge +// ============================================================================= + +function StatusBadge({ conversation }: { conversation: Conversation }) { + switch (conversation.status) { + case "active": + return ( + + + Live + + ); + case "paused": + return ( + + Paused + + ); + case "ended": + return ( + + Ended + + ); + } +} + +// ============================================================================= +// SummarySection — lightweight HTML renderer +// ============================================================================= + +function parseMarkdownToHtml(text: string): string { + return text + .replace(/^### (.*$)/gim, "

$1

") + .replace(/^## (.*$)/gim, "

$1

") + .replace(/^# (.*$)/gim, "

$1

") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/^[-•]\s+(.+)$/gm, "
  • $1
  • ") + .replace(/(
  • .*<\/li>\n?)+/g, (match) => `
      ${match}
    `) + .split("\n\n") + .map((p) => p.trim()) + .filter((p) => p) + .map((p) => { + if (p.startsWith("${p.replace(/\n/g, "
    ")}

    `; + }) + .join(""); +} + +function ReadOnlyHtml({ content }: { content: string }) { + const html = useMemo(() => { + if (content.includes("

    ") || content.includes(" + ); +} + +function SummarySection({ + conversation, +}: { + conversation: Conversation; +}) { + // AI summary available — render with read-only Tiptap + if (conversation.aiSummary) { + return ; + } + + // Generating AI summary + if (conversation.generatingSummary) { + return ( +

    +
    + +
    +

    + Generating summary... +

    +
    + ); + } + + // Live/paused — show waiting message (summary generates after conversation ends) + return ( +
    +
    + +
    +

    + Summary will be generated when the conversation ends. +

    +
    + ); +} + +// ============================================================================= +// TranscriptSection +// ============================================================================= + +const TranscriptSection = memo(function TranscriptSection({ + segments, + startTime, + endTime, +}: { + segments: TranscriptSegment[]; + startTime: Date; + endTime: Date | null; +}) { + // Filter segments that fall within the conversation's time range + const conversationSegments = useMemo(() => { + const start = new Date(startTime).getTime(); + const end = endTime ? new Date(endTime).getTime() : Date.now(); + + return segments.filter((seg) => { + if (!seg.isFinal || seg.type === "photo") return false; + const segTime = new Date(seg.timestamp).getTime(); + return segTime >= start && segTime <= end; + }); + }, [segments, startTime, endTime]); + + if (conversationSegments.length === 0) { + return ( +

    + No transcript segments yet. +

    + ); + } + + return ( +
    + {conversationSegments.map((segment) => ( + + ))} +
    + ); +}); + +const SegmentRow = memo(function SegmentRow({ segment }: { segment: TranscriptSegment }) { + return ( +
    + + {formatTime(segment.timestamp)} + +

    + {segment.text} +

    +
    + ); +}); + +// ============================================================================= +// Helpers +// ============================================================================= + +function formatTime(date: Date | string): string { + const d = date instanceof Date ? date : new Date(date); + return d.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); +} + +function formatTimeRange( + start: Date | string, + end: Date | string | null, +): string { + const startStr = formatTime(start); + if (!end) return `${startStr} – now`; + return `${startStr} – ${formatTime(end)}`; +} + diff --git a/src/frontend/pages/day/components/tabs/NotesTab.tsx b/src/frontend/pages/day/components/tabs/NotesTab.tsx index a685112..e07a4b2 100644 --- a/src/frontend/pages/day/components/tabs/NotesTab.tsx +++ b/src/frontend/pages/day/components/tabs/NotesTab.tsx @@ -3,295 +3,34 @@ * * Shows: * - Masonry grid of note cards (manual and AI-generated) - * - FAB with floating action options (Generate Note, Add Note) */ -import { useState, useEffect, useRef } from "react"; import { useLocation } from "wouter"; -import { useMentraAuth } from "@mentra/react"; -import { Loader2, FileText, Sparkles, Clock, PencilLine, X } from "lucide-react"; -import { motion, AnimatePresence } from "motion/react"; -import { Drawer } from "vaul"; import { clsx } from "clsx"; +import { FileText } from "lucide-react"; import Masonry, { ResponsiveMasonry } from "react-responsive-masonry"; -import { useSynced } from "../../../../hooks/useSynced"; -import type { SessionI, Note } from "../../../../../shared/types"; +import type { Note } from "../../../../../shared/types"; import { NoteCard } from "../NoteCard"; interface NotesTabProps { notes: Note[]; - dateString: string; isLoading?: boolean; + selectionMode?: boolean; + selectedNoteIds?: Set; + onToggleSelection?: (noteId: string) => void; } -export function NotesTab({ notes, dateString, isLoading = false }: NotesTabProps) { - const { userId } = useMentraAuth(); - const { session } = useSynced(userId || ""); +export function NotesTab({ notes, isLoading = false, selectionMode = false, selectedNoteIds, onToggleSelection }: NotesTabProps) { const [, setLocation] = useLocation(); - const [isExpanded, setIsExpanded] = useState(false); - const [showTimeRangePicker, setShowTimeRangePicker] = useState(false); - const [startTime, setStartTime] = useState(""); - const [endTime, setEndTime] = useState(""); - const fabRef = useRef(null); - - const generating = session?.notes?.generating ?? false; - - // Close FAB when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (fabRef.current && !fabRef.current.contains(event.target as Node)) { - setIsExpanded(false); - } - }; - - if (isExpanded) { - document.addEventListener("mousedown", handleClickOutside); - } - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isExpanded]); - - // Set default times when opening time picker - useEffect(() => { - if (showTimeRangePicker) { - const now = new Date(); - const currentHour = now.getHours(); - setStartTime(`${String(currentHour).padStart(2, "0")}:00`); - setEndTime(`${String((currentHour + 1) % 24).padStart(2, "0")}:00`); - } - }, [showTimeRangePicker]); - - const handleOpenTimeRangePicker = () => { - setIsExpanded(false); - setShowTimeRangePicker(true); - }; - - const handleGenerateNote = async () => { - if (!session?.notes?.generateNote) return; - - try { - // Parse the dateString to get the correct date - const [year, month, day] = dateString.split("-").map(Number); - const [startHour, startMin] = startTime.split(":").map(Number); - const [endHour, endMin] = endTime.split(":").map(Number); - - const startDate = new Date(year, month - 1, day, startHour, startMin); - const endDate = new Date(year, month - 1, day, endHour, endMin); - - setShowTimeRangePicker(false); - - const note = await session.notes.generateNote( - undefined, - startDate, - endDate, - ); - if (note?.id) { - setLocation(`/note/${note.id}`); - } - } catch (err) { - console.error("[NotesTab] Failed to generate note:", err); - } - }; - - const handleCreateManualNote = async () => { - if (!session?.notes?.createManualNote) return; - setIsExpanded(false); - - try { - const note = await session.notes.createManualNote("New Note", ""); - // Navigate to the note editor - setLocation(`/note/${note.id}`); - } catch (err) { - console.error("[NotesTab] Failed to create note:", err); - } - }; - - // Floating Action Button JSX (rendered directly, not as a component) - const floatingActions = ( -
    - {/* Expanded options */} - - {isExpanded && ( - <> - {/* Generate Note option */} - - - Generate note - -
    - -
    -
    - - {/* Add Note option */} - - - Add note - -
    - -
    -
    - - )} -
    - - {/* Main FAB button */} - -
    - ); - const handleNoteClick = (note: Note) => { + if (selectionMode && onToggleSelection) { + onToggleSelection(note.id); + return; + } setLocation(`/note/${note.id}`); }; - // Time Range Picker Drawer JSX (rendered directly, not as a component) - const timeRangePickerDrawer = ( - - - - - {/* Handle */} -
    - - {/* Header */} -
    - - Generate Summary - - - Select a time range to generate a summary - -
    - - {/* Content */} -
    -
    -

    - Select a time range from the transcript to generate a focused - summary note. -

    - - {/* Time Inputs */} -
    -
    - -
    - setStartTime(e.target.value)} - className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white" - /> - -
    -
    -
    - -
    - setEndTime(e.target.value)} - className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white" - /> - -
    -
    -
    - - {/* Actions */} -
    - - - - -
    -
    -
    - - {/* Safe area padding for mobile */} -
    - - - - ); - // Loading state if (isLoading) { return ( @@ -330,33 +69,32 @@ export function NotesTab({ notes, dateString, isLoading = false }: NotesTabProps
  • Create a note manually or generate one from the transcript using the - button below. + pencil button below.

    - - {floatingActions} - {timeRangePickerDrawer}
    ); } return (
    -
    +
    {/* Masonry Grid */} {notes.map((note) => (
    - handleNoteClick(note)} /> + handleNoteClick(note)} + selectionMode={selectionMode} + isSelected={selectedNoteIds?.has(note.id) ?? false} + />
    ))}
    - - {floatingActions} - {timeRangePickerDrawer}
    ); } diff --git a/src/frontend/pages/day/components/tabs/TranscriptTab.tsx b/src/frontend/pages/day/components/tabs/TranscriptTab.tsx index 650b2eb..ea63517 100644 --- a/src/frontend/pages/day/components/tabs/TranscriptTab.tsx +++ b/src/frontend/pages/day/components/tabs/TranscriptTab.tsx @@ -9,20 +9,83 @@ * - Auto-scroll for new segments (only when user is near bottom) */ -import { useState, useRef, useCallback, useMemo, useEffect } from "react"; +import { useState, useRef, useCallback, useMemo, useEffect, memo } from "react"; import { clsx } from "clsx"; -import { ArrowDown, ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { ArrowDown, ChevronDown, Loader2 } from "lucide-react"; +import { DotsSpinner } from "../../../../components/shared/DotsSpinner"; +import { WaveIndicator } from "../../../../components/shared/WaveIndicator"; import type { TranscriptSegment, HourSummary, } from "../../../../../shared/types"; +// Memoized segment row to prevent re-renders when siblings update +const SegmentRow = memo(function SegmentRow({ + segment, + formatTime, + getPhotoSrc, + onImageLoad, + isLive, +}: { + segment: TranscriptSegment; + formatTime: (timestamp: Date | string) => string; + getPhotoSrc: (url: string) => string; + onImageLoad?: (e: React.SyntheticEvent) => void; + isLive?: boolean; +}) { + if (segment.type === "photo" && segment.photoUrl) { + return ( +
    + Photo capture +
    + ); + } + + const speakerLabel = segment.speakerId ? `Speaker ${segment.speakerId}` : "Speaker"; + const timeLabel = segment.timestamp ? formatTime(segment.timestamp) : ""; + + return ( +
    +
    + + {speakerLabel}{timeLabel ? ` · ${timeLabel}` : ""} + + {isLive && } +
    +

    + {segment.text} +

    + {/* {isLive && ( +
    +
    +
    +
    +
    + )} */} +
    + ); +}); + interface TranscriptTabProps { segments: TranscriptSegment[]; hourSummaries?: HourSummary[]; interimText?: string; currentHour?: number; // Only provided for "today" - undefined for historical days dateString: string; + timezone?: string; // IANA timezone for correct hour grouping (e.g., "America/Los_Angeles") onGenerateSummary?: (hour: number) => Promise; isCompactMode?: boolean; // When true, all hours show in minimal/compact view isSyncingPhoto?: boolean; // When true, a photo is being uploaded/analyzed @@ -33,13 +96,11 @@ interface GroupedSegments { [hourKey: string]: TranscriptSegment[]; } -// R2 private endpoint → public URL rewrite for legacy segments -const R2_PRIVATE_PREFIX = "https://3c764e987404b8a1199ce5fdc3544a94.r2.cloudflarestorage.com/mentra-notes/"; -const R2_PUBLIC_PREFIX = "https://pub-b5f134142a0f4fbdb5c05a2f75fc8624.r2.dev/"; +import { R2_PRIVATE_URL_PREFIX, R2_PUBLIC_URL_PREFIX } from "../../../../../shared/constants"; function getPhotoSrc(url: string): string { - if (url.startsWith(R2_PRIVATE_PREFIX)) { - return url.replace(R2_PRIVATE_PREFIX, R2_PUBLIC_PREFIX); + if (url.startsWith(R2_PRIVATE_URL_PREFIX)) { + return url.replace(R2_PRIVATE_URL_PREFIX, R2_PUBLIC_URL_PREFIX); } return url; } @@ -53,18 +114,41 @@ export function TranscriptTab({ interimText = "", currentHour, dateString, - onGenerateSummary, + timezone, + onGenerateSummary: _onGenerateSummary, isCompactMode = false, isSyncingPhoto = false, isLoading = false, }: TranscriptTabProps) { // Track expanded state for each hour (only used when not in compact mode) const [expandedHours, setExpandedHours] = useState>(new Set()); - const [generatingHour, setGeneratingHour] = useState(null); - const [loadingHour, setLoadingHour] = useState(null); + // Hours currently showing spinner (min 2s before content reveals) + const [loadingHours, setLoadingHours] = useState>(new Set()); + // Measured spinner height per hour (header bottom → container bottom) + const [spinnerHeights, setSpinnerHeights] = useState>(new Map()); const scrollContainerRef = useRef(null); const headerRefs = useRef>(new Map()); + // Track the last interim text so we can keep it visible (with finalized styling) + // until the matching final segment actually appears in the segments array. + // This prevents the "jump" where interim disappears before the final segment arrives. + const lastInterimRef = useRef(""); + const lastSegmentCountRef = useRef(segments.length); + + // When interimText is non-empty, track it + if (interimText.trim()) { + lastInterimRef.current = interimText; + } + + // When a new segment arrives and interim is cleared, clear the stale interim + if (segments.length > lastSegmentCountRef.current && !interimText.trim()) { + lastInterimRef.current = ""; + } + lastSegmentCountRef.current = segments.length; + + // The text to display as "live" — either actual interim, or the finalized-but-not-yet-in-segments text + const displayInterimText = interimText.trim() || lastInterimRef.current; + // Helper to get hour state based on compact mode and expanded state const getHourState = (hourKey: string): HourState => { if (expandedHours.has(hourKey)) return "expanded"; @@ -88,12 +172,23 @@ export function TranscriptTab({ return `${hour.toString().padStart(2, "0")}:00|${hour12} ${ampm}`; }; + // Extract hour (0-23) from a timestamp in the user's timezone + const getHourInTimezone = (timestamp: Date | string): number => { + const date = typeof timestamp === "string" ? new Date(timestamp) : timestamp; + if (timezone) { + const parts = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + hour12: false, + timeZone: timezone, + }).formatToParts(date); + return parseInt(parts.find((p) => p.type === "hour")?.value || "0", 10); + } + return date.getHours(); + }; + // Parse timestamp and return hour key for grouping const getHourKey = (timestamp: Date | string): string => { - const date = - typeof timestamp === "string" ? new Date(timestamp) : timestamp; - const hour = date.getHours(); - return createHourKey(hour); + return createHourKey(getHourInTimezone(timestamp)); }; // Format timestamp for display (12-hour with AM/PM) @@ -104,10 +199,12 @@ export function TranscriptTab({ hour: "numeric", minute: "2-digit", hour12: true, + ...(timezone && { timeZone: timezone }), }); }; // Group segments by hour (memoized to avoid re-computing on every render) + // Also ensures the current hour appears even if only interim text exists (no final segments yet) const { groupedSegments, sortedHours } = useMemo(() => { const grouped: GroupedSegments = segments.reduce((acc, segment) => { if (!segment.timestamp) return acc; @@ -119,6 +216,15 @@ export function TranscriptTab({ return acc; }, {} as GroupedSegments); + // If there's interim/finalizing text and a current hour, ensure that hour exists in the groups + // so the hour section renders immediately (before any final segment arrives) + if (currentHour !== undefined && displayInterimText.length > 0) { + const currentHourKey = createHourKey(currentHour); + if (!grouped[currentHourKey]) { + grouped[currentHourKey] = []; + } + } + const sorted = Object.keys(grouped).sort((a, b) => { const { hour24: hourA } = parseHourKey(a); const { hour24: hourB } = parseHourKey(b); @@ -126,7 +232,7 @@ export function TranscriptTab({ }); return { groupedSegments: grouped, sortedHours: sorted }; - }, [segments]); + }, [segments, currentHour, displayInterimText]); // Get summary for a specific hour const getHourSummary = (hour: number): HourSummary | undefined => { @@ -156,6 +262,7 @@ export function TranscriptTab({ }; }; + /** * Get banner content for an hour * Returns parsed title/body if summary available, otherwise first segment preview @@ -197,14 +304,6 @@ export function TranscriptTab({ }; }; - /** - * Check if this is the current hour and has interim text - */ - const hasInterimForHour = (hourKey: string): boolean => { - const { hour24 } = parseHourKey(hourKey); - const isCurrentHour = currentHour !== undefined && hour24 === currentHour; - return isCurrentHour && interimText.trim().length > 0; - }; // Tracks which hour we last expanded — images loading in this section will re-trigger scroll const activeScrollHourRef = useRef(null); @@ -246,129 +345,186 @@ export function TranscriptTab({ } }, [scrollToEndOfHour]); - // When loadingHour clears (content rendered), scroll to the end of that hour - const pendingScrollRef = useRef(null); + // Called when content mounts after spinner — immediately scroll to bottom of that hour + const handleContentReady = useCallback((hourKey: string) => { + // Stop the pin loop now that content is laid out + pinningHourRef.current = null; - useEffect(() => { - if (pendingScrollRef.current && !loadingHour) { - const hourKey = pendingScrollRef.current; - pendingScrollRef.current = null; - activeScrollHourRef.current = hourKey; - // Double rAF to ensure layout is complete after React render - requestAnimationFrame(() => { - requestAnimationFrame(() => { - scrollToEndOfHour(hourKey); - }); - }); + activeScrollHourRef.current = hourKey; + // Use rAF to ensure DOM has painted the content before measuring + requestAnimationFrame(() => { + scrollToEndOfHour(hourKey); // Stop re-scrolling on image loads after a generous timeout setTimeout(() => { if (activeScrollHourRef.current === hourKey) { activeScrollHourRef.current = null; } }, 3000); - } - }, [loadingHour, scrollToEndOfHour]); + }); + }, [scrollToEndOfHour]); + + // Scroll a header to the top of the scroll container + const scrollHeaderToTop = useCallback((hourKey: string, behavior: ScrollBehavior = "smooth") => { + const container = scrollContainerRef.current; + const header = headerRefs.current.get(hourKey); + if (!container || !header) return; + + const containerRect = container.getBoundingClientRect(); + const headerRect = header.getBoundingClientRect(); + const targetScroll = headerRect.top - containerRect.top + container.scrollTop; + + container.scrollTo({ + top: targetScroll, + behavior, + }); + }, []); + + // rAF loop that continuously pins a header to the top during content animation + const pinningHourRef = useRef(null); + + const startPinningHeader = useCallback((hourKey: string) => { + pinningHourRef.current = hourKey; + + const pin = () => { + if (pinningHourRef.current !== hourKey) return; + scrollHeaderToTop(hourKey, "instant"); + requestAnimationFrame(pin); + }; + requestAnimationFrame(pin); + }, [scrollHeaderToTop]); // Toggle between collapsed/veryCollapsed and expanded const toggleHour = (hourKey: string) => { const wasExpanded = expandedHours.has(hourKey); if (wasExpanded) { - // Collapsing — clear any active scroll tracking + // Collapsing — clear all tracking: pin loop, auto-scroll suppression, active scroll + pinningHourRef.current = null; activeScrollHourRef.current = null; + setLoadingHours((prev) => { + const newSet = new Set(prev); + newSet.delete(hourKey); + return newSet; + }); setExpandedHours((prev) => { const newSet = new Set(prev); newSet.delete(hourKey); return newSet; }); } else { - // Expanding — show spinner first, then render content + scroll - setLoadingHour(hourKey); + // Calculate spinner height as: container height - header height + // This is the exact space below the header once it's pinned to the top + const container = scrollContainerRef.current; + const header = headerRefs.current.get(hourKey); + if (container && header) { + const containerHeight = container.clientHeight; + const headerHeight = header.getBoundingClientRect().height; + setSpinnerHeights((prev) => new Map(prev).set(hourKey, Math.max(containerHeight - headerHeight, 200))); + } + + // Expanding — pin header to top, show spinner for min 2s setExpandedHours((prev) => { const newSet = new Set(prev); newSet.add(hourKey); return newSet; }); - pendingScrollRef.current = hourKey; + setLoadingHours((prev) => { + const newSet = new Set(prev); + newSet.add(hourKey); + return newSet; + }); + + // Suppress MutationObserver auto-scroll so it doesn't fight our scroll-to-header - // Brief delay to let the spinner show, then clear loading to render segments + // Smooth-scroll header to top after React renders the expanded state requestAnimationFrame(() => { - setLoadingHour(null); + scrollHeaderToTop(hourKey); }); + + // Clear loading after 2 seconds — content is already rendered (hidden), animation will then play + setTimeout(() => { + // Start rAF loop to keep header pinned during content animation + startPinningHeader(hourKey); + + setLoadingHours((prev) => { + const newSet = new Set(prev); + newSet.delete(hourKey); + return newSet; + }); + // Re-enable auto-scroll after content animation starts + setTimeout(() => { + }, 400); + }, 1000); } }; - // Lock/unlock scroll: when locked (near bottom), auto-scroll on DOM changes. - // When unlocked (user scrolled up), stop auto-scrolling and show a FAB to re-lock. + // Scroll button + auto-scroll only when at the bottom. + // Touch scroll up → unlock. Button or manual scroll back to bottom → re-lock. const isLive = currentHour !== undefined; - const [isScrollLocked, setIsScrollLocked] = useState(true); - const isScrollLockedRef = useRef(true); - const suppressAutoScrollRef = useRef(false); + const [showScrollButton, setShowScrollButton] = useState(false); + const lockedRef = useRef(true); + // Initial scroll to bottom on first load only + const initialScrollDone = useRef(false); + useEffect(() => { + if (isLoading || initialScrollDone.current) return; + const container = scrollContainerRef.current; + if (!container) return; + initialScrollDone.current = true; + lockedRef.current = true; + container.scrollTo({ top: container.scrollHeight, behavior: "instant" }); + }, [isLoading]); - // Suppress auto-scroll briefly when compact mode changes to prevent layout shift + // Reset initial scroll flag when date changes useEffect(() => { - suppressAutoScrollRef.current = true; - const timer = setTimeout(() => { suppressAutoScrollRef.current = false; }, 200); - return () => clearTimeout(timer); - }, [isCompactMode]); + initialScrollDone.current = false; + }, [dateString]); useEffect(() => { const container = scrollContainerRef.current; if (!container) return; - const LOCK_THRESHOLD = 100; + const isNearBottom = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + return scrollHeight - scrollTop - clientHeight < 200; + }; - // Update lock state based on scroll position + // Detect scroll position to lock/unlock — works for both touch and mouse const handleScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = container; - const distFromBottom = scrollHeight - scrollTop - clientHeight; - const locked = distFromBottom <= LOCK_THRESHOLD; - isScrollLockedRef.current = locked; - setIsScrollLocked(locked); + if (isNearBottom()) { + lockedRef.current = true; + setShowScrollButton(false); + } else { + lockedRef.current = false; + setShowScrollButton(true); + } }; - // Auto-scroll on DOM mutations only when locked and not suppressed + // Auto-scroll on new child elements only when locked + // Removed characterData to avoid interim text causing scroll jank const observer = new MutationObserver(() => { - if (!isScrollLockedRef.current || suppressAutoScrollRef.current) return; - container.scrollTo({ top: container.scrollHeight, behavior: "instant" }); + if (!lockedRef.current) return; + requestAnimationFrame(() => { + container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); + }); }); - observer.observe(container, { childList: true, subtree: true, characterData: true }); - container.addEventListener("scroll", handleScroll); - - // Initial scroll to bottom - container.scrollTo({ top: container.scrollHeight, behavior: "instant" }); + container.addEventListener("scroll", handleScroll, { passive: true }); + observer.observe(container, { childList: true, subtree: true }); return () => { - observer.disconnect(); container.removeEventListener("scroll", handleScroll); + observer.disconnect(); }; - }, []); + }, [isLoading]); - // Scroll to bottom and re-lock - const scrollToBottomAndLock = useCallback(() => { + const scrollToBottom = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; - container.scrollTo({ top: container.scrollHeight, behavior: "instant" }); - isScrollLockedRef.current = true; - setIsScrollLocked(true); + lockedRef.current = true; + setShowScrollButton(false); + container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); }, []); - // Handle generating summary for an hour - const handleGenerateSummary = async (e: React.MouseEvent, hour: number) => { - e.stopPropagation(); // Don't toggle expand - - if (!onGenerateSummary || generatingHour !== null) return; - - setGeneratingHour(hour); - try { - await onGenerateSummary(hour); - } catch (error) { - console.error("Failed to generate summary:", error); - } finally { - setGeneratingHour(null); - } - }; if (isLoading) { return ( @@ -393,7 +549,7 @@ export function TranscriptTab({ ); } - if (segments.length === 0) { + if (segments.length === 0 && sortedHours.length === 0) { return (
    @@ -406,238 +562,251 @@ export function TranscriptTab({ ); } + // How many segments to show before a "+N more" collapse + const PREVIEW_COUNT = 2; + return (
    -
    +
    {sortedHours.map((hourKey) => { const hourSegments = groupedSegments[hourKey]; const { hour24, label: hourLabel } = parseHourKey(hourKey); const hourState = getHourState(hourKey); - const isCollapsed = hourState === "collapsed"; const isExpanded = hourState === "expanded"; - const isCurrentHour = - currentHour !== undefined && hour24 === currentHour; - + const isCurrentHour = currentHour !== undefined && hour24 === currentHour; const banner = getBannerContent(hourKey, hourSegments); - const summary = getHourSummary(hour24); - const hasSummary = summary && summary.segmentCount > 0; - const isGenerating = generatingHour === hour24; + const hasSummary = !!(getHourSummary(hour24)?.segmentCount); + const segCount = hourSegments.length; + + // For compact (veryCollapsed) mode, show a minimal one-line row + if (hourState === "veryCollapsed") { + return ( +
    +
    + + {hourLabel} + +
    + +
    + ); + } return (
    - {/* Hour Header - Sticky when expanded */} -
    )} - {/* Banner Content (when collapsed - normal state) */} - {isCollapsed && ( -
    - {/* Title + Body (when summary exists) */} - {banner.hasSummary && banner.title ? ( - <> -

    - {banner.title} -

    - {banner.body && ( -

    - {banner.body} -

    - )} - - ) : ( - /* Preview (when no summary) */ -

    - {banner.preview} -

    - )} - - {/* Interim text shown BELOW summary for current hour */} - {hasInterimForHour(hourKey) && ( -

    - {interimText} -

    + {/* Collapsed: show preview segments + expand row */} + {!isExpanded && ( + <> + {/* First PREVIEW_COUNT final segments */} + {hourSegments.filter(s => s.isFinal && s.type !== "photo").slice(0, PREVIEW_COUNT).map((segment, idx) => ( + + ))} + + {/* "+N more segments" divider if there are more */} + {segCount > PREVIEW_COUNT && ( + )} - {/*
    - - {hourSegments.length} segment - {hourSegments.length !== 1 ? "s" : ""} - - {!banner.hasSummary && onGenerateSummary && ( - handleGenerateSummary(e, hour24)} - className={clsx( - "text-xs font-medium flex items-center gap-1 transition-colors cursor-pointer", - isGenerating - ? "text-zinc-400 dark:text-zinc-500" - : "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200", - )} - > - {isGenerating ? ( - <> - - Summarizing... - - ) : ( - Generate summary - )} + {/* If ≤ PREVIEW_COUNT segments, still need the header ref + expand affordance */} + {segCount <= PREVIEW_COUNT && segCount > 0 && ( +
    */} -
    - )} + + Expand + + + )} - {/* Summary shown when expanded (not in compact mode) */} - {isExpanded && hasSummary && !isCompactMode && ( -
    - {banner.title && ( -

    - {banner.title} -

    + {/* Empty hour */} + {segCount === 0 && !isCurrentHour && ( +
    + No segments +
    )} - {banner.body && ( -

    - {banner.body} -

    + + {/* Live interim for current hour (collapsed) */} + {isCurrentHour && displayInterimText && ( + )} -
    + )} - {/* Expand indicator */} -
    - {isExpanded ? ( - - ) : ( - - )} -
    - - - {/* Expanded Segments */} - {isExpanded && ( -
    - {loadingHour === hourKey ? ( -
    - -
    - ) : ( -
    - {hourSegments.map((segment, idx) => { - const segId = segment.id || `idx-${idx}`; - return ( -
    - {/* Timestamp */} - - {segment.timestamp ? formatTime(segment.timestamp) : ""} - - - {/* Content */} -
    - {segment.type === "photo" && segment.photoUrl ? ( -
    - Photo capture handleImageLoad(e, hourKey)} - /> -
    - ) : ( - <> -

    - {segment.text} -

    - {segment.speakerId && ( - - Speaker {segment.speakerId} - - )} - - )} -
    -
    - ); - })} - - {/* Show interim text at the bottom for current hour */} - {isCurrentHour && ( -
    { if (el) headerRefs.current.set(hourKey, el as any); }} + style={{ minHeight: loadingHours.has(hourKey) ? (spinnerHeights.get(hourKey) ?? 300) : undefined }} + > + + {loadingHours.has(hourKey) && ( + - - now - -

    - {interimText || "\u00A0"} -

    -
    + + Loading transcription... + )} -
    - )} -
    - )} + + + {!loadingHours.has(hourKey) && ( + handleContentReady(hourKey)} + className="flex flex-col gap-1.5" + > + {hourSegments.map((segment, idx) => { + const isLastSegment = idx === hourSegments.length - 1; + const isLiveSegment = isCurrentHour && isLastSegment && !segment.isFinal; + return ( + handleImageLoad(e, hourKey)} + isLive={isLiveSegment} + /> + ); + })} + + {/* Interim text */} + {isCurrentHour && displayInterimText && ( + + )} + + {/* Collapse button */} + + + )} +
    + )} +
    ); })} {/* Syncing photo indicator */} {isSyncingPhoto && ( -
    +
    - Syncing image... + Syncing image...
    )} -
    -
    - {/* Scroll-to-bottom FAB — only shown for live (today) when unlocked */} - {isLive && !isScrollLocked && ( + {/* Scroll-to-bottom FAB */} + {showScrollButton && ( diff --git a/src/frontend/pages/home/HomePage.tsx b/src/frontend/pages/home/HomePage.tsx index da77794..4f5a0a3 100644 --- a/src/frontend/pages/home/HomePage.tsx +++ b/src/frontend/pages/home/HomePage.tsx @@ -1,76 +1,114 @@ /** - * HomePage - Main landing page showing the list of days/folders + * HomePage - Main landing page showing conversations * * Features: - * - Filter dropdown (All Files / Archived / Trash) - * - View modes (Folders / All Notes / Favorites) - * - Calendar view toggle - * - Global AI chat trigger (sparkles icon) + * - New Mentra Notes design with conversation-based list + * - Filter pills (All / Today) + * - List/Calendar view toggle + * - FAB menu (Ask AI, Add note, Stop transcribing) + * - Empty state with listening indicator + * - Keeps all existing backend logic (filters, trash, archive, calendar) */ -import { useState, useMemo } from "react"; -import { useLocation } from "wouter"; +import { useState, useMemo, useEffect, useCallback, useRef } from "react"; +import { useLocation, useSearch } from "wouter"; import { useMentraAuth } from "@mentra/react"; -import { - Calendar, - Sparkles, - ChevronDown, - Trash2, - Archive, - Star, - FolderOpen, - Loader2, -} from "lucide-react"; -import { motion } from "motion/react"; +import { ChevronLeft } from "lucide-react"; +import { AnimatePresence } from "motion/react"; import { useSynced } from "../../hooks/useSynced"; -import type { SessionI, FileFilter } from "../../../shared/types"; -import { FolderList } from "./components/FolderList"; +import { useMultiSelect } from "../../hooks/useMultiSelect"; +import type { SessionI, Conversation } from "../../../shared/types"; import type { DailyFolder } from "./components/FolderList"; import { - FilterDrawer, - type FilterType, - type ViewType, -} from "../../components/shared/FilterDrawer"; + ConversationFilterDrawer, + type SortBy, + type DateRange, + type ShowFilter, +} from "../../components/shared/ConversationFilterDrawer"; import { CalendarView } from "./components/CalendarView"; import { GlobalAIChat } from "./components/GlobalAIChat"; +import { ConversationList } from "./components/ConversationList"; +import { FABMenu } from "./components/FABMenu"; +import { TranscriptList } from "./components/TranscriptList"; import { Drawer } from "vaul"; import { HomePageSkeleton } from "../../components/shared/SkeletonLoader"; +import { SelectionHeader } from "../../components/shared/SelectionHeader"; +import { MultiSelectBar, type MultiSelectAction, ExportIcon, MergeIcon, FavoriteIcon, DeleteIcon } from "../../components/shared/MultiSelectBar"; +import { ExportDrawer, type ExportOptions } from "../../components/shared/ExportDrawer"; +import { EmailDrawer } from "../../components/shared/EmailDrawer"; export function HomePage() { const { userId } = useMentraAuth(); - const { session, isConnected, reconnect } = useSynced(userId || ""); + const { session } = useSynced(userId || ""); const [, setLocation] = useLocation(); + const search = useSearch(); - // Local UI state - only for things that can't be derived from backend - // Note: "all_notes" view is frontend-only, so we track if user explicitly chose it + // Local UI state const [isAllNotesView, setIsAllNotesView] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false); const [viewMode, setViewMode] = useState<"list" | "calendar">("list"); const [showGlobalChat, setShowGlobalChat] = useState(false); - const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); const [showEmptyTrashConfirm, setShowEmptyTrashConfirm] = useState(false); + const [timeFilter, setTimeFilter] = useState<"all" | "today">("all"); + const [convSortBy, setConvSortBy] = useState("recent"); + const [convDateRange, setConvDateRange] = useState("all"); + const [convShowFilter, setConvShowFilter] = useState("all"); + const [convCustomStart, setConvCustomStart] = useState(); + const [convCustomEnd, setConvCustomEnd] = useState(); + const initialTab = + new URLSearchParams(search).get("tab") === "transcripts" + ? "transcripts" + : ("conversations" as const); + const [activeTimeFilter, setActiveTimeFilter] = useState< + "conversations" | "transcripts" + >(initialTab); + // renderedFilter is what's actually shown — swaps after fade-out completes + const [renderedFilter, setRenderedFilter] = useState< + "conversations" | "transcripts" + >(initialTab); + const [tabOpacity, setTabOpacity] = useState(1); + + useEffect(() => { + if (activeTimeFilter === renderedFilter) return; + // Fade out + setTabOpacity(0); + const swap = setTimeout(() => { + setRenderedFilter(activeTimeFilter); + setTabOpacity(1); + }, 150); + return () => clearTimeout(swap); + }, [activeTimeFilter]); + + // Multi-select state (one for conversations, one for transcripts) + const convSelect = useMultiSelect(); + const transcriptSelect = useMultiSelect(); + const [showConvExportDrawer, setShowConvExportDrawer] = useState(false); + const [showTranscriptExportDrawer, setShowTranscriptExportDrawer] = useState(false); + const [showConvEmailDrawer, setShowConvEmailDrawer] = useState(false); + const [pendingConvExportOptions, setPendingConvExportOptions] = useState(null); + const [showTranscriptEmailDrawer, setShowTranscriptEmailDrawer] = useState(false); + const pendingTranscriptTextRef = useRef(""); + const pendingTranscriptDatesRef = useRef([]); + const [showTranscriptDeleteConfirm, setShowTranscriptDeleteConfirm] = useState(false); + const [transcriptDeleteWarning, setTranscriptDeleteWarning] = useState(""); + const [showMergeDrawer, setShowMergeDrawer] = useState(false); + const [mergeTrashOriginals, setMergeTrashOriginals] = useState(true); + const [isMerging, setIsMerging] = useState(false); + const [mergedHighlightId, setMergedHighlightId] = useState(null); - // Derive data from session - now using FileManager as source of truth + // Derive data from session const files = session?.file?.files ?? []; const isRecording = session?.transcript?.isRecording ?? false; + const transcriptionPaused = session?.settings?.transcriptionPaused ?? false; + const isMicActive = !transcriptionPaused; const notes = session?.notes?.notes ?? []; + const conversations = session?.conversation?.conversations ?? []; + const isConversationsHydrated = session?.conversation?.isHydrated ?? false; + const availableDates = session?.transcript?.availableDates ?? []; - // Get activeFilter from backend state - this is the source of truth - const backendFilter = session?.file?.activeFilter ?? "all"; - - // Derive activeView from backend filter (favourites) or local state (all_notes) - // This ensures the view state survives navigation for filter-based views - const activeView: ViewType = isAllNotesView - ? "all_notes" - : backendFilter === "favourites" - ? "favorites" - : "folders"; - const activeFilter: FilterType = backendFilter === "favourites" ? "all" : backendFilter as FilterType; - - // Debug: Log filter state on every render - console.log(`[HomePage] Render - backendFilter: ${backendFilter}, activeView: ${activeView}, activeFilter: ${activeFilter}, files: ${files.length}`); + // Backend filter state (used by filterCounts) - // Transform FileData to DailyFolder format + // Transform FileData to DailyFolder format (kept for calendar view) const folders = useMemo((): DailyFolder[] => { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; @@ -88,13 +126,97 @@ export function HomePage() { isTranscribing: isToday && isRecording, noteCount: file.noteCount, transcriptCount: file.transcriptSegmentCount, + transcriptHourCount: file.transcriptHourCount ?? 0, hasTranscript: file.hasTranscript, }; }); }, [files, isRecording]); - // Filter counts - from session (computed on backend) - const fileCounts = session?.file?.counts ?? { all: 0, archived: 0, trash: 0, favourites: 0 }; + const todayStr = useMemo(() => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + }, []); + + const filteredConversations = useMemo(() => { + let result = [...conversations]; + + // Time filter (today tab) + if (timeFilter === "today") { + result = result.filter((c) => c.date === todayStr); + } + + // Date range filter + if (convDateRange === "custom" && convCustomStart && convCustomEnd) { + const start = new Date(convCustomStart + "T00:00:00").getTime(); + const end = new Date(convCustomEnd + "T23:59:59").getTime(); + result = result.filter((c) => { + const t = new Date(c.startTime).getTime(); + return t >= start && t <= end; + }); + } else if (convDateRange !== "all") { + const now = new Date(); + let cutoff: Date; + if (convDateRange === "today") { + cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + } else if (convDateRange === "week") { + cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7); + } else { + cutoff = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); + } + result = result.filter( + (c) => new Date(c.startTime).getTime() >= cutoff.getTime(), + ); + } + + // Show filter + if (convShowFilter === "favourites") { + result = result.filter((c) => c.isFavourite); + } else if (convShowFilter === "archived") { + result = result.filter((c) => c.isArchived); + } else if (convShowFilter === "trash") { + result = result.filter((c) => c.isTrashed); + } else { + // "all" — hide archived and trashed + result = result.filter((c) => !c.isTrashed && !c.isArchived); + } + + // Sort + if (convSortBy === "oldest") { + result.sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + } else { + result.sort( + (a, b) => + new Date(b.startTime).getTime() - new Date(a.startTime).getTime(), + ); + } + + return result; + }, [ + conversations, + timeFilter, + todayStr, + convDateRange, + convShowFilter, + convSortBy, + convCustomStart, + convCustomEnd, + ]); + + // Count today's conversations for subtitle + const todayConversationCount = useMemo(() => { + return conversations.filter((c) => c.date === todayStr).length; + }, [conversations, todayStr]); + + // Filter counts + const fileCounts = session?.file?.counts ?? { + all: 0, + archived: 0, + trash: 0, + favourites: 0, + }; const filterCounts = useMemo( () => ({ all: fileCounts.all, @@ -106,268 +228,1203 @@ export function HomePage() { [fileCounts, notes], ); - // Handle filter change - call FileManager RPC - // Backend's activeFilter state will sync back to update the UI - const handleFilterChange = async (filter: FilterType) => { - // Clear all_notes view when changing filter - setIsAllNotesView(false); + // --- Handlers (all existing logic preserved) --- - // Map FilterType to FileFilter - const fileFilter: FileFilter = - filter === "archived" ? "archived" : filter === "trash" ? "trash" : "all"; + const handleSelectConversation = useCallback( + (conversation: Conversation) => { + setLocation(`/conversation/${conversation.id}`); + }, + [setLocation], + ); + + const handleGlobalChat = () => { + setShowGlobalChat(true); + }; - // Call RPC to update files - backend state will sync back - if (session?.file) { - await session.file.setFilter(fileFilter); + const handleAddNote = async () => { + if (!session?.notes?.createManualNote) return; + const note = await session.notes.createManualNote("", ""); + if (note?.id) { + setLocation(`/note/${note.id}`); } }; - // Handle view change - call FileManager RPC for favorites - const handleViewChange = async (view: ViewType) => { - if (view === "all_notes") { - // all_notes is frontend-only view - setIsAllNotesView(true); - } else if (view === "favorites") { + const handleStopTranscribing = () => { + session?.settings?.updateSettings({ transcriptionPaused: true }); + }; + + const handleResumeTranscribing = () => { + session?.settings?.updateSettings({ transcriptionPaused: false }); + }; + + const handleCalendarToggle = async () => { + if (viewMode === "list") { setIsAllNotesView(false); - // Set filter to favourites - backend state will sync back if (session?.file) { - await session.file.setFilter("favourites"); + await session.file.setFilter("all"); } + setViewMode("calendar"); } else { - // "folders" view - clear all_notes flag, filter is set by handleFilterChange - setIsAllNotesView(false); + setViewMode("list"); } }; - // Get filter label for display - const getFilterLabel = (): string => { - if (activeView === "all_notes") return "All Notes"; - if (activeView === "favorites") return "Favorites"; - switch (activeFilter) { - case "archived": - return "Archived"; - case "trash": - return "Trash"; - default: - return "All Files"; + const handleEmptyTrashConfirm = async () => { + setShowEmptyTrashConfirm(false); + try { + // Empty conversation trash + if (session?.conversation?.emptyTrash) { + const count = await session.conversation.emptyTrash(); + console.log(`[HomePage] Deleted ${count} trashed conversations`); + } + // Empty file trash + if (session?.file?.emptyTrash) { + const result = await session.file.emptyTrash(); + console.log(`[HomePage] Empty file trash result:`, result); + } + } catch (error) { + console.error(`[HomePage] Failed to empty trash:`, error); } }; - const handleSelectFolder = (folder: DailyFolder) => { - setLocation(`/day/${folder.dateString}`); - }; + const trashedConversationCount = useMemo(() => { + return conversations.filter((c) => c.isTrashed).length; + }, [conversations]); - const handleGlobalChat = () => { - setShowGlobalChat(true); - }; + const handleFilterApply = useCallback( + ({ + sortBy, + dateRange, + showFilter, + customStart, + customEnd, + }: { + sortBy: SortBy; + dateRange: DateRange; + showFilter: ShowFilter; + customStart?: string; + customEnd?: string; + }) => { + setIsFilterOpen(false); + setConvSortBy(sortBy); + setConvDateRange(dateRange); + setConvShowFilter(showFilter); + setConvCustomStart(customStart); + setConvCustomEnd(customEnd); + }, + [], + ); - const handleCalendarToggle = () => { - setViewMode((prev) => (prev === "list" ? "calendar" : "list")); - }; + const handleArchiveConversation = useCallback( + async (conversation: Conversation) => { + if (session?.conversation) { + if (conversation.isArchived) { + await session.conversation.unarchiveConversation(conversation.id); + } else { + await session.conversation.archiveConversation(conversation.id); + } + } + }, + [session?.conversation], + ); - const handleEmptyTrashClick = () => { - setShowEmptyTrashConfirm(true); - }; + const handleDeleteConversation = useCallback( + async (conversation: Conversation) => { + if (session?.conversation) { + if (conversation.isTrashed) { + await session.conversation.deleteConversation(conversation.id); + } else { + await session.conversation.trashConversation(conversation.id); + } + } + }, + [session?.conversation], + ); - const handleEmptyTrashConfirm = async () => { - if (!session?.file) return; + // Exit selection on tab/filter change + useEffect(() => { + convSelect.cancel(); + }, [timeFilter, convShowFilter]); - setShowEmptyTrashConfirm(false); - setIsEmptyingTrash(true); - try { - const result = await session.file.emptyTrash(); - console.log(`[HomePage] Empty trash result:`, result); - if (result.errors.length > 0) { - console.error(`[HomePage] Errors during empty trash:`, result.errors); + useEffect(() => { + convSelect.cancel(); + transcriptSelect.cancel(); + }, [renderedFilter]); + + // ── Conversation multi-select handlers ── + + const handleConvBatchFavourite = useCallback(async () => { + if (!session?.conversation) return; + const selectedConvs = filteredConversations.filter((c) => convSelect.selectedIds.has(c.id)); + const allFav = selectedConvs.every((c) => c.isFavourite); + for (const conv of selectedConvs) { + if (allFav) { + await session.conversation.unfavouriteConversation(conv.id); + } else if (!conv.isFavourite) { + await session.conversation.favouriteConversation(conv.id); } - } catch (error) { - console.error(`[HomePage] Failed to empty trash:`, error); + } + convSelect.cancel(); + }, [session, convSelect, filteredConversations]); + + const handleConvBatchTrash = useCallback(async () => { + if (!session?.conversation) return; + for (const id of convSelect.selectedIds) { + await session.conversation.trashConversation(id); + } + convSelect.cancel(); + }, [session, convSelect]); + + /** Build plain text for clipboard export */ + const buildConvExportText = useCallback(async (options: ExportOptions) => { + const selectedConvs = filteredConversations.filter((c) => convSelect.selectedIds.has(c.id)); + const textParts: string[] = []; + + for (const conv of selectedConvs) { + const parts: string[] = []; + + const startDate = new Date(conv.startTime); + const dateLabel = startDate.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + const startTimeLabel = startDate.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + const endTimeLabel = conv.endTime + ? new Date(conv.endTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + : "ongoing"; + const durationMin = conv.endTime + ? Math.round((new Date(conv.endTime).getTime() - startDate.getTime()) / 60000) + : null; + + if (options.includeContent) { + let meta = `Date: ${dateLabel}\nTime: ${startTimeLabel} – ${endTimeLabel}`; + if (durationMin !== null) meta += ` (${durationMin} min)`; + parts.push(`# ${conv.title || "Untitled Conversation"}\n${meta}\n\n${conv.aiSummary || conv.runningSummary || "No summary"}`); + } + + if (options.includeTranscript) { + let transcriptText = ""; + try { + if (session?.conversation?.loadConversationSegments) { + const segments = await session.conversation.loadConversationSegments(conv.id); + if (segments && segments.length > 0) { + transcriptText = segments + .map((s) => { + const time = new Date(s.timestamp).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + return `[${time}] ${s.text}`; + }) + .join("\n"); + } + } + } catch { /* fallback below */ } + if (!transcriptText && conv.chunks && conv.chunks.length > 0) { + transcriptText = conv.chunks.map((c) => c.text).join("\n\n"); + } + if (transcriptText) parts.push(`\n## Transcript\n${transcriptText}`); + } + + if (options.includeLinkedNote && conv.noteId) { + const linkedNote = notes.find((n) => n.id === conv.noteId); + if (linkedNote) { + const noteContent = (linkedNote.content || "") + .replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim(); + const noteCreated = linkedNote.createdAt + ? new Date(linkedNote.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + : ""; + parts.push(`\n## AI Note: ${linkedNote.title || "Untitled Note"}${noteCreated ? `\nGenerated: ${noteCreated}` : ""}\n\n${noteContent}`); + } + } + + if (parts.length > 0) textParts.push(parts.join("\n")); + } + return textParts.join("\n\n---\n\n"); + }, [filteredConversations, convSelect.selectedIds, notes, session]); + + const handleConvBatchExport = useCallback(async (options: ExportOptions) => { + if (options.destination === "email") { + setPendingConvExportOptions(options); + setShowConvEmailDrawer(true); + return; + } + + // Clipboard + const text = await buildConvExportText(options); + await navigator.clipboard.writeText(text); + convSelect.cancel(); + }, [convSelect, buildConvExportText]); + + const handleConvEmailSend = useCallback(async (to: string, cc: string) => { + const selectedConvs = filteredConversations.filter((c) => convSelect.selectedIds.has(c.id)); + if (selectedConvs.length === 0) return; + const options = pendingConvExportOptions; + + // Build notes array from conversations (using linked AI notes as the email note cards) + const emailNotes: Array<{ + noteId: string; + noteTimestamp: string; + noteTitle: string; + noteContent: string; + noteType: string; + }> = []; + + for (const conv of selectedConvs) { + const startDate = new Date(conv.startTime); + const timestamp = startDate.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + + // Build content parts based on toggled options + const contentParts: string[] = []; + + if (options?.includeContent) { + contentParts.push(conv.aiSummary || conv.runningSummary || "No summary available"); + } + + if (options?.includeTranscript && conv.chunks && conv.chunks.length > 0) { + const transcriptText = conv.chunks.map((c) => c.text).join("\n\n"); + contentParts.push(`

    Transcript

    ${transcriptText.replace(/\n/g, "
    ")}

    `); + } + + if (options?.includeLinkedNote && conv.noteId) { + const linkedNote = notes.find((n) => n.id === conv.noteId); + if (linkedNote) { + contentParts.push(`

    AI Note: ${linkedNote.title || "Untitled"}

    ${linkedNote.content || ""}`); + } + } + + if (contentParts.length > 0) { + emailNotes.push({ + noteId: conv.noteId || conv.id, + noteTimestamp: timestamp, + noteTitle: conv.title || "Untitled Conversation", + noteContent: contentParts.join("
    "), + noteType: "Conversation", + }); + } + } + + if (emailNotes.length === 0) return; + + const firstConv = selectedConvs[0]; + const sessionDate = new Date(firstConv.startTime).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); + const startTime = new Date(firstConv.startTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + const endTime = firstConv.endTime + ? new Date(firstConv.endTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + : ""; + + const ccList = cc ? cc.split(",").filter(Boolean) : undefined; + + const res = await fetch("/api/email/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + to, + cc: ccList, + sessionDate, + sessionStartTime: startTime, + sessionEndTime: endTime, + notes: emailNotes, + }), + }); + + const data = await res.json(); + if (!data.success) throw new Error(data.error || "Failed to send email"); + convSelect.cancel(); + }, [filteredConversations, convSelect, notes, pendingConvExportOptions]); + + const handleMergeConfirm = useCallback(async () => { + if (!session?.conversation) return; + setIsMerging(true); + try { + const ids = [...convSelect.selectedIds]; + const newId = await session.conversation.mergeConversations(ids, mergeTrashOriginals); + setShowMergeDrawer(false); + convSelect.cancel(); + // Highlight the new merged conversation for 4 seconds + setMergedHighlightId(newId); + setTimeout(() => setMergedHighlightId(null), 4000); + } catch (err) { + console.error("[HomePage] Merge failed:", err); } finally { - setIsEmptyingTrash(false); + setIsMerging(false); } - }; + }, [session, convSelect, mergeTrashOriginals]); + + const convSelectActions = useMemo((): MultiSelectAction[] => { + const canMerge = convShowFilter !== "trash" && convSelect.count >= 2 && convSelect.count <= 10; + const actions: MultiSelectAction[] = [ + { icon: , label: "Export", onClick: () => setShowConvExportDrawer(true) }, + { icon: , label: "Merge", onClick: () => setShowMergeDrawer(true), disabled: !canMerge }, + { icon: , label: "Favorite", onClick: handleConvBatchFavourite }, + ]; + if (convShowFilter !== "trash") { + actions.push({ icon: , label: "Trash", onClick: handleConvBatchTrash, variant: "danger" }); + } + return actions; + }, [handleConvBatchFavourite, handleConvBatchTrash, convShowFilter, convSelect.count]); + + const convExportLabel = useMemo(() => { + if (convSelect.count === 1) { + const conv = filteredConversations.find((c) => convSelect.selectedIds.has(c.id)); + return conv?.title || "Untitled Conversation"; + } + return `${convSelect.count} conversations selected`; + }, [convSelect.count, convSelect.selectedIds, filteredConversations]); + + const convMissingNoteCount = useMemo(() => { + const selectedConvs = filteredConversations.filter((c) => convSelect.selectedIds.has(c.id)); + return selectedConvs.filter((c) => !c.noteId).length; + }, [convSelect.selectedIds, filteredConversations]); + + // ── Transcript multi-select handlers ── + + const handleTranscriptBatchExport = useCallback(async (options: ExportOptions) => { + if (!session?.transcript) return; + + const selectedDates = [...transcriptSelect.selectedIds]; + const textParts: string[] = []; + + for (const dateStr of selectedDates) { + try { + const result = await session.transcript.loadDateTranscript(dateStr); + if (result?.segments && result.segments.length > 0) { + const [year, month, day] = dateStr.split("-").map(Number); + const dateObj = new Date(year, month - 1, day); + const dateLabel = dateObj.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + + const segmentLines = result.segments.map((s) => { + const time = new Date(s.timestamp).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + return `[${time}] ${s.text}`; + }).join("\n"); - // Loading state - no session yet + textParts.push(`# Transcript — ${dateLabel}\n${segmentLines}`); + } + } catch (err) { + console.error(`Failed to load transcript for ${dateStr}:`, err); + } + } + + const text = textParts.join("\n\n---\n\n"); + + if (options.destination === "email") { + // Store text for email handler, open email drawer + pendingTranscriptTextRef.current = text; + pendingTranscriptDatesRef.current = selectedDates; + setShowTranscriptEmailDrawer(true); + return; + } + + await navigator.clipboard.writeText(text); + transcriptSelect.cancel(); + }, [transcriptSelect, session]); + + const handleTranscriptEmailSend = useCallback(async (to: string, cc: string) => { + const dates = pendingTranscriptDatesRef.current; + if (dates.length === 0 || !session?.transcript) return; + + // Build one note card per date so each transcript day is its own section + const emailNotes: Array<{ + noteId: string; + noteTimestamp: string; + noteTitle: string; + noteContent: string; + noteType: string; + }> = []; + + let sessionDate = ""; + let firstStart = ""; + let lastEnd = ""; + + for (const dateStr of dates) { + const result = await session.transcript.loadDateTranscript(dateStr); + if (!result?.segments?.length) continue; + + const [year, month, day] = dateStr.split("-").map(Number); + const dateObj = new Date(year, month - 1, day); + const dateLabel = dateObj.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + if (!sessionDate) sessionDate = dateObj.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); + + const segmentLines = result.segments.map((s) => { + const time = new Date(s.timestamp).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + if (!firstStart) firstStart = time; + lastEnd = time; + return `${time}${s.text}`; + }).join(""); + + const segCount = result.segments.length; + const startTime = new Date(result.segments[0].timestamp).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + const endTime = new Date(result.segments[result.segments.length - 1].timestamp).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + + emailNotes.push({ + noteId: `transcript-${dateStr}`, + noteTimestamp: `${startTime} — ${endTime}`, + noteTitle: dateLabel, + noteContent: `

    ${segCount} segments

    ${segmentLines}
    `, + noteType: "Transcript", + }); + } + + if (emailNotes.length === 0) return; + + const ccList = cc ? cc.split(",").filter(Boolean) : undefined; + + const res = await fetch("/api/email/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + to, + cc: ccList, + sessionDate: sessionDate || "Transcripts", + sessionStartTime: firstStart, + sessionEndTime: lastEnd, + notes: emailNotes, + }), + }); + + const data = await res.json(); + if (!data.success) throw new Error(data.error || "Failed to send email"); + transcriptSelect.cancel(); + }, [session, transcriptSelect, userId]); + + const handleTranscriptDeleteRequest = useCallback(() => { + const dates = [...transcriptSelect.selectedIds]; + // Check if any conversations exist on these dates + const affectedConvs = conversations.filter((c) => { + if (!c.startTime) return false; + const convDate = new Date(c.startTime); + const convDateStr = `${convDate.getFullYear()}-${String(convDate.getMonth() + 1).padStart(2, "0")}-${String(convDate.getDate()).padStart(2, "0")}`; + return dates.includes(convDateStr); + }); + + if (affectedConvs.length > 0) { + setTranscriptDeleteWarning( + `${affectedConvs.length} ${affectedConvs.length === 1 ? "conversation" : "conversations"} will lose ${affectedConvs.length === 1 ? "its" : "their"} linked transcript. This cannot be undone.` + ); + } else { + setTranscriptDeleteWarning("This will permanently delete the transcript data. This cannot be undone."); + } + setShowTranscriptDeleteConfirm(true); + }, [transcriptSelect.selectedIds, conversations]); + + const handleTranscriptBatchDeleteConfirmed = useCallback(async () => { + if (!session?.file) return; + const dates = [...transcriptSelect.selectedIds]; + for (const dateStr of dates) { + await session.file.trashFile(dateStr); + } + await session.transcript?.removeDates(dates); + setShowTranscriptDeleteConfirm(false); + transcriptSelect.cancel(); + }, [transcriptSelect, session]); + + const transcriptSelectActions = useMemo(() => [ + { icon: , label: "Export", onClick: () => setShowTranscriptExportDrawer(true) }, + { icon: , label: "Delete", onClick: handleTranscriptDeleteRequest, variant: "danger" as const }, + ], [handleTranscriptDeleteRequest]); + + const transcriptExportLabel = useMemo(() => { + return `${transcriptSelect.count} transcript${transcriptSelect.count === 1 ? "" : "s"} selected`; + }, [transcriptSelect.count]); + + // Which multi-select is active (depends on current tab) + const activeSelect = renderedFilter === "conversations" ? convSelect : transcriptSelect; + + // --- Loading state --- if (!session) { return ; } - // Empty state - if (folders.length === 0) { + const hasNoConversations = isConversationsHydrated && conversations.length === 0; + + // --- Calendar view --- + if (viewMode === "calendar") { return ( -
    - {/* Header */} -
    -
    +
    +
    +
    - -
    - - -
    +

    + Calendar +

    +
    + {}} + /> +
    +
    + ); + } -
    -
    - {activeFilter === "trash" ? ( - - ) : activeFilter === "archived" ? ( - - ) : activeView === "favorites" ? ( - + // --- Populated state (conversations list) --- + return ( +
    + {/* Selection header — overlays normal header when selecting */} + {activeSelect.isSelecting && ( +
    + { + if (renderedFilter === "conversations") { + const selectableIds = filteredConversations + .filter((c) => c.status === "ended") + .map((c) => c.id); + convSelect.selectAll(selectableIds); + } else { + const selectableDates = availableDates.filter((d) => { + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + const isLive = d === todayStr && isRecording && !transcriptionPaused; + return !isLive; + }); + transcriptSelect.selectAll(selectableDates); + } + }} + /> +
    + )} + {/* Normal header — hidden during selection */} +
    +
    +
    + Mentra Notes +
    + {/*
    +
    + {isMicActive ? ( + + + + + + ) : ( - + + + + + + + )} +
    */} +
    +
    +
    +
    + {renderedFilter === "conversations" + ? "Conversations" + : "Transcripts"} +
    +
    + {renderedFilter === "conversations" ? ( + hasNoConversations + ? <>No conversations yet + : <>Today · {todayConversationCount}{" "}{todayConversationCount === 1 ? "conversation" : "conversations"} + ) : ( + <>{availableDates.length} {availableDates.length === 1 ? "day" : "days"} of transcripts + )} +
    -
    -

    - {activeFilter === "trash" - ? "Trash is empty" - : activeFilter === "archived" - ? "No archived files" - : activeView === "favorites" - ? "No favorites yet" - : "No files yet"} -

    -

    - {activeFilter === "trash" - ? "Files you delete will appear here." - : activeFilter === "archived" - ? "Files you archive will appear here." - : activeView === "favorites" - ? "Mark files as favorites to see them here." - : "Notes and transcriptions will appear here once you start recording with your glasses connected."} -

    - {activeFilter === "all" && activeView === "folders" && !isConnected && ( + +
    + {/* Filter button */} + + + {/* Conversations / Transcripts toggle */} +
    + {/* Conversations */} - )} + {/* Transcripts */} + +
    - - setIsFilterOpen(false)} - activeFilter={activeFilter} - activeView={activeView} - onFilterChange={handleFilterChange} - onViewChange={handleViewChange} - counts={filterCounts} - />
    - ); - } - return ( -
    - {/* Header */} -
    -
    + {/* Tab switcher — hidden during selection */} + {!activeSelect.isSelecting && renderedFilter === "conversations" && ( +
    - -
    - {/* Empty Trash button - only shown when viewing trash */} - {activeFilter === "trash" && filterCounts.trash > 0 && ( - - )} - + + {convShowFilter !== "all" && ( -{/* + )} + {convSortBy !== "recent" && ( */} -
    + + + + + )} +
    + )} + + {/* Content area — single wrapper fades out/in on tab switch */} +
    +
    + {renderedFilter === "transcripts" ? ( +
    + setLocation(`/transcript/${dateStr}`)} + isSelecting={transcriptSelect.isSelecting} + selectedDates={transcriptSelect.selectedIds} + onToggleSelect={(dateStr) => transcriptSelect.toggleItem(dateStr)} + longPressProps={transcriptSelect.longPressProps} + /> +
    + ) : !isConversationsHydrated ? ( +
    +
    + ) : hasNoConversations ? ( +
    + {isMicActive ? ( + <> +
    +
    +
    + + + + + +
    +
    +
    + Listening... +
    +
    + Waiting to capture your conversation +
    + + ) : ( + <> +
    + + + +
    +
    + Start a conversation +
    +
    + Mentra Notes is listening in the background. When it detects a conversation, it will appear here. +
    + + )} +
    + ) : filteredConversations.length === 0 ? ( +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Nothing found +
    +
    + {convShowFilter === "trash" + ? "Your trash is empty" + : convShowFilter === "archived" + ? "No archived conversations" + : convShowFilter === "favourites" + ? "No favourite conversations yet" + : "Try adjusting your search or filters"} +
    +
    + ) : ( + <> + {convShowFilter === "trash" && trashedConversationCount > 0 && ( +
    + + {trashedConversationCount}{" "} + {trashedConversationCount === 1 + ? "conversation" + : "conversations"}{" "} + in trash + + +
    + )} + convSelect.toggleItem(id)} + longPressProps={convSelect.longPressProps} + highlightId={mergedHighlightId} + onHighlightSeen={(id) => { if (mergedHighlightId === id) setMergedHighlightId(null); }} + isMicActive={isMicActive} + /> + + )}
    - {/* Content */} -
    - {viewMode === "list" ? ( - - ) : ( - setLocation(`/day/${dateString}`)} - /> + {/* FAB — hidden during selection */} + {!activeSelect.isSelecting && ( + + )} + + {/* Multi-select bottom bar */} + + {convSelect.isSelecting && renderedFilter === "conversations" && ( + )} -
    + {transcriptSelect.isSelecting && renderedFilter === "transcripts" && ( + + )} + + + {/* Export Drawers */} + setShowConvExportDrawer(false)} + itemType="conversation" + itemLabel={convExportLabel} + count={convSelect.count} + onExport={handleConvBatchExport} + missingNoteCount={convMissingNoteCount} + /> + setShowTranscriptExportDrawer(false)} + itemType="transcript" + itemLabel={transcriptExportLabel} + count={transcriptSelect.count} + onExport={handleTranscriptBatchExport} + /> + + {/* Email Drawer (opened after ExportDrawer selects "email") */} + { setShowConvEmailDrawer(false); setPendingConvExportOptions(null); }} + onSend={handleConvEmailSend} + defaultEmail={userId || ""} + itemLabel={convSelect.count === 1 ? "Conversation" : `${convSelect.count} Conversations`} + /> + setShowTranscriptEmailDrawer(false)} + onSend={handleTranscriptEmailSend} + defaultEmail={userId || ""} + itemLabel={transcriptSelect.count === 1 ? "Transcript" : `${transcriptSelect.count} Transcripts`} + /> + + {/* Merge Conversations Drawer */} + !open && setShowMergeDrawer(false)}> + + + +
    +
    +
    + Merge Conversations + Merge selected conversations into one +
    +
    + + Merge {convSelect.count} Conversations? + + +
    +

    + This will combine the transcripts from {convSelect.count} conversations into a single new conversation with a fresh AI summary. +

    + + {/* Conversation titles being merged */} +
    + {filteredConversations + .filter((c) => convSelect.selectedIds.has(c.id)) + .map((c) => ( +
    +
    + + {c.title || "Untitled"} + +
    + ))} +
    + + {/* Trash originals checkbox */} + + + {/* Merge button */} + + +
    +
    + + + + + {/* Transcript Delete Confirmation */} + !open && setShowTranscriptDeleteConfirm(false)}> + + + +
    +
    +
    + Delete Transcripts + Confirm transcript deletion +
    +
    + + Delete Transcripts? + + +
    +

    + {transcriptDeleteWarning} +

    + + +
    +
    + + + {/* Filter Drawer */} - setIsFilterOpen(false)} - activeFilter={activeFilter} - activeView={activeView} - onFilterChange={handleFilterChange} - onViewChange={handleViewChange} - counts={filterCounts} + sortBy={convSortBy} + dateRange={convDateRange} + showFilter={convShowFilter} + customStart={convCustomStart} + customEnd={convCustomEnd} + onApply={handleFilterApply} /> {/* Global AI Chat */} @@ -383,39 +1440,38 @@ export function HomePage() { > - - {/* Handle */} -
    - - {/* Content */} + +
    - + Empty Trash? - - You are about to permanently delete all {filterCounts.trash} items in trash. - This will remove all transcripts, notes, and chat history. - You will not be able to recover them. + + You are about to permanently delete {trashedConversationCount}{" "} + {trashedConversationCount === 1 + ? "conversation" + : "conversations"} + . This cannot be undone. Are you sure? - - {/* Actions */}
    - - {/* Safe area padding for mobile */}
    @@ -423,3 +1479,5 @@ export function HomePage() {
    ); } + +/** Top-right overflow/minimize menu (from Paper design) */ diff --git a/src/frontend/pages/home/components/CalendarView.tsx b/src/frontend/pages/home/components/CalendarView.tsx index 7fe5408..9382102 100644 --- a/src/frontend/pages/home/components/CalendarView.tsx +++ b/src/frontend/pages/home/components/CalendarView.tsx @@ -1,16 +1,15 @@ /** - * CalendarView - Month calendar grid for navigating days + * CalendarView - Month calendar with conversation/note activity dots * - * Features: - * - Month navigation (prev/next) - * - Day grid with activity indicators (dots for days with content) - * - Click day to navigate to that day's folder - * - Highlights today - * - * Reference: figma-design/src/app/views/FolderList.tsx L250-270, L420-480 + * Matches Paper design with: + * - Stats bar (Conversations, Notes, Duration) + * - Day grid with red (conversation) and gray (note) dot indicators + * - Today highlighted with dark circle + * - Legend at bottom + * - Month navigation via swipe or buttons */ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { format, startOfMonth, @@ -19,222 +18,195 @@ import { endOfWeek, eachDayOfInterval, isSameMonth, - isSameDay, + isToday, addMonths, subMonths, - isToday, } from "date-fns"; -import { clsx } from "clsx"; -import { ChevronLeft, ChevronRight, Mic } from "lucide-react"; import type { DailyFolder } from "./FolderList"; +import type { Conversation, Note } from "../../../../shared/types"; + +const WEEK_DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; interface CalendarViewProps { folders: DailyFolder[]; + conversations?: Conversation[]; + notes?: Note[]; onSelectDate: (dateString: string) => void; } -export function CalendarView({ folders, onSelectDate }: CalendarViewProps) { +export function CalendarView({ conversations = [], notes = [], onSelectDate }: CalendarViewProps) { const [currentMonth, setCurrentMonth] = useState(new Date()); - // Calendar calculations const monthStart = startOfMonth(currentMonth); const monthEnd = endOfMonth(monthStart); const startDate = startOfWeek(monthStart); const endDate = endOfWeek(monthEnd); const calendarDays = eachDayOfInterval({ start: startDate, end: endDate }); - const weekDays = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; - // Create a map for quick folder lookup - const folderByDate = new Map(); - folders.forEach((folder) => { - folderByDate.set(folder.dateString, folder); - }); + // Build lookup maps + const conversationsByDate = useMemo(() => { + const map = new Map(); + conversations.forEach((c) => { + if (!map.has(c.date)) map.set(c.date, []); + map.get(c.date)!.push(c); + }); + return map; + }, [conversations]); + + const notesByDate = useMemo(() => { + const map = new Map(); + notes.forEach((n) => { + if (!map.has(n.date)) map.set(n.date, []); + map.get(n.date)!.push(n); + }); + return map; + }, [notes]); // Month stats - const currentMonthFolders = folders.filter( - (f) => isSameMonth(f.date, currentMonth), - ); - const monthNotesCount = currentMonthFolders.reduce( - (acc, f) => acc + f.noteCount, - 0, - ); - const daysWithContent = currentMonthFolders.length; + const monthStr = format(currentMonth, "yyyy-MM"); + const monthConversations = conversations.filter((c) => c.date.startsWith(monthStr)); + const monthNotes = notes.filter((n) => n.date.startsWith(monthStr)); + const monthDurationMin = monthConversations.reduce((acc, c) => { + if (!c.endTime) return acc; + return acc + Math.round((new Date(c.endTime).getTime() - new Date(c.startTime).getTime()) / 60000); + }, 0); - const handlePrevMonth = () => { - setCurrentMonth((prev) => subMonths(prev, 1)); - }; - - const handleNextMonth = () => { - setCurrentMonth((prev) => addMonths(prev, 1)); - }; + const handlePrevMonth = () => setCurrentMonth((prev) => subMonths(prev, 1)); + const handleNextMonth = () => setCurrentMonth((prev) => addMonths(prev, 1)); const handleDayClick = (day: Date) => { - const dateString = format(day, "yyyy-MM-dd"); - const folder = folderByDate.get(dateString); - // Only allow clicking on dates that have content - if (folder) { - onSelectDate(dateString); - } + if (!isSameMonth(day, currentMonth)) return; + onSelectDate(format(day, "yyyy-MM-dd")); }; + // Split calendar days into weeks + const weeks: Date[][] = []; + for (let i = 0; i < calendarDays.length; i += 7) { + weeks.push(calendarDays.slice(i, i + 7)); + } + return ( -
    - {/* Month Header */} -
    -
    - -

    - {format(currentMonth, "MMMM yyyy")} -

    - -
    +
    + {/* Month navigation */} +
    + + + {format(currentMonth, "MMMM yyyy")} + + +
    - {/* Month Stats */} -
    - {daysWithContent} days with activity - - {monthNotesCount} notes + {/* Stats bar */} +
    +
    +
    + {monthConversations.length} +
    +
    + Conversations +
    +
    +
    +
    + {monthNotes.length} +
    +
    + Notes +
    +
    +
    +
    + {monthDurationMin}m +
    +
    + Duration +
    - {/* Calendar Grid */} -
    + {/* Calendar grid */} +
    {/* Week day headers */} -
    - {weekDays.map((day) => ( -
    +
    + {WEEK_DAYS.map((day) => ( +
    {day}
    ))}
    - {/* Day cells */} -
    - {calendarDays.map((day) => { - const dateString = format(day, "yyyy-MM-dd"); - const folder = folderByDate.get(dateString); - const isCurrentMonth = isSameMonth(day, currentMonth); - const isDayToday = isToday(day); - const hasContent = !!folder; - const noteCount = folder?.noteCount ?? 0; - const hasTranscript = folder?.hasTranscript ?? false; - - // Disable dates without content (except today which is always clickable) - const isClickable = hasContent || isDayToday; - - return ( - - ); - })} -
    -
    - - {/* Month folder list preview */} - {currentMonthFolders.length > 0 && ( -
    -

    - Activity this month -

    -
    - {currentMonthFolders.slice(0, 5).map((folder) => ( - - ))} - {currentMonthFolders.length > 5 && ( -

    - +{currentMonthFolders.length - 5} more days -

    - )} + + ); + })} +
    + ))} + + {/* Legend */} +
    +
    +
    + Conversation +
    +
    +
    + Note
    - )} +
    ); } diff --git a/src/frontend/pages/home/components/ConversationList.tsx b/src/frontend/pages/home/components/ConversationList.tsx new file mode 100644 index 0000000..b8fa042 --- /dev/null +++ b/src/frontend/pages/home/components/ConversationList.tsx @@ -0,0 +1,230 @@ +/** + * ConversationList - Displays conversations grouped by day + * + * Groups conversations into: Today, Yesterday, or formatted date headers. + * Each group shows a count and renders ConversationRow items. + */ + +import { useMemo, useRef, useEffect, useState, useCallback } from "react"; +import { format, isToday, isYesterday } from "date-fns"; +import { AnimatePresence, motion } from "motion/react"; +import type { Conversation } from "../../../../shared/types"; +import { ConversationRow } from "./ConversationRow"; +import { WaveIndicator } from "../../../components/shared/WaveIndicator"; + + +interface ConversationListProps { + conversations: Conversation[]; + onSelectConversation: (conversation: Conversation) => void; + onArchive?: (conversation: Conversation) => void; + onDelete?: (conversation: Conversation) => void; + /** Multi-select props */ + isSelecting?: boolean; + selectedIds?: Set; + onToggleSelect?: (id: string) => void; + longPressProps?: (id: string, disabled?: boolean) => { + onTouchStart: (e: React.TouchEvent) => void; + onTouchEnd: () => void; + onTouchMove: () => void; + }; + /** ID of a conversation to highlight (e.g. after merge) */ + highlightId?: string | null; + /** Called when user clicks on the highlighted conversation */ + onHighlightSeen?: (id: string) => void; + /** Whether the microphone is currently active */ + isMicActive?: boolean; +} + +interface DayGroup { + key: string; + label: string; + count: number; + conversations: Conversation[]; +} + +const PAGE_SIZE = 20; + +export function ConversationList({ + conversations, + onSelectConversation, + onArchive, + onDelete, + isSelecting = false, + selectedIds, + onToggleSelect, + longPressProps, + highlightId, + onHighlightSeen, + isMicActive = false, +}: ConversationListProps) { + const scrollRef = useRef(null); + const sentinelRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + // Reset visible count when conversations change (e.g. filter switch) + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [conversations.length]); + + // Track IDs seen on first render — only animate rows that arrive after mount + const seenIds = useRef | null>(null); + if (seenIds.current === null) { + seenIds.current = new Set(conversations.map((c) => c.id)); + } + + // Auto-scroll to top when a new active conversation appears + useEffect(() => { + const activeNew = conversations.find( + (c) => c.status === "active" && !seenIds.current!.has(c.id), + ); + if (activeNew) { + seenIds.current!.add(activeNew.id); + setTimeout(() => { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, 100); + } + for (const c of conversations) { + seenIds.current!.add(c.id); + } + }, [conversations]); + + // Infinite scroll — load more when sentinel enters viewport + const loadMore = useCallback(() => { + setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, conversations.length)); + }, [conversations.length]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) loadMore(); + }, + { root: scrollRef.current, rootMargin: "200px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [loadMore]); + + // Slice conversations to visible count, then group + const visibleConversations = useMemo( + () => conversations.slice(0, visibleCount), + [conversations, visibleCount], + ); + + const groups = useMemo((): DayGroup[] => { + const byDate = new Map(); + + for (const conv of visibleConversations) { + const dateKey = conv.date; + if (!byDate.has(dateKey)) { + byDate.set(dateKey, []); + } + byDate.get(dateKey)!.push(conv); + } + + for (const [, convs] of byDate) { + convs.sort( + (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() + ); + } + + const sortedKeys = [...byDate.keys()].sort((a, b) => b.localeCompare(a)); + + return sortedKeys.map((dateKey) => { + const convs = byDate.get(dateKey)!; + const [year, month, day] = dateKey.split("-").map(Number); + const dateObj = new Date(year, month - 1, day); + + let label: string; + if (isToday(dateObj)) { + label = "Today"; + } else if (isYesterday(dateObj)) { + label = "Yesterday"; + } else { + label = format(dateObj, "EEE MMM d"); + } + + return { + key: dateKey, + label, + count: convs.length, + conversations: convs, + }; + }); + }, [visibleConversations]); + + const hasMore = visibleCount < conversations.length; + + return ( +
    + {groups.map((group) => ( +
    + {/* Day section header */} +
    + + {group.label} · {group.count} {group.count === 1 ? "conversation" : "conversations"} + +
    + + {/* Listening placeholder — shown under Today when mic is active and no conversation is being transcribed */} + {group.label === "Today" && isMicActive && !group.conversations.some((c) => c.status === "active") && ( +
    +
    + +
    +
    + + Listening... + + + Waiting to capture your conversation + +
    +
    + )} + + {/* Conversation rows */} + + {group.conversations.map((conv, i) => { + const isNew = !seenIds.current!.has(conv.id); + return ( + + { onSelectConversation(c); if (highlightId === c.id) onHighlightSeen?.(c.id); }} + onArchive={onArchive} + onDelete={onDelete} + isLast={i === group.conversations.length - 1} + isSelecting={isSelecting} + isSelected={selectedIds?.has(conv.id) ?? false} + onToggleSelect={() => onToggleSelect?.(conv.id)} + longPressHandlers={longPressProps?.(conv.id, conv.status !== "ended")} + isHighlighted={highlightId === conv.id} + /> + + ); + })} + +
    + ))} + + {/* Scroll sentinel — triggers loading more */} + {hasMore && ( +
    + Loading more... +
    + )} +
    + ); +} diff --git a/src/frontend/pages/home/components/ConversationRow.tsx b/src/frontend/pages/home/components/ConversationRow.tsx new file mode 100644 index 0000000..f76f8bf --- /dev/null +++ b/src/frontend/pages/home/components/ConversationRow.tsx @@ -0,0 +1,231 @@ +/** + * ConversationRow - Individual conversation item in the list + * + * Displays: time | title + metadata (duration, transcribing status) | chevron + * Active conversations show red styling with "Transcribing now" badge. + * + * Uses native touch events via useSwipeToReveal for smooth, + * jank-free swipe gesture on mobile. + * + * Supports multi-select mode: shows checkbox, disables swipe, + * tap toggles selection. Active/paused conversations cannot be selected. + */ + +import { format } from "date-fns"; +import { motion, useTransform } from "motion/react"; +import { memo } from "react"; +import type { Conversation } from "../../../../shared/types"; +import { WaveIndicator } from "../../../components/shared/WaveIndicator"; +import { useSwipeToReveal } from "../../../hooks/useSwipeToReveal"; + +const SWIPE_OPEN_DISTANCE = 146; + +interface ConversationRowProps { + conversation: Conversation; + onSelect: (conversation: Conversation) => void; + onArchive?: (conversation: Conversation) => void; + onDelete?: (conversation: Conversation) => void; + isLast?: boolean; + /** Multi-select props */ + isSelecting?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + longPressHandlers?: { + onTouchStart: (e: React.TouchEvent) => void; + onTouchEnd: () => void; + onTouchMove: () => void; + }; + /** Highlight this row (e.g. after merge) */ + isHighlighted?: boolean; +} + +function getDurationMinutes(conversation: Conversation): number | null { + if (!conversation.endTime) return null; + const start = new Date(conversation.startTime).getTime(); + const end = new Date(conversation.endTime).getTime(); + return Math.max(1, Math.round((end - start) / 60000)); +} + +export const ConversationRow = memo(function ConversationRow({ + conversation, + onSelect, + onArchive, + onDelete, + isLast = false, + isSelecting = false, + isSelected = false, + onToggleSelect, + longPressHandlers, + isHighlighted = false, +}: ConversationRowProps) { + const isActive = conversation.status === "active" || conversation.status === "paused"; + const canSelect = !isActive; // Active/paused conversations can't be selected + const startTime = new Date(conversation.startTime); + const duration = getDurationMinutes(conversation); + + const { x, handlers, handleClick } = useSwipeToReveal({ + openDistance: SWIPE_OPEN_DISTANCE, + threshold: 0.3, + }); + + const archiveOpacity = useTransform(x, [-SWIPE_OPEN_DISTANCE * 0.3, -10], [1, 0]); + const deleteOpacity = useTransform(x, [-SWIPE_OPEN_DISTANCE, -SWIPE_OPEN_DISTANCE * 0.3], [1, 0]); + + // In selection mode: tap toggles (if selectable), no swipe + const rowClick = isSelecting + ? () => { if (canSelect) onToggleSelect?.(); } + : () => handleClick(() => onSelect(conversation)); + + // Merge swipe + long-press touch handlers so neither overwrites the other + const mergedTouchHandlers = (() => { + if (isSelecting) return {}; + const swipe = handlers; + const lp = canSelect ? longPressHandlers : undefined; + if (!lp) return swipe; + return { + onTouchStart: (e: React.TouchEvent) => { swipe.onTouchStart?.(e); lp.onTouchStart(e); }, + onTouchEnd: (e: React.TouchEvent) => { swipe.onTouchEnd?.(e); lp.onTouchEnd(); }, + onTouchMove: (e: React.TouchEvent) => { swipe.onTouchMove?.(e); lp.onTouchMove(); }, + }; + })(); + + return ( +
    + {/* Swipe action buttons (behind the row) — hidden in selection mode */} + {!isSelecting && ( +
    + onArchive?.(conversation)} + className="flex items-center justify-center w-[72px] bg-[#1C1917]" + > +
    + {conversation.isArchived ? ( + + + + + + ) : ( + + + + + + )} + + {conversation.isArchived ? "Unarchive" : "Archive"} + +
    +
    + onDelete?.(conversation)} + className="flex items-center justify-center w-[74px] bg-[#DC2626]" + > +
    + + + + + + + {conversation.isTrashed ? "Delete" : "Trash"} + +
    +
    +
    + )} + + {/* Row content */} + + {/* Checkbox — no layout shift, uses opacity */} + {isSelecting && canSelect && ( +
    + {isSelected ? ( +
    + + + +
    + ) : ( +
    + )} +
    + )} + + {/* Time column */} +
    +
    + {format(startTime, "h:mm")} +
    +
    + {format(startTime, "a")} +
    +
    + + {/* Content column */} +
    +
    + {conversation.title ? ( + + {conversation.title} + + ) : ( + + + + + + Generating title... + + )} + {conversation.isFavourite && ( + + )} +
    +
    + {isActive ? ( +
    + + + Transcribing now + +
    + ) : duration !== null ? ( +
    + + {duration} min + +
    + ) : null} +
    +
    + + {/* Chevron — hidden in selection mode */} + {!isSelecting && ( + + + + )} + +
    + ); +}); diff --git a/src/frontend/pages/home/components/FABMenu.tsx b/src/frontend/pages/home/components/FABMenu.tsx new file mode 100644 index 0000000..acc60bf --- /dev/null +++ b/src/frontend/pages/home/components/FABMenu.tsx @@ -0,0 +1,70 @@ +/** + * FABMenu - Single transcription mic toggle button with stop confirmation + */ + +import { useState } from "react"; +import { StopTranscriptionDialog } from "./StopTranscriptionDialog"; + +interface FABMenuProps { + transcriptionPaused: boolean; + onStopTranscribing: () => void; + onResumeTranscribing: () => void; +} + +export function FABMenu({ + transcriptionPaused, + onStopTranscribing, + onResumeTranscribing, +}: FABMenuProps) { + const isActive = !transcriptionPaused; + const [showStopDialog, setShowStopDialog] = useState(false); + + const handlePress = () => { + if (isActive) { + setShowStopDialog(true); + } else { + onResumeTranscribing(); + } + }; + + return ( + <> +
    + +
    + + setShowStopDialog(false)} + onConfirm={() => { + setShowStopDialog(false); + onStopTranscribing(); + }} + /> + + ); +} diff --git a/src/frontend/pages/home/components/FolderList.tsx b/src/frontend/pages/home/components/FolderList.tsx index 750781a..42ebe2b 100644 --- a/src/frontend/pages/home/components/FolderList.tsx +++ b/src/frontend/pages/home/components/FolderList.tsx @@ -23,6 +23,7 @@ export interface DailyFolder { isTranscribing: boolean; noteCount: number; transcriptCount: number; + transcriptHourCount: number; hasTranscript?: boolean; // For historical dates with transcripts } @@ -66,7 +67,7 @@ export function FolderList({ folders, onSelectFolder }: FolderListProps) {
    {/* Month divider - skip for current month if it's the first */} {monthIndex > 0 && ( -
    +
    {monthKey} @@ -77,7 +78,7 @@ export function FolderList({ folders, onSelectFolder }: FolderListProps) { + +
    +
    + + ); +} diff --git a/src/frontend/pages/home/components/TranscriptList.tsx b/src/frontend/pages/home/components/TranscriptList.tsx new file mode 100644 index 0000000..eb39edc --- /dev/null +++ b/src/frontend/pages/home/components/TranscriptList.tsx @@ -0,0 +1,195 @@ +/** + * TranscriptList - List of transcript dates for the Transcripts tab. + * Loads 20 at a time, with more loaded on scroll. + * + * Supports multi-select mode: shows checkbox per row, + * tap toggles selection. Live transcript dates cannot be selected. + */ + +import { useState, useRef, useCallback } from "react"; +import { format, isToday, isYesterday } from "date-fns"; +import type { FileData } from "../../../../shared/types"; +import { WaveIndicator } from "../../../components/shared/WaveIndicator"; + +const PAGE_SIZE = 20; + +interface TranscriptListProps { + availableDates: string[]; + files: FileData[]; + isRecording: boolean; + transcriptionPaused: boolean; + onSelect: (dateStr: string) => void; + /** Multi-select props */ + isSelecting?: boolean; + selectedDates?: Set; + onToggleSelect?: (dateStr: string) => void; + longPressProps?: (id: string, disabled?: boolean) => { + onTouchStart: (e: React.TouchEvent) => void; + onTouchEnd: () => void; + onTouchMove: () => void; + }; +} + +export function TranscriptList({ + availableDates, + files, + isRecording, + transcriptionPaused, + onSelect, + isSelecting = false, + selectedDates, + onToggleSelect, + longPressProps, +}: TranscriptListProps) { + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const observer = useRef(null); + + const sorted = [...availableDates].sort((a, b) => b.localeCompare(a)); + const visible = sorted.slice(0, visibleCount); + const hasMore = visibleCount < sorted.length; + + const lastItemRef = useCallback( + (node: HTMLButtonElement | HTMLDivElement | null) => { + if (observer.current) observer.current.disconnect(); + if (!node || !hasMore) return; + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) { + setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, sorted.length)); + } + }); + observer.current.observe(node); + }, + [hasMore, sorted.length], + ); + + if (availableDates.length === 0) { + return ( +
    + + + + + + + No transcripts yet +
    + ); + } + + return ( + <> + {visible.map((dateStr, i) => { + const [year, month, day] = dateStr.split("-").map(Number); + const dateObj = new Date(year, month - 1, day); + const today = isToday(dateObj); + const yesterday = isYesterday(dateObj); + const label = today ? "Today" : yesterday ? "Yesterday" : format(dateObj, "EEE, MMM d"); + const file = files.find((f) => f.date === dateStr); + const segCount = file?.transcriptSegmentCount ?? 0; + const hourCount = file?.transcriptHourCount ?? 0; + const isLive = today && isRecording && !transcriptionPaused; + const isLast = i === visible.length - 1; + const isSelected = selectedDates?.has(dateStr) ?? false; + const canSelect = !isLive; // Can't select live transcript + + const lpHandlers = longPressProps?.(dateStr, !canSelect); + + if (isSelecting) { + return ( +
    { if (canSelect) onToggleSelect?.(dateStr); }} + className={`flex items-center py-4 gap-3 w-full text-left select-none ${ + isSelected ? "bg-[#FEE2E24D] " : "" + } ${!canSelect ? "opacity-40" : ""} ${ + i < visible.length - 1 ? "border-b border-[#F5F5F4]" : "" + }`} + > + {/* Checkbox */} + {canSelect && ( +
    + {isSelected ? ( +
    + + + +
    + ) : ( +
    + )} +
    + )} + + {/* Mic icon */} +
    + {isLive ? ( + + ) : ( + + + + + + + )} +
    + {/* Content */} +
    + + {label} + + + {segCount} segments{hourCount > 0 ? ` · ${hourCount} ${hourCount === 1 ? "hour" : "hours"}` : ""} + +
    +
    + ); + } + + // Normal mode + return ( + + ); + })} + + ); +} diff --git a/src/frontend/pages/note/FolderPicker.tsx b/src/frontend/pages/note/FolderPicker.tsx new file mode 100644 index 0000000..47b0f5e --- /dev/null +++ b/src/frontend/pages/note/FolderPicker.tsx @@ -0,0 +1,104 @@ +/** + * FolderPicker - Dropdown to assign a note to a folder + * + * Collapsed: shows current folder name or "No folder" + * Expanded: shows list of all folders with color dots + */ + +import { useState } from "react"; +import type { Folder, FolderColor } from "../../../shared/types"; + +const FOLDER_COLOR_MAP: Record = { + red: "#DC2626", + gray: "#78716C", + blue: "#2563EB", +}; + +interface FolderPickerProps { + folders: Folder[]; + currentFolderId?: string | null; + onSelect: (folderId: string | null) => void; +} + +export function FolderPicker({ folders, currentFolderId, onSelect }: FolderPickerProps) { + const [isOpen, setIsOpen] = useState(false); + + const currentFolder = folders.find((f) => f.id === currentFolderId); + + return ( +
    + {/* Collapsed bar */} + + + {/* Dropdown */} + {isOpen && ( +
    + {/* No folder option */} + + + {/* Folder options */} + {folders.map((folder) => ( + + ))} +
    + )} +
    + ); +} diff --git a/src/frontend/pages/note/NotePage.tsx b/src/frontend/pages/note/NotePage.tsx index ccaf42c..bc911c8 100644 --- a/src/frontend/pages/note/NotePage.tsx +++ b/src/frontend/pages/note/NotePage.tsx @@ -1,37 +1,94 @@ /** - * NotePage - Apple Notes style editor + * NotePage - Structured note viewer with inline editing * - * Minimal, clean, distraction-free note editing. - * - Title as the first line, naturally flows into content - * - Formatting toolbar in header - * - Auto-saves as you type - * - Full screen, content-first design + * Matches Paper design with: + * - Back button + "Note" label + star + more menu + * - AI/Manual badge + date + conversation source + * - Editable title (always inline) + * - Structured sections: Summary, Key Decisions + * - TipTap editor inline for content editing + * - Bottom formatting toolbar (bold, italic, heading, list, link) */ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useParams, useLocation } from "wouter"; import { useMentraAuth } from "@mentra/react"; -import { clsx } from "clsx"; -import { - ChevronLeft, - MoreHorizontal, - Loader2, - Trash2, - Bold, - Italic, - List, - Heading2, - Check, - AlertTriangle, -} from "lucide-react"; +import { format, isToday, isYesterday } from "date-fns"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Image from "@tiptap/extension-image"; -import { Drawer } from "vaul"; import { useSynced } from "../../hooks/useSynced"; -import type { SessionI, Note } from "../../../shared/types"; +import type { SessionI, Note, FolderColor } from "../../../shared/types"; +import { FolderPicker } from "./FolderPicker"; +import { + DropdownMenu, + type DropdownMenuOption, +} from "../../components/shared/DropdownMenu"; + +const FOLDER_COLOR_MAP: Record = { + red: "#DC2626", + gray: "#78716C", + blue: "#2563EB", +}; import { NotePageSkeleton } from "../../components/shared/SkeletonLoader"; +import { EmailDrawer } from "../../components/shared/EmailDrawer"; +import { rewriteR2Urls } from "../../../shared/constants"; + +// ============================================================================= +// Content parser — extracts structured sections from note content +// ============================================================================= + +interface ParsedNote { + summary: string; +} + +function parseNoteContent(content: string, summary?: string): ParsedNote { + const result: ParsedNote = { + summary: "", + }; + + if (summary) { + result.summary = summary; + return result; + } + + if (!content) return result; + + // Strip HTML tags to extract a plain-text summary + const text = content + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(/<\/li>/gi, "\n") + .replace(/<\/h[1-6]>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); + + const lines = text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + for (const line of lines) { + const bulletText = line + .replace(/^[-•*]\s*/, "") + .replace(/^\d+\.\s*/, "") + .trim(); + if (!bulletText) continue; + result.summary += (result.summary ? " " : "") + bulletText; + if (result.summary.length > 200) break; + } + + return result; +} + +// ============================================================================= +// Component +// ============================================================================= export function NotePage() { const params = useParams<{ id: string }>(); @@ -40,8 +97,7 @@ export function NotePage() { const { session } = useSynced(userId || ""); const [editTitle, setEditTitle] = useState(""); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [showMenu, setShowMenu] = useState(false); + const [showEmailDrawer, setShowEmailDrawer] = useState(false); const [isSaving, setIsSaving] = useState(false); const [showSaved, setShowSaved] = useState(false); const saveTimeoutRef = useRef | null>(null); @@ -50,21 +106,97 @@ export function NotePage() { const noteId = params.id || ""; const allNotes = session?.notes?.notes ?? []; const note = allNotes.find((n) => n.id === noteId); + const conversations = session?.conversation?.conversations ?? []; + + // Find the source conversation for this note (match by noteId link) + const sourceConversation = useMemo(() => { + if (!note) return null; + return conversations.find((c) => c.noteId === note.id) ?? null; + }, [note, conversations]); + + // Parse structured content + const parsed = useMemo(() => { + if (!note) return null; + return parseNoteContent(note.content, note.summary); + }, [note?.content, note?.summary]); + + // Format date + const dateLabel = useMemo(() => { + if (!note?.date) return ""; + const [year, month, day] = note.date.split("-").map(Number); + const dateObj = new Date(year, month - 1, day); + if (isToday(dateObj)) return "Today"; + if (isYesterday(dateObj)) return "Yesterday"; + return format(dateObj, "MMM d, yyyy"); + }, [note?.date]); + + // Source label + const sourceLabel = useMemo(() => { + if (!note?.isAIGenerated || !sourceConversation) return null; + const duration = sourceConversation.endTime + ? Math.round( + (new Date(sourceConversation.endTime).getTime() - + new Date(sourceConversation.startTime).getTime()) / + 60000, + ) + : null; + return `From: ${sourceConversation.title}${duration ? ` · ${duration} min` : ""}`; + }, [note, sourceConversation]); + + // Parse markdown to HTML + const parseContentToHtml = useCallback((content: string): string => { + if (!content) return ""; + if (content.includes("

    ") || content.includes("$1

    ") + .replace(/^## (.*$)/gim, "

    $1

    ") + .replace(/^# (.*$)/gim, "

    $1

    ") + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .split("\n\n") + .map((p) => p.trim()) + .filter((p) => p) + .map((p) => + p.startsWith("${p}

    `, + ) + .join(""); + }, []); - // TipTap editor - always editable, minimal config + const buildEditorContent = useCallback( + (note: Note): string => { + let html = ""; + const content = note.content; + const summary = note.summary; + if ( + content && + content.trim() && + content.trim() !== "Tap to edit this note..." + ) { + html = + content.includes("

    ") || content.includes("") || summary.includes("${summary}

    `; + } + return rewriteR2Urls(html); + }, + [parseContentToHtml], + ); + + // TipTap editor — always inline, always editable const editor = useEditor({ extensions: [ - StarterKit.configure({ - heading: { - levels: [1, 2, 3], - }, - }), + StarterKit.configure({ heading: { levels: [1, 2, 3] } }), Image.configure({ inline: false, allowBase64: false, - HTMLAttributes: { - class: "rounded-lg max-w-full h-auto my-3", - }, + HTMLAttributes: { class: "rounded-lg max-w-full h-auto my-3" }, }), Placeholder.configure({ placeholder: "Start writing...", @@ -75,115 +207,30 @@ export function NotePage() { editable: true, immediatelyRender: false, editorProps: { - attributes: { - class: "focus:outline-none min-h-[70vh]", - }, + attributes: { class: "focus:outline-none min-h-[200px]" }, }, onUpdate: ({ editor }) => { - // Debounced auto-save - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => { handleAutoSave(editor.getHTML()); }, 1500); }, }); - // Check if content is placeholder text - const isPlaceholderContent = (content: string | undefined): boolean => { - if (!content) return true; - const trimmed = content.trim().toLowerCase(); - return ( - trimmed === "" || - trimmed === "tap to edit this note..." || - trimmed === "

    " - ); - }; - - // Parse markdown-style content to HTML - const parseContent = useCallback((content: string): string => { - if (!content) return ""; - if (content.includes("

    ") || content.includes("$1") - .replace(/^## (.*$)/gim, "

    $1

    ") - .replace(/^# (.*$)/gim, "

    $1

    ") - .replace(/\*\*(.*?)\*\*/g, "$1") - .replace(/\*(.*?)\*/g, "$1") - .split("\n\n") - .map((p) => p.trim()) - .filter((p) => p) - .map((p) => { - if (p.startsWith("${p}

    `; - }) - .join(""); - - return html; - }, []); - - // Rewrite private R2 URLs to public URLs in HTML content - const rewritePhotoUrls = useCallback((html: string): string => { - return html.replaceAll( - "https://3c764e987404b8a1199ce5fdc3544a94.r2.cloudflarestorage.com/mentra-notes/", - "https://pub-b5f134142a0f4fbdb5c05a2f75fc8624.r2.dev/", - ); - }, []); - - // Build editor content from note - const buildEditorContent = useCallback( - (note: Note): string => { - let html = ""; - - // Content is already HTML from AI generation, use it directly - if (note.content && !isPlaceholderContent(note.content)) { - // If content already has HTML tags, use it directly - if (note.content.includes("

    ") || note.content.includes("") || note.summary.includes("${note.summary}

    `; - } - } - - return rewritePhotoUrls(html); - }, - [parseContent, rewritePhotoUrls], - ); - // Initialize editor content when note loads useEffect(() => { if (note && editor) { setEditTitle(note.title || ""); - const content = buildEditorContent(note); - editor.commands.setContent(content); + editor.commands.setContent(buildEditorContent(note)); } }, [note?.id, editor, buildEditorContent]); - // Auto-save function + // Auto-save content const handleAutoSave = async (content: string) => { if (!session?.notes?.updateNote || !note) return; - setIsSaving(true); try { - await session.notes.updateNote(noteId, { - title: editTitle, - content: content, - }); - // Show "Saved" briefly + await session.notes.updateNote(noteId, { title: editTitle, content }); setShowSaved(true); if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current); savedTimeoutRef.current = setTimeout(() => setShowSaved(false), 2000); @@ -197,29 +244,20 @@ export function NotePage() { // Save on title change (debounced) useEffect(() => { if (!note || editTitle === note.title) return; - const timeout = setTimeout(() => { - if (session?.notes?.updateNote) { - session.notes - .updateNote(noteId, { title: editTitle }) - .then(() => { - setShowSaved(true); - if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current); - savedTimeoutRef.current = setTimeout( - () => setShowSaved(false), - 2000, - ); - }) - .catch((err) => { - console.error("[NotePage] Failed to save title:", err); - }); - } + session?.notes + ?.updateNote(noteId, { title: editTitle }) + .then(() => { + setShowSaved(true); + if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current); + savedTimeoutRef.current = setTimeout(() => setShowSaved(false), 2000); + }) + .catch(() => {}); }, 1000); - return () => clearTimeout(timeout); }, [editTitle, note?.title, noteId, session?.notes]); - // Cleanup timeout on unmount + // Cleanup useEffect(() => { return () => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); @@ -227,262 +265,518 @@ export function NotePage() { }; }, []); - // Loading state - if (!session) { - return ; - } + if (!session) return ; - // Note not found if (!note) { return ( -
    -
    - -
    -
    -
    -

    Note not found

    - -
    +
    +
    + Note not found
    +
    ); } const handleBack = () => { - // Save any pending changes before leaving - if (editor) { - handleAutoSave(editor.getHTML()); - } - - // Use the note's date field (folder date) for navigation - // This ensures we go back to the correct folder, not derived from createdAt - if (note.date) { - setLocation(`/day/${note.date}`); - } else if (note.createdAt) { - // Fallback for old notes without date field - const date = new Date(note.createdAt); - const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; - setLocation(`/day/${dateString}`); - } else { - setLocation("/"); - } + // Save pending changes before leaving + if (editor) handleAutoSave(editor.getHTML()); + setLocation("/notes"); }; - const handleDelete = async () => { - if (!session?.notes?.deleteNote) return; - - try { - await session.notes.deleteNote(noteId); - handleBack(); - } catch (err) { - console.error("[NotePage] Failed to delete note:", err); - } + const handleEmailSend = async (to: string, cc: string) => { + if (!note) return; + const ccList = cc ? cc.split(",").filter(Boolean) : undefined; + const dateStr = note.date || ""; + const noteDate = dateStr ? new Date(dateStr + "T00:00:00") : new Date(); + const sessionDate = noteDate.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + const createdAt = note.createdAt ? new Date(note.createdAt) : new Date(); + const noteTimestamp = createdAt.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + const startTime = note.transcriptRange?.startTime + ? new Date(note.transcriptRange.startTime).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }) + : noteTimestamp; + const endTime = note.transcriptRange?.endTime + ? new Date(note.transcriptRange.endTime).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }) + : ""; + + const res = await fetch("/api/email/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + to, + cc: ccList, + sessionDate, + sessionStartTime: startTime, + sessionEndTime: endTime, + notes: [ + { + noteId: note.id, + noteTimestamp, + noteTitle: editTitle || note.title, + noteContent: editor?.getHTML() || note.content, + noteType: note.isAIGenerated ? "AI Generated" : "Manual", + }, + ], + }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || "Failed to send email"); }; return ( -
    - {/* Header */} -
    - {/* Left side - fixed width to balance right side */} -
    - -
    + Note + + - {/* Formatting toolbar - centered */} - {editor && ( -
    - - - -
    + + {/* Scrollable content */} +
    + {/* Meta section */} +
    + {/* Badges row */} +
    + {note.isAIGenerated ? ( +
    + + AI + +
    + ) : ( +
    + + Manual + +
    + )} + {(() => { + const folders = session?.folders?.folders ?? []; + const noteFolder = folders.find((f) => f.id === note.folderId); + if (!noteFolder) return null; + const color = FOLDER_COLOR_MAP[noteFolder.color]; + return ( +
    + + + + + {noteFolder.name} + +
    + ); + })()} + - - + {dateLabel} + +
    + {/* Save status */} + + {isSaving ? "Saving..." : showSaved ? "Saved" : ""} + + {/* Export button */} + {/* */} + {/* More menu */} + { + const isFav = note?.isFavourite ?? false; + const isArchived = note?.isArchived ?? false; + const isTrashed = note?.isTrashed ?? false; + + const items: DropdownMenuOption[] = [ + { + id: "favourite", + label: isFav ? "Unfavourite" : "Favourite", + icon: ( + + + + ), + onClick: async () => { + if (!session?.notes || !note) return; + if (isFav) { + await session.notes.unfavouriteNote(note.id); + } else { + await session.notes.favouriteNote(note.id); + } + }, + }, + { + id: "archive", + label: isArchived ? "Unarchive" : "Archive", + icon: ( + + + + + + ), + onClick: async () => { + if (!session?.notes || !note) return; + if (isArchived) { + await session.notes.unarchiveNote(note.id); + } else { + await session.notes.archiveNote(note.id); + } + }, + }, + { type: "divider" }, + { + id: "trash", + label: isTrashed ? "Untrash" : "Trash", + danger: !isTrashed, + icon: ( + + + + + + ), + onClick: async () => { + if (!session?.notes || !note) return; + if (isTrashed) { + await session.notes.untrashNote(note.id); + } else { + await session.notes.trashNote(note.id); + setLocation("/notes"); + } + }, + }, + ]; + return items; + })()} + /> +
    - )} - {/* Right side - fixed width to keep toolbar centered */} -
    - {/* Save status */} - - {isSaving ? ( - - ) : showSaved ? ( - <> - - Saved - - ) : null} - - - {/* Menu */} -
    - + {/* Title (editable, auto-wrapping) */} +