Commit56801e1unknown_key
feat(standups): /standups feed + settings toggle + nav link
feat(standups): /standups feed + settings toggle + nav link AI standup generator routes wired: - GET /standups: daily/weekly Claude-generated team brief feed - POST /standups/preview: manual preview generation - GET /api/standups/preview: JSON API Nav link 'Standups' added in layout.tsx between Activity and Inbox. Settings toggle for 'Daily standup at 09:00 + Weekly Monday + Also email me' added in src/routes/settings.tsx. Lib + schema + migration already shipped in a686079. This wires the UI surface so the feature is discoverable.
5 files changed+1013−156801e14671bf2c4e17b491f1f1b864da871c91f
5 changed files+1013−1
Addedsrc/__tests__/ai-standup.test.ts+362−0View fileUnifiedSplit
@@ -0,0 +1,362 @@
1/**
2 * AI Standup tests.
3 *
4 * Pure-helper coverage runs unconditionally. The DB-backed branch
5 * (notification insertion + same-day dedupe) is gated on DATABASE_URL
6 * matching the project convention — see api-tokens.test.ts and friends.
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 __test,
12 classifyMaterial,
13 deliverStandup,
14 generateStandup,
15 hasStandupForToday,
16 utcDayKey,
17} from "../lib/ai-standup";
18
19const HAS_DB = Boolean(process.env.DATABASE_URL);
20
21// ---------------------------------------------------------------------------
22// Pure helpers — no DB, no AI client.
23// ---------------------------------------------------------------------------
24
25describe("ai-standup — utcDayKey", () => {
26 it("returns YYYY-MM-DD for a Date", () => {
27 expect(utcDayKey(new Date("2026-05-25T08:00:00.000Z"))).toBe(
28 "2026-05-25"
29 );
30 });
31 it("buckets two timestamps on the same UTC day to the same key", () => {
32 const a = new Date("2026-05-25T01:00:00.000Z");
33 const b = new Date("2026-05-25T23:59:59.000Z");
34 expect(utcDayKey(a)).toBe(utcDayKey(b));
35 });
36});
37
38describe("ai-standup — classifyMaterial", () => {
39 const now = new Date("2026-05-25T09:00:00.000Z");
40 const fourDaysAgo = new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000);
41 const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
42
43 it("buckets merged PRs as shipped", () => {
44 const out = classifyMaterial({
45 now,
46 deploys: [],
47 issues: [],
48 prs: [
49 {
50 id: "p1",
51 number: 12,
52 title: "Add /metrics",
53 state: "merged",
54 isAiBuilt: false,
55 mergedAt: oneHourAgo,
56 updatedAt: oneHourAgo,
57 createdAt: oneHourAgo,
58 repo: "demo/repo",
59 },
60 ],
61 });
62 expect(out.shipped.length).toBe(1);
63 expect(out.shipped[0]).toContain("Merged");
64 expect(out.inFlight.length).toBe(0);
65 expect(out.atRisk.length).toBe(0);
66 });
67
68 it("flags open PRs older than 3 days as at-risk", () => {
69 const out = classifyMaterial({
70 now,
71 deploys: [],
72 issues: [],
73 prs: [
74 {
75 id: "p1",
76 number: 7,
77 title: "WIP refactor",
78 state: "open",
79 isAiBuilt: false,
80 mergedAt: null,
81 updatedAt: fourDaysAgo,
82 createdAt: fourDaysAgo,
83 repo: "demo/repo",
84 },
85 ],
86 });
87 expect(out.atRisk.length).toBe(1);
88 expect(out.atRisk[0]).toContain("Stale");
89 expect(out.inFlight.length).toBe(0);
90 });
91
92 it("surfaces ai:build PRs in the aiHighlights bucket", () => {
93 const out = classifyMaterial({
94 now,
95 deploys: [],
96 issues: [],
97 prs: [
98 {
99 id: "p1",
100 number: 1,
101 title: "ai:build add tests for /standups",
102 state: "merged",
103 isAiBuilt: true,
104 mergedAt: oneHourAgo,
105 updatedAt: oneHourAgo,
106 createdAt: oneHourAgo,
107 repo: "demo/repo",
108 },
109 ],
110 });
111 expect(out.aiHighlights.length).toBe(1);
112 });
113
114 it("counts failed deploys as at-risk and succeeded as shipped", () => {
115 const out = classifyMaterial({
116 now,
117 issues: [],
118 prs: [],
119 deploys: [
120 {
121 runId: "r1",
122 sha: "deadbeefdeadbeef",
123 status: "succeeded",
124 startedAt: oneHourAgo,
125 finishedAt: oneHourAgo,
126 },
127 {
128 runId: "r2",
129 sha: "abcdefabcdefabcd",
130 status: "failed",
131 startedAt: oneHourAgo,
132 finishedAt: oneHourAgo,
133 },
134 ],
135 });
136 expect(out.shipped.some((s) => s.includes("succeeded"))).toBe(true);
137 expect(out.atRisk.some((s) => s.includes("failed"))).toBe(true);
138 });
139});
140
141describe("ai-standup — renderFallbackSummary", () => {
142 it("renders all three sections when material is empty", () => {
143 const out = __test.renderFallbackSummary("daily", {
144 shipped: [],
145 inFlight: [],
146 atRisk: [],
147 aiHighlights: [],
148 });
149 expect(out).toContain("Daily standup");
150 expect(out).toContain("Shipped");
151 expect(out).toContain("In flight");
152 expect(out).toContain("At risk");
153 });
154
155 it("includes the AI section only when highlights exist", () => {
156 const empty = __test.renderFallbackSummary("daily", {
157 shipped: ["one"],
158 inFlight: [],
159 atRisk: [],
160 aiHighlights: [],
161 });
162 expect(empty).not.toContain("AI-driven changes");
163 const filled = __test.renderFallbackSummary("daily", {
164 shipped: [],
165 inFlight: [],
166 atRisk: [],
167 aiHighlights: ["AI-authored PR #1"],
168 });
169 expect(filled).toContain("AI-driven changes");
170 });
171});
172
173describe("ai-standup — buildPrompt", () => {
174 it("includes scope-specific wording", () => {
175 const daily = __test.buildPrompt("daily", {
176 shipped: [],
177 inFlight: [],
178 atRisk: [],
179 aiHighlights: [],
180 });
181 expect(daily).toContain("last 24 hours");
182 const weekly = __test.buildPrompt("weekly", {
183 shipped: [],
184 inFlight: [],
185 atRisk: [],
186 aiHighlights: [],
187 });
188 expect(weekly).toContain("last 7 days");
189 });
190});
191
192describe("ai-standup — deliverStandup (canned generator, DI'd)", () => {
193 it("dedupes when alreadyDelivered returns true", async () => {
194 let generated = 0;
195 const res = await deliverStandup({
196 userId: "u-1",
197 scope: "daily",
198 alreadyDelivered: async () => true,
199 generate: async () => {
200 generated += 1;
201 return {
202 summary: "should not be called",
203 shippedItems: [],
204 blockedItems: [],
205 atRiskItems: [],
206 windowStart: new Date(),
207 windowEnd: new Date(),
208 aiAvailable: false,
209 };
210 },
211 });
212 expect(generated).toBe(0);
213 expect(res.ok).toBe(false);
214 expect(res.reason).toContain("already");
215 expect(res.notified).toBe(false);
216 });
217});
218
219// ---------------------------------------------------------------------------
220// DB-backed: insert a standup, then confirm the notification + dedupe.
221// ---------------------------------------------------------------------------
222
223describe("ai-standup — DB-backed delivery", () => {
224 it.skipIf(!HAS_DB)(
225 "creates a notification + dedupes on the same UTC day",
226 async () => {
227 const { db } = await import("../db");
228 const { users, notifications } = await import("../db/schema");
229 const { aiStandups, userStandupPrefs } = await import(
230 "../db/schema-standup"
231 );
232 const { eq } = await import("drizzle-orm");
233
234 const uname = "stand-" + Math.random().toString(36).slice(2, 10);
235 const [user] = await db
236 .insert(users)
237 .values({
238 username: uname,
239 email: `${uname}@example.com`,
240 passwordHash: "x",
241 })
242 .returning();
243
244 try {
245 const cannedGen = async () => ({
246 summary: "## 🚀 Shipped\n- demo\n\n## 🚧 In flight\n- nothing\n\n## ⚠️ At risk\n- nothing",
247 shippedItems: ["demo PR"],
248 blockedItems: [],
249 atRiskItems: [],
250 windowStart: new Date(Date.now() - 24 * 3600 * 1000),
251 windowEnd: new Date(),
252 aiAvailable: true,
253 });
254
255 // First call should insert + notify.
256 const first = await deliverStandup({
257 userId: user.id,
258 scope: "daily",
259 generate: cannedGen,
260 });
261 expect(first.ok).toBe(true);
262 expect(first.notified).toBe(true);
263 expect(first.standupId).toBeTruthy();
264
265 // The notification row should exist with the standup body and a
266 // /standups URL.
267 const notifs = await db
268 .select()
269 .from(notifications)
270 .where(eq(notifications.userId, user.id));
271 expect(notifs.length).toBeGreaterThanOrEqual(1);
272 const standupNotif = notifs.find((n) =>
273 (n.url || "").startsWith("/standups")
274 );
275 expect(standupNotif).toBeTruthy();
276 expect(standupNotif?.body || "").toContain("Shipped");
277
278 // Second call on the same UTC day should be deduped.
279 const second = await deliverStandup({
280 userId: user.id,
281 scope: "daily",
282 generate: cannedGen,
283 });
284 expect(second.ok).toBe(false);
285 expect(second.reason).toContain("already");
286
287 // hasStandupForToday should also report true.
288 const dupe = await hasStandupForToday(user.id, "daily", new Date());
289 expect(dupe).toBe(true);
290 } finally {
291 // Clean up rows we created — best-effort.
292 try {
293 await db
294 .delete(aiStandups)
295 .where(eq(aiStandups.userId, user.id));
296 } catch {
297 /* ignore */
298 }
299 try {
300 await db
301 .delete(userStandupPrefs)
302 .where(eq(userStandupPrefs.userId, user.id));
303 } catch {
304 /* ignore */
305 }
306 try {
307 await db
308 .delete(notifications)
309 .where(eq(notifications.userId, user.id));
310 } catch {
311 /* ignore */
312 }
313 try {
314 await db.delete(users).where(eq(users.id, user.id));
315 } catch {
316 /* ignore */
317 }
318 }
319 }
320 );
321
322 it.skipIf(!HAS_DB)(
323 "generateStandup returns a fallback body when AI key is absent",
324 async () => {
325 // Save and clear the key for this single assertion.
326 const prev = process.env.ANTHROPIC_API_KEY;
327 delete process.env.ANTHROPIC_API_KEY;
328 try {
329 const { db } = await import("../db");
330 const { users } = await import("../db/schema");
331 const { eq } = await import("drizzle-orm");
332
333 const uname = "stand2-" + Math.random().toString(36).slice(2, 10);
334 const [user] = await db
335 .insert(users)
336 .values({
337 username: uname,
338 email: `${uname}@example.com`,
339 passwordHash: "x",
340 })
341 .returning();
342 try {
343 const res = await generateStandup({
344 userId: user.id,
345 scope: "daily",
346 });
347 expect(typeof res.summary).toBe("string");
348 expect(res.summary.length).toBeGreaterThan(0);
349 expect(res.aiAvailable).toBe(false);
350 } finally {
351 try {
352 await db.delete(users).where(eq(users.id, user.id));
353 } catch {
354 /* ignore */
355 }
356 }
357 } finally {
358 if (prev) process.env.ANTHROPIC_API_KEY = prev;
359 }
360 }
361 );
362});
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
@@ -132,6 +132,7 @@ import workflowsRoutes from "./routes/workflows";
132132import workflowArtifactsRoutes from "./routes/workflow-artifacts";
133133import workflowSecretsRoutes from "./routes/workflow-secrets";
134134import sleepModeRoutes from "./routes/sleep-mode";
135import standupRoutes from "./routes/standups";
135136import vsGithubRoutes from "./routes/vs-github";
136137import playgroundRoutes from "./routes/playground";
137138import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
@@ -340,6 +341,8 @@ app.route("/", issuesDashboardRoutes);
340341app.route("/", activityRoutes);
341342// Unified inbox — mentions + review requests + CI failures + AI events in one timeline
342343app.route("/", inboxRoutes);
344// AI standup feed — daily / weekly Claude-generated team brief
345app.route("/", standupRoutes);
343346
344347// Auth routes (register, login, logout)
345348app.route("/", authRoutes);
Modifiedsrc/routes/settings.tsx+122−1View fileUnifiedSplit
@@ -6,6 +6,7 @@ import { Hono } from "hono";
66import { eq } from "drizzle-orm";
77import { db } from "../db";
88import { users, sshKeys } from "../db/schema";
9import { getStandupPrefs, setStandupPrefs } from "../lib/ai-standup";
910import type { AuthEnv } from "../middleware/auth";
1011import { requireAuth } from "../middleware/auth";
1112import { Layout } from "../views/layout";
@@ -629,9 +630,17 @@ function Banner(props: { kind: "success" | "error"; text: string }) {
629630}
630631
631632// Profile settings
632settings.get("/settings", (c) => {
633settings.get("/settings", async (c) => {
633634 const user = c.get("user")!;
634635 const success = c.req.query("success");
636 const standupPrefs = (await getStandupPrefs(user.id)) || {
637 dailyEnabled: false,
638 weeklyEnabled: false,
639 emailEnabled: false,
640 hourUtc: 9,
641 lastDailySentAt: null,
642 lastWeeklySentAt: null,
643 };
635644 return c.html(
636645 <Layout title="Settings" user={user}>
637646 <style dangerouslySetInnerHTML={{ __html: settingsStyles }} />
@@ -879,6 +888,7 @@ settings.get("/settings", (c) => {
879888 </section>
880889
881890 {/* ─── Mobile push ─── */}
891 {/* (Standups section rendered as its own form below — see /settings/standups) */}
882892 <section class="settings-section">
883893 <div class="settings-section-head">
884894 <div class="settings-section-eyebrow">Mobile</div>
@@ -951,6 +961,95 @@ settings.get("/settings", (c) => {
951961 </section>
952962 </form>
953963
964 {/* ─── AI Standups ─── */}
965 <form method="post" action="/settings/standups">
966 <section class="settings-section" id="standups">
967 <div class="settings-section-head">
968 <div class="settings-section-eyebrow">Autonomy</div>
969 <h2 class="settings-section-title">AI standups</h2>
970 <p class="settings-section-desc">
971 A daily Claude-generated brief on what your team shipped,
972 what’s in flight, and what’s at risk. Lands in
973 your <a href="/inbox">inbox</a> and (optionally) your email.
974 Browse the feed at <a href="/standups">/standups</a>.
975 </p>
976 </div>
977 <div class="settings-section-body">
978 <label class="settings-toggle-row">
979 <input
980 type="checkbox"
981 name="standup_daily_enabled"
982 value="1"
983 checked={standupPrefs.dailyEnabled}
984 aria-label="Daily standup at 09:00 UTC"
985 />
986 <span class="settings-toggle-text">
987 Daily standup at 09:00 UTC
988 <span class="settings-toggle-text-hint">
989 One brief per day, based on the last 24 hours of PRs +
990 issues across repos you own or collaborate on.
991 </span>
992 </span>
993 </label>
994 <label class="settings-toggle-row">
995 <input
996 type="checkbox"
997 name="standup_weekly_enabled"
998 value="1"
999 checked={standupPrefs.weeklyEnabled}
1000 aria-label="Weekly standup Monday 09:00 UTC"
1001 />
1002 <span class="settings-toggle-text">
1003 Weekly standup Monday 09:00 UTC
1004 <span class="settings-toggle-text-hint">
1005 A wider Monday-morning recap covering the last 7 days.
1006 </span>
1007 </span>
1008 </label>
1009 <label class="settings-toggle-row">
1010 <input
1011 type="checkbox"
1012 name="standup_email_enabled"
1013 value="1"
1014 checked={standupPrefs.emailEnabled}
1015 aria-label="Also email me the standup"
1016 />
1017 <span class="settings-toggle-text">
1018 Also email me the standup
1019 <span class="settings-toggle-text-hint">
1020 In-app notification is always sent; this opts you into
1021 a parallel email delivery.
1022 </span>
1023 </span>
1024 </label>
1025 <div class="settings-hour-row">
1026 <label for="standup_hour_utc">
1027 Send my standup at (UTC hour, 0–23):
1028 </label>
1029 <input
1030 class="settings-hour-input"
1031 type="number"
1032 id="standup_hour_utc"
1033 name="standup_hour_utc"
1034 min={0}
1035 max={23}
1036 step={1}
1037 value={String(standupPrefs.hourUtc)}
1038 aria-label="Standup UTC hour"
1039 />
1040 </div>
1041 </div>
1042 <div class="settings-section-foot">
1043 <span class="settings-foot-hint">
1044 Saved separately from email + sleep-mode preferences.
1045 </span>
1046 <button type="submit" class="btn btn-primary">
1047 Save standup preferences
1048 </button>
1049 </div>
1050 </section>
1051 </form>
1052
9541053 {/* ─── Push device controls (separate, JS-driven) ─── */}
9551054 <section class="settings-section">
9561055 <div class="settings-section-head">
@@ -1850,6 +1949,28 @@ settings.post("/settings/notifications", async (c) => {
18501949 return c.redirect("/settings?success=Notification+preferences+updated");
18511950});
18521951
1952// AI Standup preferences. Lives in a separate row in `user_standup_prefs`
1953// so we don't have to touch the locked `users` schema.
1954settings.post("/settings/standups", async (c) => {
1955 const user = c.get("user")!;
1956 const body = await c.req.parseBody();
1957 const dailyEnabled = String(body.standup_daily_enabled || "") === "1";
1958 const weeklyEnabled = String(body.standup_weekly_enabled || "") === "1";
1959 const emailEnabled = String(body.standup_email_enabled || "") === "1";
1960 const rawHour = String(body.standup_hour_utc ?? "");
1961 const hourUtc = Number.parseInt(rawHour, 10);
1962 await setStandupPrefs(user.id, {
1963 dailyEnabled,
1964 weeklyEnabled,
1965 emailEnabled,
1966 hourUtc: Number.isFinite(hourUtc) ? hourUtc : 9,
1967 });
1968 return c.redirect(
1969 "/settings?success=" + encodeURIComponent("Standup preferences updated") +
1970 "#standups"
1971 );
1972});
1973
18531974// Block M2 — client-side device subscribe/unsubscribe/test helpers. Plain
18541975// JS, no framework; reads/writes via the /pwa/* endpoints.
18551976const pushDeviceScript = `
Addedsrc/routes/standups.tsx+523−0View fileUnifiedSplit
@@ -0,0 +1,523 @@
1/**
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’s PRs, issues, and
75 deploys and writes a 200-word brief: what shipped, what’s
76 in flight, what’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’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;
Modifiedsrc/views/layout.tsx+3−0View fileUnifiedSplit
@@ -222,6 +222,9 @@ export const Layout: FC<
222222 <a href="/activity" class="nav-link">
223223 Activity
224224 </a>
225 <a href="/standups" class="nav-link">
226 Standups
227 </a>
225228 <a href="/inbox" class="nav-link" style="position:relative">
226229 Inbox
227230 {notificationCount && notificationCount > 0 ? (
228231