Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

digest.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

digest.tsxBlame508 lines · 1 contributor
cc34156Claude1/**
2 * /digest — Smart Morning Digest page.
3 *
4 * GET /digest — personal AI-curated digest (requireAuth)
5 * POST /digest/refresh — regenerate digest for current user, then redirect
6 *
7 * Shows the user's most recent digest notification (type='digest') or
8 * auto-generates one on first load. Displays:
9 * - AI-written headline
10 * - Priority-sorted queue (blocking=red, important=amber, fyi=grey)
11 * - Stats row (PRs reviewed / issues closed / commits)
12 * - Optional AI insight
13 * - "Regenerate" button
14 */
15
16import { Hono } from "hono";
17import { eq, and, desc } from "drizzle-orm";
18import { db } from "../db";
19import { notifications, users } from "../db/schema";
20import { Layout } from "../views/layout";
21import { requireAuth, softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { composeSmartDigest, sendSmartDigest, type SmartDigest, type DigestItem } from "../lib/smart-digest";
24import { formatRelative } from "../views/ui";
25
26const digest = new Hono<AuthEnv>();
27
28// ---------------------------------------------------------------------------
29// Styles
30// ---------------------------------------------------------------------------
31
32const DIGEST_STYLES = `
33 .digest-hero {
34 position: relative;
35 margin: 0 0 var(--space-5, 24px);
36 padding: 24px 28px 26px;
37 background: var(--bg-elevated, #f8f9fa);
38 border: 1px solid var(--border, #e1e4e8);
39 border-radius: 16px;
40 overflow: hidden;
41 }
42 .digest-hero::before {
43 content: '';
44 position: absolute; top: 0; left: 0; right: 0;
45 height: 2px;
46 background: linear-gradient(90deg, transparent 0%, #f7971e 30%, #ffd200 70%, transparent 100%);
47 opacity: 0.8;
48 pointer-events: none;
49 }
50 .digest-hero-icon {
51 font-size: 28px;
52 margin-bottom: 10px;
53 display: block;
54 }
55 .digest-headline {
56 font-size: clamp(20px, 2.8vw, 28px);
57 font-weight: 800;
58 letter-spacing: -0.02em;
59 color: var(--text-strong, #111);
60 margin: 0 0 8px;
61 line-height: 1.15;
62 }
63 .digest-meta {
64 font-size: 12.5px;
65 color: var(--text-muted, #777);
66 margin: 0;
67 }
68 .digest-actions {
69 display: flex;
70 gap: 8px;
71 align-items: center;
72 flex-wrap: wrap;
73 margin-top: 16px;
74 }
75 .digest-regenerate-btn {
76 display: inline-flex;
77 align-items: center;
78 gap: 6px;
79 padding: 8px 14px;
80 background: var(--bg, #fff);
81 border: 1px solid var(--border, #e1e4e8);
82 border-radius: 8px;
83 font-size: 13px;
84 font-weight: 500;
85 color: var(--text, #333);
86 cursor: pointer;
87 text-decoration: none;
88 }
89 .digest-regenerate-btn:hover {
90 background: var(--bg-elevated, #f8f9fa);
91 border-color: var(--accent, #0070f3);
92 }
93
94 /* Queue */
95 .digest-queue {
96 display: flex;
97 flex-direction: column;
98 gap: 8px;
99 margin-bottom: 24px;
100 }
101 .digest-item {
102 display: flex;
103 align-items: center;
104 gap: 12px;
105 padding: 12px 16px;
106 background: var(--bg-elevated, #f8f9fa);
107 border: 1px solid var(--border, #e1e4e8);
108 border-radius: 10px;
109 border-left: 3px solid transparent;
110 text-decoration: none;
111 color: inherit;
112 transition: box-shadow 0.15s;
113 }
114 .digest-item:hover {
115 box-shadow: 0 2px 8px rgba(0,0,0,0.08);
116 }
117 .digest-item.blocking {
118 border-left-color: #ef4444;
119 background: #fef2f2;
120 }
121 .digest-item.important {
122 border-left-color: #f59e0b;
123 background: #fffbeb;
124 }
125 .digest-item.fyi {
126 border-left-color: #94a3b8;
127 }
128 .digest-item-icon {
129 font-size: 18px;
130 flex-shrink: 0;
131 width: 28px;
132 text-align: center;
133 }
134 .digest-item-content {
135 flex: 1;
136 min-width: 0;
137 }
138 .digest-item-title {
139 font-size: 14px;
140 font-weight: 600;
141 color: var(--text-strong, #111);
142 margin: 0 0 2px;
143 white-space: nowrap;
144 overflow: hidden;
145 text-overflow: ellipsis;
146 }
147 .digest-item-subtitle {
148 font-size: 12px;
149 color: var(--text-muted, #777);
150 margin: 0;
151 }
152 .digest-item-priority {
153 font-size: 11px;
154 font-weight: 600;
155 padding: 2px 7px;
156 border-radius: 99px;
157 flex-shrink: 0;
158 }
159 .digest-item-priority.blocking {
160 background: #fee2e2;
161 color: #b91c1c;
162 }
163 .digest-item-priority.important {
164 background: #fef3c7;
165 color: #92400e;
166 }
167 .digest-item-priority.fyi {
168 background: var(--bg, #f1f5f9);
169 color: var(--text-muted, #64748b);
170 }
171 .digest-item-go {
172 font-size: 13px;
173 color: var(--accent, #0070f3);
174 flex-shrink: 0;
175 font-weight: 500;
176 }
177
178 /* Stats row */
179 .digest-stats {
180 display: flex;
181 gap: 16px;
182 flex-wrap: wrap;
183 margin-bottom: 24px;
184 }
185 .digest-stat-card {
186 flex: 1;
187 min-width: 120px;
188 padding: 14px 18px;
189 background: var(--bg-elevated, #f8f9fa);
190 border: 1px solid var(--border, #e1e4e8);
191 border-radius: 10px;
192 text-align: center;
193 }
194 .digest-stat-value {
195 font-size: 28px;
196 font-weight: 800;
197 color: var(--text-strong, #111);
198 letter-spacing: -0.03em;
199 line-height: 1;
200 margin-bottom: 4px;
201 }
202 .digest-stat-label {
203 font-size: 12px;
204 color: var(--text-muted, #777);
205 }
206
207 /* Insight */
208 .digest-insight {
209 padding: 16px 20px;
210 background: linear-gradient(135deg, #f0f4ff 0%, #fdf4ff 100%);
211 border: 1px solid #c7d2fe;
212 border-radius: 12px;
213 margin-bottom: 24px;
214 display: flex;
215 gap: 12px;
216 align-items: flex-start;
217 }
218 .digest-insight-icon {
219 font-size: 20px;
220 flex-shrink: 0;
221 margin-top: 2px;
222 }
223 .digest-insight-text {
224 font-size: 14px;
225 color: #3730a3;
226 margin: 0;
227 line-height: 1.5;
228 }
229
230 /* Empty state */
231 .digest-empty {
232 text-align: center;
233 padding: 48px 24px;
234 color: var(--text-muted, #777);
235 }
236 .digest-empty-icon { font-size: 40px; margin-bottom: 12px; }
237 .digest-empty-text { font-size: 16px; font-weight: 600; margin-bottom: 8px; color: var(--text-strong, #111); }
238 .digest-empty-sub { font-size: 14px; margin: 0; }
239
240 /* Spinner */
241 .digest-spinner-wrap {
242 text-align: center;
243 padding: 48px 24px;
244 }
245 .digest-spinner {
246 display: inline-block;
247 width: 32px;
248 height: 32px;
249 border: 3px solid var(--border, #e1e4e8);
250 border-top-color: var(--accent, #0070f3);
251 border-radius: 50%;
252 animation: digest-spin 0.8s linear infinite;
253 margin-bottom: 12px;
254 }
255 @keyframes digest-spin { to { transform: rotate(360deg); } }
256
257 /* Section header */
258 .digest-section-title {
259 font-size: 13px;
260 font-weight: 700;
261 letter-spacing: 0.06em;
262 text-transform: uppercase;
263 color: var(--text-muted, #777);
264 margin: 0 0 12px;
265 }
266`;
267
268// ---------------------------------------------------------------------------
269// Helpers
270// ---------------------------------------------------------------------------
271
272type Priority = DigestItem["priority"];
273type ItemType = DigestItem["type"];
274
275function priorityLabel(p: Priority): string {
276 if (p === "blocking") return "Blocking";
277 if (p === "important") return "Important";
278 return "FYI";
279}
280
281function itemIcon(t: ItemType): string {
282 if (t === "pr_review") return "\u{1F50D}";
283 if (t === "pr_comment") return "\u{1F4AC}";
284 if (t === "ci_failure") return "\u{274C}";
285 if (t === "mention") return "\u{1F514}";
286 if (t === "dep_update") return "\u{1F4E6}";
287 if (t === "new_issue") return "\u{1F41B}";
288 return "\u{2022}";
289}
290
291// ---------------------------------------------------------------------------
292// GET /digest
293// ---------------------------------------------------------------------------
294
295digest.get("/digest", softAuth, requireAuth, async (c) => {
296 const user = c.get("user")!;
297 const generating = c.req.query("generating") === "1";
298
299 // Look up the most recent digest notification
300 const [latestDigestNotif] = await db
301 .select()
302 .from(notifications)
303 .where(
304 and(
305 eq(notifications.userId, user.id),
306 eq(notifications.kind, "digest")
307 )
308 )
309 .orderBy(desc(notifications.createdAt))
310 .limit(1)
311 .catch(() => []);
312
313 let digestData: SmartDigest | null = null;
314 let isToday = false;
315
316 if (latestDigestNotif?.body) {
317 try {
318 digestData = JSON.parse(latestDigestNotif.body) as SmartDigest;
319 const digestDate = new Date(digestData.generatedAt);
320 const now = new Date();
321 isToday =
322 digestDate.getFullYear() === now.getFullYear() &&
323 digestDate.getMonth() === now.getMonth() &&
324 digestDate.getDate() === now.getDate();
325 } catch {
326 /* malformed JSON */
327 }
328 }
329
330 // Auto-generate if no digest today and not already generating
331 if (!isToday && !generating) {
332 // Fire-and-forget then redirect with ?generating=1 to show spinner
333 void (async () => {
334 try {
335 await sendSmartDigest(user.id);
336 } catch {
337 /* swallow */
338 }
339 })();
340 return c.redirect("/digest?generating=1");
341 }
342
343 // If generating, show spinner page that polls
344 if (generating && !isToday) {
345 return c.html(
346 <Layout title="Morning Digest" user={user}>
347 <style dangerouslySetInnerHTML={{ __html: DIGEST_STYLES }} />
348 <div style="max-width:720px;margin:0 auto;padding:24px 16px">
349 <h1 class="digest-headline" style="margin-bottom:24px">
350 Morning Digest
351 </h1>
352 <div class="digest-spinner-wrap">
353 <div class="digest-spinner" />
354 <p style="font-size:15px;color:var(--text-muted);margin:0">
355 Generating your digest...
356 </p>
357 </div>
358 <script
359 dangerouslySetInnerHTML={{
360 __html: `
361 setTimeout(function() {
362 window.location.href = '/digest';
363 }, 3000);
364 `,
365 }}
366 />
367 </div>
368 </Layout>
369 );
370 }
371
372 return c.html(
373 <Layout title="Morning Digest" user={user}>
374 <style dangerouslySetInnerHTML={{ __html: DIGEST_STYLES }} />
375 <div style="max-width:720px;margin:0 auto;padding:24px 16px">
376
377 {/* Hero */}
378 <div class="digest-hero">
379 <span class="digest-hero-icon" aria-hidden="true">{"☀"}</span>
380 <h1 class="digest-headline">
381 {digestData?.headline || "No digest available"}
382 </h1>
383 {digestData && (
384 <p class="digest-meta">
385 Generated {formatRelative(digestData.generatedAt)}
386 </p>
387 )}
388 <div class="digest-actions">
389 <form method="post" action="/digest/refresh" style="display:inline">
390 <button type="submit" class="digest-regenerate-btn">
391 {"↻"} Regenerate
392 </button>
393 </form>
394 <a href="/inbox" class="digest-regenerate-btn">
395 View all notifications
396 </a>
397 </div>
398 </div>
399
400 {/* AI Insight */}
401 {digestData?.insight && (
402 <div>
403 <p class="digest-section-title">AI Insight</p>
404 <div class="digest-insight">
405 <span class="digest-insight-icon" aria-hidden="true">{"✨"}</span>
406 <p class="digest-insight-text">{digestData.insight}</p>
407 </div>
408 </div>
409 )}
410
411 {/* Stats row */}
412 {digestData?.stats && (
413 <div>
414 <p class="digest-section-title">This week</p>
415 <div class="digest-stats" style="margin-bottom:24px">
416 <div class="digest-stat-card">
417 <div class="digest-stat-value">{digestData.stats.prsReviewed}</div>
418 <div class="digest-stat-label">PRs reviewed</div>
419 </div>
420 <div class="digest-stat-card">
421 <div class="digest-stat-value">{digestData.stats.issuesClosed}</div>
422 <div class="digest-stat-label">Issues closed</div>
423 </div>
424 <div class="digest-stat-card">
425 <div class="digest-stat-value">{digestData.stats.commitsThisWeek}</div>
426 <div class="digest-stat-label">Commits</div>
427 </div>
428 </div>
429 </div>
430 )}
431
432 {/* Queue */}
433 {digestData && digestData.queue.length > 0 ? (
434 <div>
435 <p class="digest-section-title">Action queue</p>
436 <div class="digest-queue">
437 {digestData.queue.map((item, idx) => (
438 <a
439 key={idx}
440 href={item.url}
441 class={`digest-item ${item.priority}`}
442 >
443 <span class="digest-item-icon" aria-hidden="true">
444 {itemIcon(item.type)}
445 </span>
446 <div class="digest-item-content">
447 <p class="digest-item-title">{item.title}</p>
448 <p class="digest-item-subtitle">{item.subtitle}</p>
449 </div>
450 <span class={`digest-item-priority ${item.priority}`}>
451 {priorityLabel(item.priority)}
452 </span>
453 <span class="digest-item-go" aria-hidden="true">{"→"}</span>
454 </a>
455 ))}
456 </div>
457 </div>
458 ) : digestData ? (
459 <div class="digest-empty">
460 <div class="digest-empty-icon" aria-hidden="true">{"🎉"}</div>
461 <p class="digest-empty-text">All caught up!</p>
462 <p class="digest-empty-sub">No action items for today.</p>
463 </div>
464 ) : (
465 <div class="digest-empty">
466 <div class="digest-empty-icon" aria-hidden="true">{"📋"}</div>
467 <p class="digest-empty-text">No digest yet</p>
468 <p class="digest-empty-sub">
469 Use the Regenerate button above to create your first digest.
470 </p>
471 </div>
472 )}
473
474 </div>
475 </Layout>
476 );
477});
478
479// ---------------------------------------------------------------------------
480// POST /digest/refresh
481// ---------------------------------------------------------------------------
482
483digest.post("/digest/refresh", softAuth, requireAuth, async (c) => {
484 const user = c.get("user")!;
485 // Force a fresh digest by clearing cooldown temporarily — just compose + insert
486 try {
487 const freshDigest = await composeSmartDigest(user.id);
488 if (freshDigest) {
489 await db.insert(notifications).values({
490 userId: user.id,
491 kind: "digest",
492 title: freshDigest.headline,
493 body: JSON.stringify(freshDigest),
494 url: "/digest",
495 });
496 // Update last sent timestamp
497 await db
498 .update(users)
499 .set({ lastSmartDigestSentAt: new Date() })
500 .where(eq(users.id, user.id));
501 }
502 } catch (err) {
503 console.error("[digest] refresh error:", err);
504 }
505 return c.redirect("/digest");
506});
507
508export default digest;