Created
February 24, 2026 03:04
-
-
Save toolness/bac39ccefb486729c1480cd7e2b15ab4 to your computer and use it in GitHub Desktop.
Draft storage design options for issue #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Draft Storage Design Options</title> | |
| <style> | |
| :root { | |
| --bg: #1a1a2e; | |
| --card: #16213e; | |
| --border: #0f3460; | |
| --text: #e0e0e0; | |
| --muted: #8a8a9a; | |
| --accent: #e94560; | |
| --accent2: #0f3460; | |
| --green: #4ecca3; | |
| --yellow: #f0c040; | |
| --orange: #e07c3a; | |
| --blue: #457b9d; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| line-height: 1.6; | |
| padding: 2rem; | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| } | |
| h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: #fff; } | |
| h2 { font-size: 1.4rem; margin: 2.5rem 0 1rem; color: #fff; border-bottom: 2px solid var(--border); padding-bottom: 0.5rem; } | |
| h3 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; color: var(--green); } | |
| p, li { color: var(--text); margin-bottom: 0.5rem; } | |
| ul { padding-left: 1.5rem; margin-bottom: 1rem; } | |
| .subtitle { color: var(--muted); font-size: 0.95rem; margin-bottom: 2rem; } | |
| .erd-container { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin: 1.5rem 0; | |
| overflow-x: auto; | |
| } | |
| .erd-container svg { display: block; margin: 0 auto; } | |
| .flow-step { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 1rem; | |
| margin: 0.75rem 0; | |
| } | |
| .flow-num { | |
| background: var(--blue); | |
| color: #fff; | |
| border-radius: 50%; | |
| min-width: 28px; | |
| height: 28px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.8rem; | |
| font-weight: 700; | |
| flex-shrink: 0; | |
| } | |
| .flow-text { padding-top: 2px; } | |
| .storage-badge { | |
| display: inline-block; | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| vertical-align: middle; | |
| } | |
| .badge-local { background: var(--orange); color: #fff; } | |
| .badge-server { background: var(--green); color: #1a1a2e; } | |
| .badge-none { background: var(--muted); color: #1a1a2e; } | |
| .problem-box { | |
| background: rgba(233, 69, 96, 0.1); | |
| border: 1px solid rgba(233, 69, 96, 0.3); | |
| border-radius: 8px; | |
| padding: 1rem 1.25rem; | |
| margin: 1rem 0; | |
| } | |
| .approach-card { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin: 1.5rem 0; | |
| } | |
| .pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; } | |
| .pro { color: var(--green); } | |
| .con { color: var(--orange); } | |
| .new-field { fill: #4ecca3; font-weight: bold; } | |
| .changed { fill: #f0c040; } | |
| code { | |
| background: rgba(255,255,255,0.08); | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| font-size: 0.85em; | |
| } | |
| .comparison-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin: 1rem 0; | |
| } | |
| .comparison-table th, .comparison-table td { | |
| padding: 0.6rem 1rem; | |
| text-align: left; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .comparison-table th { | |
| color: var(--muted); | |
| font-size: 0.85rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .comparison-table td:first-child { font-weight: 600; } | |
| .legend { display: flex; gap: 1.5rem; margin: 1rem 0; flex-wrap: wrap; } | |
| .legend-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; } | |
| .legend-swatch { width: 14px; height: 14px; border-radius: 3px; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Draft Storage: Current Design & Proposed Changes</h1> | |
| <p class="subtitle">Understanding the data model so we can pick the right approach</p> | |
| <!-- ============================================================ --> | |
| <h2>Current Database Schema</h2> | |
| <p>Four main collections, plus users. Arrows show foreign-key relations.</p> | |
| <div class="erd-container"> | |
| <svg viewBox="0 0 900 520" width="900" height="520" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, system-ui, sans-serif"> | |
| <!-- Users table --> | |
| <rect x="30" y="30" width="200" height="190" rx="8" fill="#1e2a3a" stroke="#457b9d" stroke-width="2"/> | |
| <rect x="30" y="30" width="200" height="36" rx="8" fill="#457b9d"/> | |
| <rect x="30" y="58" width="200" height="8" fill="#1e2a3a"/> | |
| <text x="130" y="54" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">users</text> | |
| <text x="44" y="82" fill="#8a8a9a" font-size="11">id</text><text x="216" y="82" text-anchor="end" fill="#607a8a" font-size="11">pk</text> | |
| <text x="44" y="102" fill="#e0e0e0" font-size="11">email</text> | |
| <text x="44" y="122" fill="#e0e0e0" font-size="11">display_name</text> | |
| <text x="44" y="142" fill="#e0e0e0" font-size="11">target_language</text> | |
| <text x="44" y="162" fill="#e0e0e0" font-size="11">proficiency_level</text> | |
| <text x="44" y="182" fill="#e0e0e0" font-size="11">tts_enabled</text> | |
| <text x="44" y="202" fill="#e0e0e0" font-size="11">can_create_conversations</text> | |
| <!-- Conversations table --> | |
| <rect x="340" y="30" width="220" height="150" rx="8" fill="#1e2a3a" stroke="#457b9d" stroke-width="2"/> | |
| <rect x="340" y="30" width="220" height="36" rx="8" fill="#457b9d"/> | |
| <rect x="340" y="58" width="220" height="8" fill="#1e2a3a"/> | |
| <text x="450" y="54" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">conversations</text> | |
| <text x="354" y="82" fill="#8a8a9a" font-size="11">id</text><text x="546" y="82" text-anchor="end" fill="#607a8a" font-size="11">pk</text> | |
| <text x="354" y="102" fill="#e0e0e0" font-size="11">participant_a</text><text x="546" y="102" text-anchor="end" fill="#607a8a" font-size="11">fk → users</text> | |
| <text x="354" y="122" fill="#e0e0e0" font-size="11">participant_b</text><text x="546" y="122" text-anchor="end" fill="#607a8a" font-size="11">fk → users</text> | |
| <text x="354" y="142" fill="#e0e0e0" font-size="11">last_message_at</text> | |
| <text x="354" y="162" fill="#e0e0e0" font-size="11">is_composing_user</text><text x="546" y="162" text-anchor="end" fill="#607a8a" font-size="11">fk → users</text> | |
| <!-- Messages table --> | |
| <rect x="340" y="240" width="220" height="140" rx="8" fill="#1e2a3a" stroke="#457b9d" stroke-width="2"/> | |
| <rect x="340" y="240" width="220" height="36" rx="8" fill="#457b9d"/> | |
| <rect x="340" y="268" width="220" height="8" fill="#1e2a3a"/> | |
| <text x="450" y="264" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">messages</text> | |
| <text x="354" y="292" fill="#8a8a9a" font-size="11">id</text><text x="546" y="292" text-anchor="end" fill="#607a8a" font-size="11">pk</text> | |
| <text x="354" y="312" fill="#e0e0e0" font-size="11">conversation</text><text x="546" y="312" text-anchor="end" fill="#607a8a" font-size="11">fk → conversations</text> | |
| <text x="354" y="332" fill="#e0e0e0" font-size="11">sender</text><text x="546" y="332" text-anchor="end" fill="#607a8a" font-size="11">fk → users</text> | |
| <text x="354" y="352" fill="#e0e0e0" font-size="11">content</text> | |
| <text x="354" y="372" fill="#e0e0e0" font-size="11">created</text> | |
| <!-- Translations table --> | |
| <rect x="660" y="220" width="220" height="200" rx="8" fill="#1e2a3a" stroke="#457b9d" stroke-width="2"/> | |
| <rect x="660" y="220" width="220" height="36" rx="8" fill="#457b9d"/> | |
| <rect x="660" y="248" width="220" height="8" fill="#1e2a3a"/> | |
| <text x="770" y="244" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">translations</text> | |
| <text x="674" y="272" fill="#8a8a9a" font-size="11">id</text><text x="866" y="272" text-anchor="end" fill="#607a8a" font-size="11">pk</text> | |
| <text x="674" y="292" fill="#e0e0e0" font-size="11">message</text><text x="866" y="292" text-anchor="end" fill="#607a8a" font-size="11">fk → messages</text> | |
| <text x="674" y="312" fill="#e0e0e0" font-size="11">target_user</text><text x="866" y="312" text-anchor="end" fill="#607a8a" font-size="11">fk → users</text> | |
| <text x="674" y="332" fill="#e0e0e0" font-size="11">translated_text</text> | |
| <text x="674" y="352" fill="#e0e0e0" font-size="11">breakdown_json</text> | |
| <text x="674" y="372" fill="#e0e0e0" font-size="11">cognates_json</text> | |
| <text x="674" y="392" fill="#e0e0e0" font-size="11">grammar_notes_json</text> | |
| <text x="674" y="412" fill="#e0e0e0" font-size="11">idiom_notes_json</text> | |
| <!-- Drafts table --> | |
| <rect x="30" y="310" width="220" height="190" rx="8" fill="#1e2a3a" stroke="#457b9d" stroke-width="2"/> | |
| <rect x="30" y="310" width="220" height="36" rx="8" fill="#457b9d"/> | |
| <rect x="30" y="338" width="220" height="8" fill="#1e2a3a"/> | |
| <text x="140" y="334" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">drafts</text> | |
| <text x="44" y="362" fill="#8a8a9a" font-size="11">id</text><text x="236" y="362" text-anchor="end" fill="#607a8a" font-size="11">pk</text> | |
| <text x="44" y="382" fill="#e0e0e0" font-size="11">conversation</text><text x="236" y="382" text-anchor="end" fill="#607a8a" font-size="11">fk → conversations</text> | |
| <text x="44" y="402" fill="#e0e0e0" font-size="11">sender</text><text x="236" y="402" text-anchor="end" fill="#607a8a" font-size="11">fk → users</text> | |
| <text x="44" y="422" fill="#e0e0e0" font-size="11">content</text> | |
| <text x="44" y="442" fill="#e0e0e0" font-size="11">ai_response</text> | |
| <text x="44" y="462" fill="#e0e0e0" font-size="11">sequence</text> | |
| <text x="44" y="482" fill="#e0e0e0" font-size="11">message</text><text x="236" y="482" text-anchor="end" fill="#607a8a" font-size="11">fk → messages</text> | |
| <!-- Relationship lines --> | |
| <!-- conversations.participant_a -> users --> | |
| <line x1="340" y1="98" x2="230" y2="82" stroke="#607a8a" stroke-width="1.5" stroke-dasharray="6,3"/> | |
| <!-- conversations.participant_b -> users --> | |
| <line x1="340" y1="118" x2="230" y2="92" stroke="#607a8a" stroke-width="1.5" stroke-dasharray="6,3"/> | |
| <!-- messages.conversation -> conversations --> | |
| <line x1="450" y1="240" x2="450" y2="180" stroke="#607a8a" stroke-width="1.5"/> | |
| <polygon points="450,184 445,194 455,194" fill="#607a8a"/> | |
| <!-- messages.sender -> users --> | |
| <path d="M 340 328 C 280 328, 280 160, 230 160" stroke="#607a8a" stroke-width="1.5" fill="none" stroke-dasharray="6,3"/> | |
| <!-- translations.message -> messages --> | |
| <line x1="660" y1="288" x2="560" y2="310" stroke="#607a8a" stroke-width="1.5"/> | |
| <polygon points="564,308 558,318 568,316" fill="#607a8a"/> | |
| <!-- drafts.conversation -> conversations --> | |
| <path d="M 140 310 C 140 260, 300 200, 340 140" stroke="#607a8a" stroke-width="1.5" fill="none"/> | |
| <polygon points="337,144 346,136 342,146" fill="#607a8a"/> | |
| <!-- drafts.message -> messages --> | |
| <line x1="250" y1="478" x2="340" y2="370" stroke="#607a8a" stroke-width="1.5"/> | |
| <polygon points="337,374 346,366 342,376" fill="#607a8a"/> | |
| </svg> | |
| </div> | |
| <!-- ============================================================ --> | |
| <h2>Current Draft Flow (What Happens Today)</h2> | |
| <p>When a non-fluent user drafts a message, the system uses <strong>two different storage locations</strong> at different stages:</p> | |
| <h3>Stage 1: While Drafting (in-progress)</h3> | |
| <p>The back-and-forth conversation with the AI assistant is stored in the <strong>browser's localStorage</strong>.</p> | |
| <div class="flow-step"><div class="flow-num">1</div><div class="flow-text">User types "annyeonghaseyo" in the compose input <span class="storage-badge badge-local">sessionStorage</span></div></div> | |
| <div class="flow-step"><div class="flow-num">2</div><div class="flow-text">User clicks "Draft" → opens the Drafting Panel</div></div> | |
| <div class="flow-step"><div class="flow-num">3</div><div class="flow-text">Draft auto-submitted to <code>POST /api/assist</code> → AI responds with feedback</div></div> | |
| <div class="flow-step"><div class="flow-num">4</div><div class="flow-text">The conversation <code>[{role:'user', text:'...'}, {role:'ai', text:'...'}]</code> is saved to <span class="storage-badge badge-local">localStorage</span></div></div> | |
| <div class="flow-step"><div class="flow-num">5</div><div class="flow-text">User revises → repeat steps 3-4 (conversation grows)</div></div> | |
| <h3>Stage 2: On Send (finalized)</h3> | |
| <p>Once the user clicks "Send", two things happen atomically:</p> | |
| <div class="flow-step"><div class="flow-num">6</div><div class="flow-text">A <strong>message</strong> record is created in PocketBase <span class="storage-badge badge-server">server</span><br/> | |
| <em>This triggers translation + push notifications via a backend hook</em></div></div> | |
| <div class="flow-step"><div class="flow-num">7</div><div class="flow-text"><strong>Draft</strong> records are created in PocketBase, linked to the message <span class="storage-badge badge-server">server</span><br/> | |
| <em>These are the historical record of the drafting conversation</em></div></div> | |
| <div class="flow-step"><div class="flow-num">8</div><div class="flow-text">localStorage is cleared <span class="storage-badge badge-none">deleted</span></div></div> | |
| <h3>Stage 3: After Send (viewing history)</h3> | |
| <p>When you view a sent message, you can expand "draft history" to see the back-and-forth that led to it. This reads from the <code>drafts</code> collection (server).</p> | |
| <div class="problem-box"> | |
| <strong>The Problem:</strong> During Stage 1, all state lives in localStorage. If the user's session expires, they switch devices, or clear their browser — the in-progress draft conversation is <strong>lost forever</strong>. The <code>drafts</code> collection only gets populated <em>after</em> sending (Stage 2). | |
| </div> | |
| <!-- ============================================================ --> | |
| <h2>Approach A: Minimal — Save Draft Entries to Server Incrementally</h2> | |
| <p>The simplest fix: save each drafting-conversation entry to the <code>drafts</code> collection <strong>as it happens</strong>, not just after sending. No changes to the <code>messages</code> collection at all.</p> | |
| <div class="erd-container"> | |
| <svg viewBox="0 0 680 300" width="680" height="300" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, system-ui, sans-serif"> | |
| <defs> | |
| <filter id="glow-green"><feDropShadow dx="0" dy="0" stdDeviation="2" flood-color="#4ecca3" flood-opacity="0.5"/></filter> | |
| </defs> | |
| <!-- Messages table (unchanged) --> | |
| <rect x="30" y="30" width="220" height="120" rx="8" fill="#1e2a3a" stroke="#457b9d" stroke-width="2"/> | |
| <rect x="30" y="30" width="220" height="36" rx="8" fill="#457b9d"/> | |
| <rect x="30" y="58" width="220" height="8" fill="#1e2a3a"/> | |
| <text x="140" y="54" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">messages</text> | |
| <text x="120" y="54" text-anchor="end" fill="rgba(255,255,255,0.4)" font-size="10">unchanged </text> | |
| <text x="44" y="82" fill="#8a8a9a" font-size="12">id</text> | |
| <text x="44" y="102" fill="#e0e0e0" font-size="12">conversation</text> | |
| <text x="44" y="122" fill="#e0e0e0" font-size="12">sender, content, created</text> | |
| <text x="44" y="142" fill="#e0e0e0" font-size="12">...</text> | |
| <!-- Drafts table (changed) --> | |
| <rect x="380" y="30" width="270" height="250" rx="8" fill="#1e2a3a" stroke="#4ecca3" stroke-width="2.5" filter="url(#glow-green)"/> | |
| <rect x="380" y="30" width="270" height="36" rx="8" fill="#3a7a63"/> | |
| <rect x="380" y="58" width="270" height="8" fill="#1e2a3a"/> | |
| <text x="515" y="54" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">drafts</text> | |
| <text x="495" y="54" text-anchor="end" fill="rgba(255,255,255,0.6)" font-size="10">modified </text> | |
| <text x="394" y="82" fill="#8a8a9a" font-size="12">id</text> | |
| <text x="394" y="102" fill="#e0e0e0" font-size="12">conversation</text> | |
| <text x="394" y="122" fill="#e0e0e0" font-size="12">sender</text> | |
| <text x="394" y="142" fill="#e0e0e0" font-size="12">content</text><text x="636" y="142" text-anchor="end" fill="#8a8a9a" font-size="10">user's draft text</text> | |
| <text x="394" y="162" fill="#e0e0e0" font-size="12">ai_response</text><text x="636" y="162" text-anchor="end" fill="#8a8a9a" font-size="10">AI's feedback</text> | |
| <text x="394" y="182" fill="#e0e0e0" font-size="12">sequence</text> | |
| <text x="394" y="202" fill="#e0e0e0" font-size="12">message</text><text x="636" y="202" text-anchor="end" fill="#8a8a9a" font-size="10">null while drafting, set on send</text> | |
| <text x="394" y="226" fill="#4ecca3" font-size="12" font-weight="700">+ is_active</text><text x="636" y="226" text-anchor="end" fill="#4ecca3" font-size="10">NEW: true while drafting</text> | |
| <text x="394" y="248" fill="#4ecca3" font-size="12" font-weight="700">+ ai_approved</text><text x="636" y="248" text-anchor="end" fill="#4ecca3" font-size="10">NEW: AI says it's good</text> | |
| <text x="394" y="268" fill="#4ecca3" font-size="12" font-weight="700">+ draft_text</text><text x="636" y="268" text-anchor="end" fill="#4ecca3" font-size="10">NEW: current compose input</text> | |
| <!-- Arrow: drafts.message -> messages (optional) --> | |
| <line x1="380" y1="198" x2="250" y2="120" stroke="#607a8a" stroke-width="1.5" stroke-dasharray="6,3"/> | |
| <text x="300" y="148" fill="#8a8a9a" font-size="10" text-anchor="middle">linked on send</text> | |
| </svg> | |
| </div> | |
| <div class="approach-card"> | |
| <h3>How It Works</h3> | |
| <div class="flow-step"><div class="flow-num">1</div><div class="flow-text">User clicks "Draft" → first draft entry created on server with <code>is_active: true</code>, <code>message: null</code></div></div> | |
| <div class="flow-step"><div class="flow-num">2</div><div class="flow-text">AI responds → draft entry updated with <code>ai_response</code> (and <code>ai_approved</code> if applicable)</div></div> | |
| <div class="flow-step"><div class="flow-num">3</div><div class="flow-text">User revises → new draft entry created (same pattern, <code>sequence</code> increments)</div></div> | |
| <div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>On Send:</strong> message created normally → all active draft entries get <code>message</code> set and <code>is_active: false</code></div></div> | |
| <div class="flow-step"><div class="flow-num">5</div><div class="flow-text"><strong>On Cancel:</strong> active draft entries deleted</div></div> | |
| <div class="flow-step"><div class="flow-num">6</div><div class="flow-text"><strong>On Restore:</strong> query <code>drafts</code> where <code>is_active = true</code> for this conversation → reopen panel with full history</div></div> | |
| <div class="pros-cons"> | |
| <div> | |
| <p class="pro"><strong>Pros</strong></p> | |
| <ul> | |
| <li class="pro">No changes to the <code>messages</code> collection at all</li> | |
| <li class="pro">No realtime subscription complexity</li> | |
| <li class="pro">No new access rules needed on messages</li> | |
| <li class="pro">Message creation still happens at send time (same hook fires normally)</li> | |
| <li class="pro">Small migration, small code change</li> | |
| </ul> | |
| </div> | |
| <div> | |
| <p class="con"><strong>Cons</strong></p> | |
| <ul> | |
| <li class="con">The compose-input text needs a home — stored on the latest active draft entry as <code>draft_text</code></li> | |
| <li class="con">If user types but never clicks "Draft", nothing is saved (same as today)</li> | |
| <li class="con">Draft entries exist without a message during drafting (message is null) — need to filter these out of draft-history UI</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ============================================================ --> | |
| <h2>Approach B: Full — Draft Messages on the Server</h2> | |
| <p>Add an <code>is_draft</code> boolean to the <code>messages</code> collection. A draft message is created as soon as drafting starts, then "published" by flipping <code>is_draft</code> to <code>false</code> when sent.</p> | |
| <div class="erd-container"> | |
| <svg viewBox="0 0 680 360" width="680" height="360" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, system-ui, sans-serif"> | |
| <!-- Messages table (changed) --> | |
| <rect x="30" y="30" width="270" height="160" rx="8" fill="#1e2a3a" stroke="#4ecca3" stroke-width="2.5" filter="url(#glow-green)"/> | |
| <rect x="30" y="30" width="270" height="36" rx="8" fill="#3a7a63"/> | |
| <rect x="30" y="58" width="270" height="8" fill="#1e2a3a"/> | |
| <text x="165" y="54" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">messages</text> | |
| <text x="145" y="54" text-anchor="end" fill="rgba(255,255,255,0.6)" font-size="10">modified </text> | |
| <text x="44" y="82" fill="#8a8a9a" font-size="12">id</text> | |
| <text x="44" y="102" fill="#e0e0e0" font-size="12">conversation</text> | |
| <text x="44" y="122" fill="#e0e0e0" font-size="12">sender</text> | |
| <text x="44" y="142" fill="#e0e0e0" font-size="12">content, created</text> | |
| <text x="44" y="166" fill="#4ecca3" font-size="12" font-weight="700">+ is_draft</text><text x="286" y="166" text-anchor="end" fill="#4ecca3" font-size="10">NEW: true until sent</text> | |
| <text x="44" y="184" fill="#f0c040" font-size="11">~ ListRule changed</text><text x="286" y="184" text-anchor="end" fill="#f0c040" font-size="10">hide drafts from other user</text> | |
| <!-- Drafts table (changed) --> | |
| <rect x="380" y="30" width="270" height="230" rx="8" fill="#1e2a3a" stroke="#4ecca3" stroke-width="2.5" filter="url(#glow-green)"/> | |
| <rect x="380" y="30" width="270" height="36" rx="8" fill="#3a7a63"/> | |
| <rect x="380" y="58" width="270" height="8" fill="#1e2a3a"/> | |
| <text x="515" y="54" text-anchor="middle" fill="#fff" font-weight="700" font-size="14">drafts</text> | |
| <text x="495" y="54" text-anchor="end" fill="rgba(255,255,255,0.6)" font-size="10">modified </text> | |
| <text x="394" y="82" fill="#8a8a9a" font-size="12">id</text> | |
| <text x="394" y="102" fill="#e0e0e0" font-size="12">conversation, sender</text> | |
| <text x="394" y="122" fill="#e0e0e0" font-size="12">content</text> | |
| <text x="394" y="142" fill="#e0e0e0" font-size="12">ai_response</text> | |
| <text x="394" y="162" fill="#e0e0e0" font-size="12">sequence</text> | |
| <text x="394" y="182" fill="#e0e0e0" font-size="12">message</text><text x="636" y="182" text-anchor="end" fill="#8a8a9a" font-size="10">links to draft msg immediately</text> | |
| <text x="394" y="206" fill="#4ecca3" font-size="12" font-weight="700">+ is_active</text><text x="636" y="206" text-anchor="end" fill="#4ecca3" font-size="10">NEW: true while drafting</text> | |
| <text x="394" y="228" fill="#4ecca3" font-size="12" font-weight="700">+ ai_approved</text><text x="636" y="228" text-anchor="end" fill="#4ecca3" font-size="10">NEW: AI says it's good</text> | |
| <!-- Arrow: drafts.message -> messages --> | |
| <line x1="380" y1="178" x2="300" y2="130" stroke="#4ecca3" stroke-width="1.5"/> | |
| <text x="326" y="145" fill="#4ecca3" font-size="10" text-anchor="middle">linked immediately</text> | |
| <!-- Backend changes box --> | |
| <rect x="30" y="230" width="620" height="120" rx="8" fill="rgba(240,192,64,0.08)" stroke="#f0c040" stroke-width="1"/> | |
| <text x="50" y="254" fill="#f0c040" font-weight="700" font-size="13">Backend Hook Changes Required</text> | |
| <text x="50" y="278" fill="#e0e0e0" font-size="12">1. OnRecordAfterCreateSuccess("messages"): skip if is_draft = true</text> | |
| <text x="50" y="298" fill="#e0e0e0" font-size="12">2. NEW OnRecordAfterUpdateSuccess("messages"): when is_draft flips false → trigger translation</text> | |
| <text x="50" y="318" fill="#e0e0e0" font-size="12">3. Add UpdateRule + DeleteRule to messages (sender can update own drafts)</text> | |
| <text x="50" y="338" fill="#e0e0e0" font-size="12">4. ListRule: (is_draft = false || sender = @request.auth.id) — hide drafts from other user</text> | |
| </svg> | |
| </div> | |
| <div class="approach-card"> | |
| <h3>How It Works</h3> | |
| <div class="flow-step"><div class="flow-num">1</div><div class="flow-text">User clicks "Draft" → a <strong>message</strong> is created with <code>is_draft: true</code>. Backend hook sees the flag and <strong>skips</strong> translation/push.</div></div> | |
| <div class="flow-step"><div class="flow-num">2</div><div class="flow-text">Draft entries are created on the server, linked to this draft message immediately</div></div> | |
| <div class="flow-step"><div class="flow-num">3</div><div class="flow-text">User revises → message content updated, new draft entry added</div></div> | |
| <div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>On Send:</strong> message updated: <code>is_draft: false</code>. Backend hook fires, triggering translation + push. Draft entries marked <code>is_active: false</code>.</div></div> | |
| <div class="flow-step"><div class="flow-num">5</div><div class="flow-text"><strong>On Cancel:</strong> draft message deleted, draft entries deleted</div></div> | |
| <div class="flow-step"><div class="flow-num">6</div><div class="flow-text"><strong>On Restore:</strong> query messages where <code>is_draft = true</code> → reopen panel with full history</div></div> | |
| <div class="pros-cons"> | |
| <div> | |
| <p class="pro"><strong>Pros</strong></p> | |
| <ul> | |
| <li class="pro">Complete server-side state — the draft message text is always on the server</li> | |
| <li class="pro">Cleaner relationship: draft entries always have a message FK</li> | |
| <li class="pro">The "current text" has a natural home (the message's <code>content</code> field)</li> | |
| <li class="pro">Better for future features (e.g., showing "X is drafting..." to the other user)</li> | |
| </ul> | |
| </div> | |
| <div> | |
| <p class="con"><strong>Cons</strong></p> | |
| <ul> | |
| <li class="con">Must modify <code>messages</code> ListRule/ViewRule to hide drafts from other participant</li> | |
| <li class="con">Must add UpdateRule + DeleteRule to messages (currently none)</li> | |
| <li class="con">Translation hook changes: must gate on <code>is_draft</code>, plus add a new <code>OnRecordAfterUpdateSuccess</code> hook for the draft→published transition</li> | |
| <li class="con">Realtime subscription edge case: the other user receives a message <em>update</em> event (not <em>create</em>) when the draft is published — needs careful handling</li> | |
| <li class="con">More moving parts = more things to test</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ============================================================ --> | |
| <h2>Side-by-Side Comparison</h2> | |
| <table class="comparison-table"> | |
| <thead> | |
| <tr><th>Aspect</th><th>Approach A (Minimal)</th><th>Approach B (Full)</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td>Collections changed</td> | |
| <td>1 (<code>drafts</code>)</td> | |
| <td>2 (<code>messages</code> + <code>drafts</code>)</td> | |
| </tr> | |
| <tr> | |
| <td>New fields</td> | |
| <td>3 on drafts</td> | |
| <td>1 on messages + 2 on drafts</td> | |
| </tr> | |
| <tr> | |
| <td>Access rule changes</td> | |
| <td>Add UpdateRule/DeleteRule to drafts</td> | |
| <td>Modify ListRule/ViewRule on messages, add UpdateRule/DeleteRule to both</td> | |
| </tr> | |
| <tr> | |
| <td>Backend hook changes</td> | |
| <td>None</td> | |
| <td>Gate create hook on is_draft, add update hook</td> | |
| </tr> | |
| <tr> | |
| <td>Realtime complexity</td> | |
| <td>None — messages still only created at send</td> | |
| <td>Must handle update events as "new message" for other participant</td> | |
| </tr> | |
| <tr> | |
| <td>Draft survives logout?</td> | |
| <td>Yes (draft entries on server)</td> | |
| <td>Yes (draft message + entries on server)</td> | |
| </tr> | |
| <tr> | |
| <td>Cross-device sync?</td> | |
| <td>Yes (query active drafts on load)</td> | |
| <td>Yes (query draft messages on load)</td> | |
| </tr> | |
| <tr> | |
| <td>Current compose text preserved?</td> | |
| <td>Stored as <code>draft_text</code> on latest active draft entry</td> | |
| <td>Stored as <code>content</code> on the draft message itself</td> | |
| </tr> | |
| <tr> | |
| <td>Estimated complexity</td> | |
| <td>Small</td> | |
| <td>Medium-Large</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <div class="legend" style="margin-top: 2rem;"> | |
| <div class="legend-item"><div class="legend-swatch" style="background: #4ecca3;"></div> New field / modified table</div> | |
| <div class="legend-item"><div class="legend-swatch" style="background: #f0c040;"></div> Changed rule / hook</div> | |
| <div class="legend-item"><div class="legend-swatch" style="background: #457b9d;"></div> Unchanged</div> | |
| </div> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment