( rows: MessageSummary[], now: Date = new Date(), )
| 95 | * @param now Reference timestamp for "closed >7d" — caller injects so tests can pin it. |
| 96 | */ |
| 97 | export function groupIntoThreads( |
| 98 | rows: MessageSummary[], |
| 99 | now: Date = new Date(), |
| 100 | ): Thread[] { |
| 101 | const nowMs = now.getTime(); |
| 102 | const buckets = new Map<string, MessageSummary[]>(); |
| 103 | |
| 104 | for (const m of rows) { |
| 105 | const key = m.conversation_id |
| 106 | ? `conv:${m.conversation_id}` |
| 107 | : `orphan:${m.message_id}`; |
| 108 | const bucket = buckets.get(key); |
| 109 | if (bucket) bucket.push(m); |
| 110 | else buckets.set(key, [m]); |
| 111 | } |
| 112 | |
| 113 | const threads: Thread[] = []; |
| 114 | for (const [key, bucket] of buckets) { |
| 115 | // Order oldest → newest. Stable on created_at; ties broken by message_id |
| 116 | // so the order is deterministic even at sub-second resolution. |
| 117 | bucket.sort((a, b) => { |
| 118 | const ad = new Date(a.created_at).getTime(); |
| 119 | const bd = new Date(b.created_at).getTime(); |
| 120 | if (ad !== bd) return ad - bd; |
| 121 | return a.message_id.localeCompare(b.message_id); |
| 122 | }); |
| 123 | |
| 124 | const latest = bucket[bucket.length - 1]; |
| 125 | const oldest = bucket[0]; |
| 126 | |
| 127 | // Subject: first non-empty in the thread, else "(no subject)". |
| 128 | const subject = |
| 129 | bucket.map((m) => m.subject).find((s) => s && s.trim() !== "") || |
| 130 | "(no subject)"; |
| 131 | |
| 132 | // Counterparty: derived from any non-agent participant. We use the |
| 133 | // latest message's "other side" — typically what the user thinks of |
| 134 | // as "who this thread is with". |
| 135 | const cpEmail = counterpartyEmail(latest); |
| 136 | const counterparty: Counterparty = { |
| 137 | email: cpEmail, |
| 138 | name: nameFromEmail(cpEmail), |
| 139 | }; |
| 140 | |
| 141 | threads.push({ |
| 142 | key, |
| 143 | conversationId: latest.conversation_id || undefined, |
| 144 | counterparty, |
| 145 | subject, |
| 146 | state: deriveState(bucket, nowMs), |
| 147 | lastMessageAt: latest.created_at, |
| 148 | startedAt: oldest.created_at, |
| 149 | msgCount: bucket.length, |
| 150 | lastDirection: latest.direction, |
| 151 | // Subject as preview for v1 — body parts aren't in the wire payload |
| 152 | // yet. When a server-side preview field lands, switch to that. |
| 153 | lastPreview: (latest.subject || "(no subject)").slice(0, 80), |
| 154 | messages: bucket, |
no test coverage detected