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