Commite1fc7dbunknown_key
Add last_sleep_digest_sent_at column split + changelog page
Add last_sleep_digest_sent_at column split + changelog page - drizzle/0077: adds last_sleep_digest_sent_at column to users table so sleep-mode digest and weekly digest maintain independent cooldown timers - schema.ts: exposes lastSleepDigestSentAt field - sleep-mode.ts: writes lastSleepDigestSentAt instead of lastDigestSentAt - autopilot.ts: SleepModeDigestCandidate uses lastSleepDigestSentAt; defaultFindSleepModeCandidates selects and maps the new column - sleep-mode.test.ts: updates test fixtures to use new field name - routes/changelog.tsx: new GET /changelog page with curated June/May 2026 release entries and a Subscribe to updates CTA - app.tsx: mounts changelogRoutes before marketingRoutes - layout.tsx: adds Changelog link to footer Product column https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
8 files changed+293−16e1fc7dbe2705a6cc90c17526cf16b015ef8a2ecc
8 changed files+293−16
Addeddrizzle/0077_sleep_digest_column.sql+5−0View fileUnifiedSplit
@@ -0,0 +1,5 @@
1-- Migration 0077 — L1 Sleep-mode digest column split.
2-- Splits the shared `last_digest_sent_at` anchor into two independent
3-- columns so the sleep-mode daily digest and the weekly digest maintain
4-- their own cooldown timers and no longer reset each other.
5ALTER TABLE users ADD COLUMN IF NOT EXISTS last_sleep_digest_sent_at timestamptz;
Modifiedsrc/__tests__/sleep-mode.test.ts+4−4View fileUnifiedSplit
@@ -234,7 +234,7 @@ describe("sleep-mode — autopilot task (runSleepModeDigestTaskOnce)", () => {
234234 return {
235235 userId: "u-1",
236236 digestHourUtc: 9,
237 lastDigestSentAt: null,
237 lastSleepDigestSentAt: null,
238238 ...overrides,
239239 };
240240 }
@@ -279,9 +279,9 @@ describe("sleep-mode — autopilot task (runSleepModeDigestTaskOnce)", () => {
279279 const old = new Date(sentinelNow.getTime() - 24 * 60 * 60 * 1000);
280280 const summary = await runSleepModeDigestTaskOnce({
281281 findCandidates: async () => [
282 cand({ userId: "recent-user", lastDigestSentAt: recent }),
283 cand({ userId: "old-user", lastDigestSentAt: old }),
284 cand({ userId: "never-user", lastDigestSentAt: null }),
282 cand({ userId: "recent-user", lastSleepDigestSentAt: recent }),
283 cand({ userId: "old-user", lastSleepDigestSentAt: old }),
284 cand({ userId: "never-user", lastSleepDigestSentAt: null }),
285285 ],
286286 sendOne: async (id) => {
287287 sent.push(id);
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -50,6 +50,7 @@ import healthDashboardRoutes from "./routes/health";
5050import statusRoutes from "./routes/status";
5151import adminStatusRoutes from "./routes/admin-status";
5252import helpRoutes from "./routes/help";
53import changelogRoutes from "./routes/changelog";
5354import marketingRoutes from "./routes/marketing";
5455import pricingRoutes from "./routes/pricing";
5556import seoRoutes from "./routes/seo";
@@ -524,6 +525,9 @@ app.route("/", helpRoutes);
524525// pricing remains as a safety net but is shadowed at the router.
525526app.route("/", pricingRoutes);
526527
528// /changelog — manually curated platform release history
529app.route("/", changelogRoutes);
530
527531// /pricing, /features, /about — marketing surface
528532app.route("/", marketingRoutes);
529533
Modifiedsrc/db/schema.ts+5−3View fileUnifiedSplit
@@ -79,9 +79,11 @@ export const users = pgTable("users", {
7979 lastDigestSentAt: timestamp("last_digest_sent_at"),
8080 // Block L1 — Sleep Mode. When enabled, the autopilot sleep-mode-digest
8181 // task delivers a daily "what Claude shipped overnight" report at the
82 // user-configured UTC hour (0-23, default 9). Reuses lastDigestSentAt
83 // as the 23h cooldown anchor — the cooldown is shared with the weekly
84 // digest, so a user cannot receive both on the same day.
82 // user-configured UTC hour (0-23, default 9). Uses its own independent
83 // cooldown anchor (lastSleepDigestSentAt) so the weekly digest timer is
84 // not reset when a sleep-mode digest fires, and vice versa.
85 // Migration 0077 adds the column.
86 lastSleepDigestSentAt: timestamp("last_sleep_digest_sent_at"),
8587 sleepModeEnabled: boolean("sleep_mode_enabled").default(false).notNull(),
8688 sleepModeDigestHourUtc: integer("sleep_mode_digest_hour_utc").default(9).notNull(),
8789 // Block M2 — Web Push per-event preferences. Default on; opt-out via /settings.
Modifiedsrc/lib/autopilot.ts+10−7View fileUnifiedSplit
@@ -768,7 +768,8 @@ export async function runSyntheticMonitorTaskOnce(
768768export interface SleepModeDigestCandidate {
769769 userId: string;
770770 digestHourUtc: number;
771 lastDigestSentAt: Date | null;
771 /** Independent cooldown anchor for the sleep-mode digest (migration 0077). */
772 lastSleepDigestSentAt: Date | null;
772773}
773774
774775export interface SleepModeDigestTaskDeps {
@@ -791,9 +792,11 @@ export interface SleepModeDigestTaskSummary {
791792
792793/**
793794 * Default candidate-finder. Returns enabled users whose
794 * `lastDigestSentAt` is older than the cooldown OR null. The hour-match
795 * `lastSleepDigestSentAt` is older than the cooldown OR null. The hour-match
795796 * filter is applied in JS by `runSleepModeDigestTaskOnce` so it stays
796797 * timezone-independent of any SQL `extract(hour ...)` behaviour.
798 * Uses the dedicated `last_sleep_digest_sent_at` column (migration 0077) so
799 * the cooldown is independent of the weekly digest timer.
797800 */
798801async function defaultFindSleepModeCandidates(
799802 cap: number
@@ -803,7 +806,7 @@ async function defaultFindSleepModeCandidates(
803806 .select({
804807 userId: users.id,
805808 digestHourUtc: users.sleepModeDigestHourUtc,
806 lastDigestSentAt: users.lastDigestSentAt,
809 lastSleepDigestSentAt: users.lastSleepDigestSentAt,
807810 })
808811 .from(users)
809812 .where(eq(users.sleepModeEnabled, true))
@@ -811,7 +814,7 @@ async function defaultFindSleepModeCandidates(
811814 return rows.map((r) => ({
812815 userId: r.userId,
813816 digestHourUtc: r.digestHourUtc,
814 lastDigestSentAt: r.lastDigestSentAt,
817 lastSleepDigestSentAt: r.lastSleepDigestSentAt,
815818 }));
816819 } catch (err) {
817820 console.error("[autopilot] sleep-mode-digest: candidate query failed:", err);
@@ -823,7 +826,7 @@ async function defaultFindSleepModeCandidates(
823826 * One iteration of the sleep-mode-digest task. Never throws.
824827 *
825828 * Per-user filters (applied in JS so we can DI a clock):
826 * 1. `lastDigestSentAt` is null OR older than cooldown (23h).
829 * 1. `lastSleepDigestSentAt` is null OR older than cooldown (23h).
827830 * 2. `now.getUTCHours() === digestHourUtc` — fires once at the user's
828831 * configured local UTC hour.
829832 *
@@ -863,8 +866,8 @@ export async function runSleepModeDigestTaskOnce(
863866 }
864867 // Cooldown: skip if we sent within the last cooldown window.
865868 if (
866 cand.lastDigestSentAt &&
867 nowDate.getTime() - new Date(cand.lastDigestSentAt).getTime() <
869 cand.lastSleepDigestSentAt &&
870 nowDate.getTime() - new Date(cand.lastSleepDigestSentAt).getTime() <
868871 cooldownMs
869872 ) {
870873 skipped += 1;
Modifiedsrc/lib/sleep-mode.ts+2−2View fileUnifiedSplit
@@ -475,10 +475,10 @@ export async function sendSleepModeDigestForUser(
475475 try {
476476 await db
477477 .update(users)
478 .set({ lastDigestSentAt: new Date() })
478 .set({ lastSleepDigestSentAt: new Date() })
479479 .where(eq(users.id, userId));
480480 } catch (err) {
481 console.error("[sleep-mode] lastDigestSentAt update failed:", err);
481 console.error("[sleep-mode] lastSleepDigestSentAt update failed:", err);
482482 }
483483 return { ok: true };
484484 }
Addedsrc/routes/changelog.tsx+262−0View fileUnifiedSplit
@@ -0,0 +1,262 @@
1/**
2 * /changelog — manually curated list of recent platform releases.
3 * Public, no auth required.
4 */
5
6import { Hono } from "hono";
7import type { FC } from "hono/jsx";
8import { Layout } from "../views/layout";
9import { softAuth } from "../middleware/auth";
10import type { AuthEnv } from "../middleware/auth";
11
12const changelog = new Hono<AuthEnv>();
13changelog.use("*", softAuth);
14
15changelog.get("/changelog", (c) => {
16 const user = c.get("user");
17 return c.html(
18 <Layout
19 title="Changelog — gluecron"
20 description="What's new in gluecron — recent feature releases, improvements, and platform updates."
21 user={user}
22 >
23 <ChangelogPage />
24 </Layout>,
25 );
26});
27
28// ---------------------------------------------------------------------------
29// Data
30// ---------------------------------------------------------------------------
31
32interface ChangelogEntry {
33 title: string;
34 description: string;
35}
36
37interface ChangelogMonth {
38 month: string;
39 entries: ChangelogEntry[];
40}
41
42const RELEASES: ChangelogMonth[] = [
43 {
44 month: "June 2026",
45 entries: [
46 {
47 title: "AI Trio Review",
48 description:
49 "Three-model parallel PR review running Security, Correctness, and Style passes simultaneously — faster feedback, broader coverage.",
50 },
51 {
52 title: "Spec-to-Live progress UI",
53 description:
54 "Watch your spec become a merged PR in real time: a live progress stream shows each agent step from spec parse to gate green to merge.",
55 },
56 {
57 title: "Pack-content ruleset enforcement",
58 description:
59 "Block bad commits at push time with pack-content rulesets — enforce file-size limits, banned extensions, and content patterns before the push lands.",
60 },
61 {
62 title: "Customer deploy targets",
63 description:
64 "SSH deploy to your own server directly from a merge. Register a server target in repo settings and autopilot handles the rsync.",
65 },
66 {
67 title: "Workflow cache SAVE",
68 description:
69 "CI runs warm from the second run. Workflow jobs can now persist dependency caches between runs, cutting install time on hot paths by up to 80%.",
70 },
71 {
72 title: "Push Watch",
73 description:
74 "A pulsing Live indicator appears in the repo header whenever a push is in flight — gate runs, AI review, and deploy status update without a page reload.",
75 },
76 ],
77 },
78 {
79 month: "May 2026",
80 entries: [
81 {
82 title: "Branch preview URLs with auto-expiry cleanup",
83 description:
84 "Every PR branch gets an isolated preview URL. Autopilot tears down stale previews 24 hours after the branch is merged or closed.",
85 },
86 {
87 title: "Dashboard AI activity widget",
88 description:
89 "A compact widget on /dashboard surfaces the last hour of autopilot actions across all your repos — repairs, reviews, deploys, and digest sends at a glance.",
90 },
91 {
92 title: "Health score badge on repo header",
93 description:
94 "A colour-coded health score (0–100) appears in the repo header, computed from gate pass rate, stale PR count, and recent deploy success.",
95 },
96 ],
97 },
98];
99
100// ---------------------------------------------------------------------------
101// View
102// ---------------------------------------------------------------------------
103
104const ChangelogPage: FC = () => (
105 <>
106 <style dangerouslySetInnerHTML={{ __html: changelogCss }} />
107 <div class="cl-root">
108 <header class="cl-hero">
109 <div class="cl-hero-inner">
110 <p class="cl-eyebrow">Platform updates</p>
111 <h1 class="cl-title">What's New in Gluecron</h1>
112 <p class="cl-subtitle">
113 Recent features, fixes, and improvements shipped to the platform.
114 </p>
115 <a href="/settings/notifications" class="cl-cta">
116 Subscribe to updates →
117 </a>
118 </div>
119 </header>
120
121 <div class="cl-content">
122 {RELEASES.map((rel) => (
123 <section class="cl-month" key={rel.month}>
124 <h2 class="cl-month-heading">{rel.month}</h2>
125 <ul class="cl-entries">
126 {rel.entries.map((entry) => (
127 <li class="cl-entry" key={entry.title}>
128 <span class="cl-entry-dot" aria-hidden="true" />
129 <div class="cl-entry-body">
130 <strong class="cl-entry-title">{entry.title}</strong>
131 <p class="cl-entry-desc">{entry.description}</p>
132 </div>
133 </li>
134 ))}
135 </ul>
136 </section>
137 ))}
138 </div>
139 </div>
140 </>
141);
142
143// ---------------------------------------------------------------------------
144// Styles (dark-theme aware, uses CSS custom properties from layout.tsx)
145// ---------------------------------------------------------------------------
146
147const changelogCss = `
148.cl-root {
149 max-width: 760px;
150 margin: 0 auto;
151 padding: 48px 24px 80px;
152}
153
154/* Hero */
155.cl-hero {
156 margin-bottom: 56px;
157 text-align: center;
158}
159.cl-hero-inner {
160 display: flex;
161 flex-direction: column;
162 align-items: center;
163 gap: 12px;
164}
165.cl-eyebrow {
166 font-size: 12px;
167 font-weight: 600;
168 letter-spacing: 0.1em;
169 text-transform: uppercase;
170 color: var(--accent);
171 margin: 0;
172}
173.cl-title {
174 font-size: clamp(28px, 5vw, 40px);
175 font-weight: 700;
176 color: var(--text-strong);
177 margin: 0;
178 line-height: 1.2;
179}
180.cl-subtitle {
181 font-size: 16px;
182 color: var(--text-muted);
183 margin: 0;
184 max-width: 520px;
185 line-height: 1.6;
186}
187.cl-cta {
188 display: inline-block;
189 margin-top: 8px;
190 padding: 10px 20px;
191 background: var(--accent);
192 color: #fff;
193 border-radius: 8px;
194 font-size: 14px;
195 font-weight: 600;
196 text-decoration: none;
197 transition: opacity 0.15s;
198}
199.cl-cta:hover { opacity: 0.85; text-decoration: none; }
200
201/* Month groups */
202.cl-content {
203 display: flex;
204 flex-direction: column;
205 gap: 48px;
206}
207.cl-month {}
208.cl-month-heading {
209 font-size: 18px;
210 font-weight: 700;
211 color: var(--text-strong);
212 margin: 0 0 24px;
213 padding-bottom: 12px;
214 border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.08));
215}
216
217/* Entry list */
218.cl-entries {
219 list-style: none;
220 margin: 0;
221 padding: 0;
222 display: flex;
223 flex-direction: column;
224 gap: 20px;
225}
226.cl-entry {
227 display: flex;
228 gap: 16px;
229 align-items: flex-start;
230}
231.cl-entry-dot {
232 flex-shrink: 0;
233 margin-top: 6px;
234 width: 8px;
235 height: 8px;
236 border-radius: 50%;
237 background: var(--accent);
238 box-shadow: 0 0 8px rgba(140,109,255,0.5);
239}
240.cl-entry-body {
241 flex: 1;
242}
243.cl-entry-title {
244 display: block;
245 font-size: 15px;
246 font-weight: 600;
247 color: var(--text-strong);
248 margin-bottom: 4px;
249}
250.cl-entry-desc {
251 margin: 0;
252 font-size: 14px;
253 color: var(--text-muted);
254 line-height: 1.6;
255}
256
257@media (max-width: 600px) {
258 .cl-root { padding: 32px 16px 64px; }
259}
260`;
261
262export default changelog;
Modifiedsrc/views/layout.tsx+1−0View fileUnifiedSplit
@@ -329,6 +329,7 @@ export const Layout: FC<
329329 <div class="footer-col-title">Product</div>
330330 <a href="/features">Features</a>
331331 <a href="/pricing">Pricing</a>
332 <a href="/changelog">Changelog</a>
332333 <a href="/explore">Explore</a>
333334 <a href="/marketplace">Marketplace</a>
334335 </div>
335336