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

standups.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.

standups.tsxBlame523 lines · 1 contributor
56801e1Claude1/**
2 * AI Standup feed (`/standups`).
3 *
4 * Polished surface where users see their daily + weekly standups. Hero
5 * with gradient + orb + display headline + featured "Today's standup" card
6 * at the top, then a chronological feed of every recent brief.
7 *
8 * Owns its own scoped CSS (`.standup-*`) so it can never bleed into the
9 * locked layout / shared components.
10 */
11
12import { Hono } from "hono";
13import { eq } from "drizzle-orm";
14import { Layout } from "../views/layout";
15import { softAuth, requireAuth } from "../middleware/auth";
16import type { AuthEnv } from "../middleware/auth";
17import { db } from "../db";
18import { users } from "../db/schema";
19import { renderMarkdown } from "../lib/markdown";
20import {
21 deliverStandup,
22 generateStandup,
23 getStandupPrefs,
24 listRecentStandups,
25} from "../lib/ai-standup";
26
27const standups = new Hono<AuthEnv>();
28
29standups.use("*", softAuth);
30standups.use("/standups*", requireAuth);
31
32function formatStamp(d: Date): string {
33 try {
34 return new Date(d).toISOString().replace("T", " ").slice(0, 16) + " UTC";
35 } catch {
36 return "—";
37 }
38}
39
40function scopeLabel(scope: string): string {
41 return scope === "weekly" ? "Weekly" : "Daily";
42}
43
44standups.get("/standups", async (c) => {
45 const user = c.get("user")!;
46 const [recent, prefs] = await Promise.all([
47 listRecentStandups(user.id, 30),
48 getStandupPrefs(user.id),
49 ]);
50
51 // The featured card is the most recent standup, if any.
52 const featured = recent[0] || null;
53 const rest = featured ? recent.slice(1) : [];
54
55 const enabledDaily = prefs?.dailyEnabled === true;
56 const enabledWeekly = prefs?.weeklyEnabled === true;
57
58 return c.html(
59 <Layout title="Standups" user={user}>
60 <style dangerouslySetInnerHTML={{ __html: pageCss }} />
61 <div class="standup-wrap">
62 <section class="standup-hero">
63 <div class="standup-hero-orb" aria-hidden="true" />
64 <div class="standup-hero-inner">
65 <div class="standup-eyebrow">
66 <span class="standup-eyebrow-pill" aria-hidden="true" />
67 Standups · powered by Claude
68 </div>
69 <h1 class="standup-title">
70 Your morning routine.{" "}
71 <span class="standup-title-grad">Ship-status at a glance.</span>
72 </h1>
73 <p class="standup-sub">
74 Every day Claude reads your team&rsquo;s PRs, issues, and
75 deploys and writes a 200-word brief: what shipped, what&rsquo;s
76 in flight, what&rsquo;s at risk. Lands in your inbox at 09:00
77 UTC by default.
78 </p>
79 <div class="standup-hero-cta">
80 <a href="/settings#standups" class="standup-btn standup-btn-primary">
81 {enabledDaily || enabledWeekly ? "Manage delivery" : "Turn on standups"}
82 <span aria-hidden="true">→</span>
83 </a>
84 <form
85 method="post"
86 action="/standups/preview"
87 style="display:inline"
88 >
89 <button type="submit" class="standup-btn">
90 Generate one now
91 </button>
92 </form>
93 </div>
94 <div class="standup-hero-meta">
95 <span class={"standup-pill " + (enabledDaily ? "is-on" : "is-off")}>
96 <span class="standup-dot" aria-hidden="true" />
97 Daily {enabledDaily ? "on" : "off"}
98 </span>
99 <span class={"standup-pill " + (enabledWeekly ? "is-on" : "is-off")}>
100 <span class="standup-dot" aria-hidden="true" />
101 Weekly {enabledWeekly ? "on" : "off"}
102 </span>
103 </div>
104 </div>
105 </section>
106
107 {featured ? (
108 <section class="standup-featured" aria-labelledby="standup-featured-h">
109 <header class="standup-featured-head">
110 <div>
111 <p class="standup-featured-eyebrow">Today&rsquo;s standup</p>
112 <h2 class="standup-featured-title" id="standup-featured-h">
113 {scopeLabel(featured.scope)} brief
114 <span class="standup-featured-stamp">
115 {formatStamp(featured.createdAt)}
116 </span>
117 </h2>
118 </div>
119 <div class="standup-featured-stats">
120 <span class="standup-stat">
121 <strong>{featured.shippedItems.length}</strong> shipped
122 </span>
123 <span class="standup-stat">
124 <strong>{featured.blockedItems.length}</strong> in flight
125 </span>
126 <span class="standup-stat standup-stat-warn">
127 <strong>{featured.atRiskItems.length}</strong> at risk
128 </span>
129 </div>
130 </header>
131 <div
132 class="standup-featured-body"
133 dangerouslySetInnerHTML={{
134 __html: renderMarkdown(featured.summary),
135 }}
136 />
137 </section>
138 ) : (
139 <section class="standup-empty">
140 <h2>No standups yet.</h2>
141 <p>
142 Turn on daily or weekly standups in{" "}
143 <a href="/settings#standups">Settings</a>, or generate one now
144 with the button above. Your first brief will appear here.
145 </p>
146 </section>
147 )}
148
149 {rest.length > 0 ? (
150 <section class="standup-feed" aria-label="Past standups">
151 <h2 class="standup-feed-title">Past standups</h2>
152 <ol class="standup-feed-list">
153 {rest.map((s) => (
154 <li class="standup-card" id={s.id}>
155 <div class="standup-card-head">
156 <span class={"standup-tag standup-tag-" + s.scope}>
157 {scopeLabel(s.scope)}
158 </span>
159 <span class="standup-card-stamp">
160 {formatStamp(s.createdAt)}
161 </span>
162 {s.aiAvailable ? (
163 <span class="standup-card-ai">AI</span>
164 ) : (
165 <span class="standup-card-fallback">fallback</span>
166 )}
167 </div>
168 <div
169 class="standup-card-body"
170 dangerouslySetInnerHTML={{
171 __html: renderMarkdown(s.summary),
172 }}
173 />
174 </li>
175 ))}
176 </ol>
177 </section>
178 ) : null}
179 </div>
180 </Layout>
181 );
182});
183
184standups.post("/standups/preview", async (c) => {
185 const user = c.get("user")!;
186 // Bypass the dedupe check so the on-demand button always produces a row.
187 await deliverStandup({
188 userId: user.id,
189 scope: "daily",
190 bypassDedupe: true,
191 });
192 return c.redirect("/standups?success=" + encodeURIComponent("Standup generated"));
193});
194
195// Optional JSON endpoint — handy for /admin debugging and tests.
196standups.get("/api/standups/preview", async (c) => {
197 const user = c.get("user")!;
198 const scope = c.req.query("scope") === "weekly" ? "weekly" : "daily";
199 const result = await generateStandup({ userId: user.id, scope });
200 return c.json(result);
201});
202
203// Stamp the lastDailySentAt timestamp so manual previews don't reset the
204// scheduler too aggressively. We rely on getStandupPrefs above; no extra
205// writes here. (Reference users to silence unused-import lints.)
206void users;
207void eq;
208
209// ---------------------------------------------------------------------------
210// Scoped CSS — `.standup-*` only. New file → no risk to locked surfaces.
211// ---------------------------------------------------------------------------
212const pageCss = `
213.standup-wrap {
214 max-width: 980px;
215 margin: 0 auto;
216 padding: 32px 24px 80px;
217 display: flex;
218 flex-direction: column;
219 gap: 28px;
220 font-family: var(--font-body, Inter, system-ui, sans-serif);
221}
222.standup-hero {
223 position: relative;
224 isolation: isolate;
225 overflow: hidden;
226 border-radius: 22px;
227 padding: 56px 48px;
228 background: linear-gradient(135deg,
229 rgba(140, 109, 255, 0.18) 0%,
230 rgba(54, 197, 214, 0.14) 60%,
231 rgba(255, 198, 88, 0.10) 100%),
232 var(--bg-secondary, #14172a);
233 border: 1px solid var(--border, #2b2f44);
234 box-shadow: 0 12px 60px -24px rgba(140, 109, 255, 0.35);
235}
236.standup-hero-orb {
237 position: absolute;
238 top: -120px;
239 right: -120px;
240 width: 360px;
241 height: 360px;
242 border-radius: 9999px;
243 background: radial-gradient(circle at 30% 30%,
244 rgba(140, 109, 255, 0.45) 0%,
245 rgba(54, 197, 214, 0.18) 45%,
246 transparent 75%);
247 filter: blur(8px);
248 z-index: 0;
249}
250.standup-hero-inner {
251 position: relative;
252 z-index: 1;
253 max-width: 640px;
254}
255.standup-eyebrow {
256 display: inline-flex;
257 align-items: center;
258 gap: 8px;
259 padding: 6px 12px;
260 font-size: 12px;
261 font-weight: 600;
262 letter-spacing: 0.04em;
263 text-transform: uppercase;
264 color: var(--text-muted, #aab0c2);
265 background: rgba(255, 255, 255, 0.04);
266 border: 1px solid var(--border, #2b2f44);
267 border-radius: 9999px;
268}
269.standup-eyebrow-pill {
270 display: inline-block;
271 width: 8px;
272 height: 8px;
273 border-radius: 9999px;
274 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
275 box-shadow: 0 0 8px rgba(140, 109, 255, 0.6);
276}
277.standup-title {
278 font-family: var(--font-display, "Inter Tight", Inter, system-ui, sans-serif);
279 font-weight: 700;
280 font-size: clamp(34px, 5.2vw, 52px);
281 line-height: 1.05;
282 letter-spacing: -0.028em;
283 margin: 22px 0 12px;
284 color: var(--text-strong, #fff);
285}
286.standup-title-grad {
287 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
288 -webkit-background-clip: text;
289 background-clip: text;
290 color: transparent;
291}
292.standup-sub {
293 color: var(--text-muted, #aab0c2);
294 font-size: 16px;
295 line-height: 1.55;
296 margin: 0 0 22px;
297}
298.standup-hero-cta {
299 display: flex;
300 flex-wrap: wrap;
301 gap: 10px;
302 margin-top: 6px;
303}
304.standup-btn {
305 display: inline-flex;
306 align-items: center;
307 gap: 6px;
308 padding: 10px 18px;
309 font-size: 14px;
310 font-weight: 600;
311 border-radius: 10px;
312 border: 1px solid var(--border, #2b2f44);
313 background: rgba(255, 255, 255, 0.03);
314 color: var(--text-strong, #fff);
315 text-decoration: none;
316 cursor: pointer;
317 transition: transform .12s ease, background .12s ease;
318}
319.standup-btn:hover { transform: translateY(-1px); background: rgba(255,255,255,0.07); }
320.standup-btn-primary {
321 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
322 border-color: transparent;
323 color: #0d1117;
324}
325.standup-hero-meta {
326 display: flex;
327 gap: 10px;
328 margin-top: 18px;
329}
330.standup-pill {
331 display: inline-flex;
332 align-items: center;
333 gap: 8px;
334 padding: 5px 12px;
335 font-size: 12px;
336 font-weight: 600;
337 border-radius: 9999px;
338 border: 1px solid var(--border, #2b2f44);
339 background: rgba(255, 255, 255, 0.03);
340 color: var(--text-muted, #aab0c2);
341}
342.standup-pill.is-on { color: #8c6dff; border-color: rgba(140, 109, 255, 0.4); }
343.standup-pill.is-off { opacity: .8; }
344.standup-dot { width: 7px; height: 7px; border-radius: 9999px; background: currentColor; }
345.standup-featured {
346 padding: 26px 28px;
347 border-radius: 18px;
348 background: var(--bg-secondary, #14172a);
349 border: 1px solid var(--border, #2b2f44);
350 position: relative;
351}
352.standup-featured::before {
353 content: "";
354 position: absolute;
355 top: 0; left: 0;
356 height: 3px;
357 width: 100%;
358 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 50%, transparent 100%);
359 border-top-left-radius: 18px;
360 border-top-right-radius: 18px;
361}
362.standup-featured-head {
363 display: flex;
364 align-items: flex-start;
365 justify-content: space-between;
366 gap: 16px;
367 margin-bottom: 16px;
368}
369.standup-featured-eyebrow {
370 font-size: 12px;
371 font-weight: 600;
372 letter-spacing: 0.05em;
373 text-transform: uppercase;
374 color: var(--text-muted, #aab0c2);
375 margin: 0 0 2px;
376}
377.standup-featured-title {
378 font-family: var(--font-display, "Inter Tight", Inter, system-ui, sans-serif);
379 font-size: 22px;
380 font-weight: 700;
381 margin: 0;
382 color: var(--text-strong, #fff);
383 letter-spacing: -0.01em;
384}
385.standup-featured-stamp {
386 font-size: 13px;
387 font-weight: 500;
388 margin-left: 10px;
389 color: var(--text-muted, #aab0c2);
390}
391.standup-featured-stats {
392 display: flex;
393 gap: 16px;
394 font-size: 13px;
395 color: var(--text-muted, #aab0c2);
396}
397.standup-stat strong {
398 color: var(--text-strong, #fff);
399 font-weight: 700;
400 margin-right: 4px;
401}
402.standup-stat-warn strong { color: #ffc658; }
403.standup-featured-body {
404 color: var(--text, #d6dbe7);
405 font-size: 15px;
406 line-height: 1.65;
407}
408.standup-featured-body h1,
409.standup-featured-body h2,
410.standup-featured-body h3 {
411 font-family: var(--font-display, "Inter Tight", Inter, system-ui, sans-serif);
412 font-weight: 700;
413 margin-top: 18px;
414 margin-bottom: 6px;
415 color: var(--text-strong, #fff);
416 letter-spacing: -0.01em;
417}
418.standup-featured-body h1 { font-size: 22px; }
419.standup-featured-body h2 { font-size: 18px; }
420.standup-featured-body h3 { font-size: 16px; }
421.standup-featured-body ul,
422.standup-featured-body ol { padding-left: 20px; }
423.standup-featured-body li { margin: 4px 0; }
424.standup-featured-body code {
425 background: rgba(255, 255, 255, 0.06);
426 padding: 1px 6px;
427 border-radius: 4px;
428 font-family: var(--font-mono, "JetBrains Mono", monospace);
429 font-size: 13px;
430}
431.standup-empty {
432 padding: 36px;
433 text-align: center;
434 border-radius: 18px;
435 background: var(--bg-secondary, #14172a);
436 border: 1px dashed var(--border, #2b2f44);
437 color: var(--text-muted, #aab0c2);
438}
439.standup-empty h2 {
440 font-family: var(--font-display, "Inter Tight", Inter, system-ui, sans-serif);
441 font-size: 20px;
442 margin: 0 0 8px;
443 color: var(--text-strong, #fff);
444}
445.standup-feed-title {
446 font-family: var(--font-display, "Inter Tight", Inter, system-ui, sans-serif);
447 font-size: 16px;
448 font-weight: 700;
449 letter-spacing: 0.04em;
450 text-transform: uppercase;
451 color: var(--text-muted, #aab0c2);
452 margin: 0 0 14px;
453}
454.standup-feed-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
455.standup-card {
456 padding: 20px 22px;
457 border-radius: 14px;
458 background: var(--bg-secondary, #14172a);
459 border: 1px solid var(--border, #2b2f44);
460}
461.standup-card-head {
462 display: flex;
463 align-items: center;
464 gap: 10px;
465 font-size: 12px;
466 margin-bottom: 10px;
467 color: var(--text-muted, #aab0c2);
468}
469.standup-tag {
470 display: inline-flex;
471 padding: 3px 9px;
472 border-radius: 9999px;
473 font-weight: 600;
474 font-size: 11px;
475 letter-spacing: 0.03em;
476 text-transform: uppercase;
477}
478.standup-tag-daily { background: rgba(140, 109, 255, 0.15); color: #b9a4ff; }
479.standup-tag-weekly { background: rgba(54, 197, 214, 0.15); color: #6fd8e6; }
480.standup-card-stamp { font-variant-numeric: tabular-nums; }
481.standup-card-ai {
482 padding: 2px 8px;
483 border-radius: 9999px;
484 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
485 color: #0d1117;
486 font-weight: 700;
487 font-size: 10.5px;
488 letter-spacing: 0.06em;
489}
490.standup-card-fallback {
491 padding: 2px 8px;
492 border-radius: 9999px;
493 background: rgba(255, 255, 255, 0.06);
494 color: var(--text-muted, #aab0c2);
495 font-weight: 600;
496 font-size: 10.5px;
497 letter-spacing: 0.06em;
498}
499.standup-card-body {
500 color: var(--text, #d6dbe7);
501 font-size: 14.5px;
502 line-height: 1.6;
503}
504.standup-card-body h1,
505.standup-card-body h2,
506.standup-card-body h3 {
507 font-size: 15px;
508 font-weight: 700;
509 margin: 12px 0 4px;
510 color: var(--text-strong, #fff);
511}
512.standup-card-body ul,
513.standup-card-body ol { padding-left: 20px; }
514.standup-card-body li { margin: 2px 0; }
515@media (max-width: 700px) {
516 .standup-hero { padding: 40px 24px; }
517 .standup-title { font-size: 32px; }
518 .standup-featured { padding: 20px; }
519 .standup-featured-head { flex-direction: column; }
520}
521`;
522
523export default standups;