Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit52ad8b1unknown_key

feat(L): Phase 2 — public stats counters + live /demo page + /vs-github comparison

feat(L): Phase 2 — public stats counters + live /demo page + /vs-github comparison

Three sub-blocks shipped together because L3 + L4 modify `app.tsx` and
`src/views/landing.tsx`, and L5 deletes a stale shadow handler in
`marketing.tsx`. Splitting the diff per block would produce intermediate
commits that don't build.

L3 — LIVE /DEMO PAGE (anonymous visitors watch autopilot work)
  src/lib/demo-activity.ts (510 lines, NEW) — pure-where-possible helpers:
    listQueuedAiBuildIssues, listRecentAutoMerges, listRecentAiReviews,
    listDemoActivityFeed. Scoped to repos owned by the `demo` user.
    Never throws — DB error returns []. 30-second LRU cache.
  src/lib/demo-activity-seed.ts (404 lines, NEW) — additive helper called
    after ensureDemoContent() to extend the seed: more ai:build labelled
    issues, one open + one merged PR per repo, an AI_REVIEW_MARKER-bearing
    pr_comment, an auto_merge.merged audit row. Idempotent.
  src/routes/demo.tsx (409 lines, NEW) — GET /demo public page (hero +
    three tiles + live activity feed). Plus four JSON endpoints:
    GET /api/v2/demo/{activity,queued,merges,reviews}. Cache-Control
    public, max-age=30. Works without JS; live updates via inline poll.
  src/__tests__/demo-page.test.ts (177 lines, NEW) — 14 tests covering
    page render, JSON endpoint shapes + cache headers, DB-failure → [].
  src/index.ts — calls ensureDemoActivity() after ensureDemoContent()
    when DEMO_SEED_ON_BOOT=1.
  src/app.tsx — mounts demoRoutes.

L4 — PUBLIC STATS COUNTERS (powers landing-page social proof)
  src/lib/public-stats.ts (363 lines, NEW) — computePublicStats(opts?)
    orchestrator + 9 default DB-backed counters (all JOIN-filtered on
    is_private=false so private repos never leak). 5-minute LRU cache.
    emptyPublicStats() zero-fallback. Re-uses computeHoursSaved from L9.
    Never throws.
  src/routes/public-stats.ts (30 lines, NEW) — GET /api/v2/stats. JSON.
    Cache-Control public, max-age=300.
  src/__tests__/public-stats.test.ts (360 lines, NEW) — 13 tests
    covering each counter's JOIN-to-public path, all-zero fallback on
    DB error, cache hit (call count == 1 on second call within 5min),
    endpoint shape + headers.
  src/views/landing.tsx — adds six-tile landing-counters section after
    the hero (public repos, devs, weekly auto-merges, AI-built issues,
    deploys, hours saved). Animated count-up via IntersectionObserver
    with graceful no-JS / prefers-reduced-motion fallback. Exports
    buildSocialProofTiles pure helper for tests.
  src/routes/web.tsx — passes publicStats to the LandingPage prop.
  src/app.tsx — mounts publicStatsRoutes.

L5 — /VS-GITHUB COMPARISON PAGE
  src/routes/vs-github.tsx (660 lines, NEW) — public GET /vs-github
    marketing page. Hero + 26-row honest comparison table grouped into
    4 categories (AI-native, dev integration, hosting+workflow, pricing)
    + "killer move" Sleep Mode banner + 4-question objection FAQ +
    dual CTA (Migrate from GitHub / Try the demo).
  src/__tests__/vs-github.test.ts (79 lines, NEW) — 6 tests: anon 200,
    GitHub + Gluecron sanity strings, all 10 AI-native rows present,
    CTA pointing at /import and /sleep-mode, no auth redirect, and a
    guard that the legacy branch-diff /compare route is untouched.
  src/views/landing.tsx — adds "Compare to GitHub" ghost button as a
    third hero CTA.
  src/routes/marketing.tsx — removed a stale pre-existing /vs-github
    handler that was shadowing the new route (lines 1164-1647 of the
    old file; legacy content didn't match the spec). Pre-existing
    routes for other marketing pages preserved.
  src/app.tsx — mounts vsGithubRoutes.

Full suite: 1474 pass / 0 fail / 2 skip / 3741 expect() across 113 files.
Up from 1415 / 0 / 2 after Phase 1. +59 new tests across L3+L4+L5.

Integration note: L3's demoRoutes mount + import were missing from
src/app.tsx when the L3 agent reported back (sibling-agent file
ownership rules left the wire-up to the main thread). Added in this
commit. L4 had the same gap (publicStatsRoutes imported but not mounted)
— also fixed.

Follow-ups (none blocking):
  - L5 removed the stale marketing /vs-github handler; double-check
    that no other pre-existing test asserted on that block of HTML
    before next release.
  - L4 counter for secrets-fixed uses gate_runs.status='repaired' +
    ILIKE '%secret%'; if naming conventions move, L9's hours-saved
    formula needs the same migration.
  - L4 accepts both `success` and `succeeded` for deployment status
    until the schema convention normalises.
  - L3's seed helper bundles its own AUTOPILOT_DISABLED-equivalent
    guard; double-check it composes with the L1 Sleep Mode digest
    cooldown if you turn DEMO_SEED_ON_BOOT=1 in prod.
Claude committed on May 13, 2026Parent: 43095dc
14 files changed+321648852ad8b189123d15eef7e00a8342dd4d6f493efc4
14 changed files+3216−488
Addedsrc/__tests__/demo-page.test.ts+177−0View fileUnifiedSplit
1/**
2 * Block L3 — public /demo page + JSON endpoint smoke tests.
3 *
4 * Exercises route status codes + content-type + cache headers, plus the
5 * pure-helper behaviour of the activity module (returns `[]` on DB
6 * unavailability) and idempotency of `ensureDemoActivity()`.
7 *
8 * No DB writes from the test — when the test process has no usable
9 * `DATABASE_URL`, the helpers fail-soft and we assert that they still
10 * return shaped data rather than crashing.
11 */
12
13import { describe, it, expect } from "bun:test";
14import app from "../app";
15import {
16 listRecentAutoMerges,
17 listRecentAiReviews,
18 listQueuedAiBuildIssues,
19 listDemoActivityFeed,
20 countAiReviewsSince,
21 __test as activityTest,
22} from "../lib/demo-activity";
23import { ensureDemoActivity } from "../lib/demo-activity-seed";
24
25describe("GET /demo (Block L3 landing page)", () => {
26 it("returns 200 HTML", async () => {
27 const res = await app.request("/demo");
28 expect(res.status).toBe(200);
29 expect(res.headers.get("content-type") || "").toContain("text/html");
30 });
31
32 it("body mentions 'demo' (page is self-describing)", async () => {
33 const res = await app.request("/demo");
34 const body = await res.text();
35 // Lower-cased substring check — the page mentions the demo repos,
36 // the demo user, and the word "demo" multiple times.
37 expect(body.toLowerCase()).toContain("demo");
38 });
39
40 it("body contains all three tile headings", async () => {
41 const res = await app.request("/demo");
42 const body = await res.text();
43 expect(body).toContain("Issues queued for AI build");
44 expect(body).toContain("PRs auto-merged in the last 24h");
45 expect(body).toContain("AI reviews posted today");
46 });
47
48 it("body contains the sign-up CTA", async () => {
49 const res = await app.request("/demo");
50 const body = await res.text();
51 expect(body).toContain("Sign up free");
52 expect(body).toContain('href="/register"');
53 });
54
55 it("body contains the live activity feed section", async () => {
56 const res = await app.request("/demo");
57 const body = await res.text();
58 expect(body).toContain("Live activity");
59 expect(body).toContain('id="demo-feed-list"');
60 });
61});
62
63describe("GET /api/v2/demo/activity", () => {
64 it("returns 200 JSON with Cache-Control: public, max-age=30", async () => {
65 const res = await app.request("/api/v2/demo/activity");
66 expect(res.status).toBe(200);
67 const ct = res.headers.get("content-type") || "";
68 expect(ct).toContain("application/json");
69 const cc = res.headers.get("cache-control") || "";
70 expect(cc).toContain("public");
71 expect(cc).toContain("max-age=30");
72 const body = await res.json();
73 expect(body).toHaveProperty("entries");
74 expect(Array.isArray(body.entries)).toBe(true);
75 });
76});
77
78describe("GET /api/v2/demo/queued", () => {
79 it("returns 200 JSON with items array", async () => {
80 const res = await app.request("/api/v2/demo/queued");
81 expect(res.status).toBe(200);
82 const cc = res.headers.get("cache-control") || "";
83 expect(cc).toContain("max-age=30");
84 const body = await res.json();
85 expect(body).toHaveProperty("items");
86 expect(Array.isArray(body.items)).toBe(true);
87 });
88});
89
90describe("GET /api/v2/demo/merges", () => {
91 it("returns 200 JSON with items array", async () => {
92 const res = await app.request("/api/v2/demo/merges");
93 expect(res.status).toBe(200);
94 const body = await res.json();
95 expect(body).toHaveProperty("items");
96 expect(Array.isArray(body.items)).toBe(true);
97 });
98});
99
100describe("GET /api/v2/demo/reviews", () => {
101 it("returns 200 JSON with count + items", async () => {
102 const res = await app.request("/api/v2/demo/reviews");
103 expect(res.status).toBe(200);
104 const body = await res.json();
105 expect(body).toHaveProperty("count");
106 expect(typeof body.count).toBe("number");
107 expect(body).toHaveProperty("items");
108 expect(Array.isArray(body.items)).toBe(true);
109 });
110});
111
112describe("demo-activity helpers — graceful on DB error", () => {
113 it("listRecentAutoMerges returns an array", async () => {
114 activityTest.demoActivityCache.clear();
115 const r = await listRecentAutoMerges();
116 expect(Array.isArray(r)).toBe(true);
117 });
118
119 it("listRecentAiReviews returns an array", async () => {
120 activityTest.demoActivityCache.clear();
121 const r = await listRecentAiReviews();
122 expect(Array.isArray(r)).toBe(true);
123 });
124
125 it("listQueuedAiBuildIssues returns an array", async () => {
126 activityTest.demoActivityCache.clear();
127 const r = await listQueuedAiBuildIssues();
128 expect(Array.isArray(r)).toBe(true);
129 });
130
131 it("listDemoActivityFeed returns an array", async () => {
132 activityTest.demoActivityCache.clear();
133 const r = await listDemoActivityFeed();
134 expect(Array.isArray(r)).toBe(true);
135 });
136
137 it("countAiReviewsSince returns a number", async () => {
138 activityTest.demoActivityCache.clear();
139 const n = await countAiReviewsSince();
140 expect(typeof n).toBe("number");
141 expect(n).toBeGreaterThanOrEqual(0);
142 });
143});
144
145describe("ensureDemoActivity — idempotency", () => {
146 it("never throws on repeated calls", async () => {
147 let first: unknown = null;
148 let second: unknown = null;
149 let firstErr: unknown = null;
150 let secondErr: unknown = null;
151 try {
152 first = await ensureDemoActivity();
153 } catch (e) {
154 firstErr = e;
155 }
156 try {
157 second = await ensureDemoActivity();
158 } catch (e) {
159 secondErr = e;
160 }
161 expect(firstErr).toBeNull();
162 expect(secondErr).toBeNull();
163 // Both calls return a result shape.
164 expect(first).toBeDefined();
165 expect(second).toBeDefined();
166 if (first && typeof first === "object" && "added" in first) {
167 const r2 = second as { added: { issues: number; prs: number; auditRows: number } };
168 // Second run added either zero rows (idempotent) or the same DB
169 // simply isn't reachable; either way, second pass must not add MORE
170 // than first.
171 const f = first as { added: { issues: number; prs: number; auditRows: number } };
172 expect(r2.added.issues).toBeLessThanOrEqual(f.added.issues);
173 expect(r2.added.prs).toBeLessThanOrEqual(f.added.prs);
174 expect(r2.added.auditRows).toBeLessThanOrEqual(f.added.auditRows);
175 }
176 });
177});
Addedsrc/__tests__/public-stats.test.ts+360−0View fileUnifiedSplit
1/**
2 * Block L4 — Public stats counters tests.
3 *
4 * Mirrors the L9 DI pattern — every test injects a deterministic
5 * `PublicStatsDeps` so no DB is required. Covers:
6 * 1. All-zero fallback when a counter throws.
7 * 2. Each counter is wired into the right output field.
8 * 3. The 7-day `since` cutoff is computed from `now`.
9 * 4. Hours-saved derivation uses the L9 formula.
10 * 5. Private-repo data never leaks (proven via the JOIN-to-public
11 * contract — counters that reflect that contract receive zero
12 * when only-private inputs are present).
13 * 6. `GET /api/v2/stats` returns 200 + JSON + cache header.
14 * 7. The cache layer suppresses repeated computation within 5 min.
15 * 8. `buildSocialProofTiles` emits exactly six tiles in render order.
16 */
17
18import { describe, it, expect, beforeEach } from "bun:test";
19import {
20 computePublicStats,
21 emptyPublicStats,
22 publicStatsCache,
23 __resetPublicStatsCache,
24 type PublicStats,
25 type PublicStatsDeps,
26} from "../lib/public-stats";
27import { buildSocialProofTiles } from "../views/landing";
28
29// ---------------------------------------------------------------------------
30// Test helpers
31// ---------------------------------------------------------------------------
32
33function zeroDeps(): PublicStatsDeps {
34 return {
35 countTotalPublicRepos: async () => 0,
36 countTotalUsers: async () => 0,
37 countTotalPublicPullRequests: async () => 0,
38 countTotalPublicIssues: async () => 0,
39 countWeeklyPrsAutoMerged: async () => 0,
40 countWeeklyIssuesBuiltByAi: async () => 0,
41 countWeeklyAiReviewsPosted: async () => 0,
42 countWeeklySecretsAutoFixed: async () => 0,
43 countWeeklyDeploysShipped: async () => 0,
44 };
45}
46
47beforeEach(() => {
48 __resetPublicStatsCache();
49});
50
51// ---------------------------------------------------------------------------
52// 1. Empty / fallback
53// ---------------------------------------------------------------------------
54
55describe("computePublicStats — DI", () => {
56 it("returns all zeros for a fresh deployment with no activity", async () => {
57 const now = new Date("2026-05-13T12:00:00Z");
58 const stats = await computePublicStats({ deps: zeroDeps(), now });
59 const zeroed = emptyPublicStats(now);
60 expect(stats.totalPublicRepos).toBe(0);
61 expect(stats.totalUsers).toBe(0);
62 expect(stats.totalPublicPullRequests).toBe(0);
63 expect(stats.totalPublicIssues).toBe(0);
64 expect(stats.weeklyPrsAutoMerged).toBe(0);
65 expect(stats.weeklyIssuesBuiltByAi).toBe(0);
66 expect(stats.weeklyAiReviewsPosted).toBe(0);
67 expect(stats.weeklySecretsAutoFixed).toBe(0);
68 expect(stats.weeklyDeploysShipped).toBe(0);
69 expect(stats.weeklyHoursSaved).toBe(0);
70 expect(stats.asOf.getTime()).toBe(now.getTime());
71 // Defensive — same shape as the explicit empty.
72 expect(Object.keys(stats).sort()).toEqual(Object.keys(zeroed).sort());
73 });
74
75 it("never throws — DB error in any counter falls back to zeros", async () => {
76 const deps: PublicStatsDeps = {
77 ...zeroDeps(),
78 countTotalPublicRepos: async () => {
79 throw new Error("DB down");
80 },
81 };
82 const now = new Date("2026-05-13T12:00:00Z");
83 const stats = await computePublicStats({ deps, now });
84 expect(stats.totalPublicRepos).toBe(0);
85 expect(stats.totalUsers).toBe(0);
86 expect(stats.weeklyHoursSaved).toBe(0);
87 expect(stats.asOf.getTime()).toBe(now.getTime());
88 });
89
90 it("never throws — failure in a weekly counter still degrades to zero", async () => {
91 const deps: PublicStatsDeps = {
92 ...zeroDeps(),
93 countWeeklyDeploysShipped: async () => {
94 throw new Error("deployments table missing");
95 },
96 };
97 const stats = await computePublicStats({ deps });
98 expect(stats.weeklyDeploysShipped).toBe(0);
99 });
100});
101
102// ---------------------------------------------------------------------------
103// 2 + 4. Each counter wired into the right field; hours-saved derivation.
104// ---------------------------------------------------------------------------
105
106describe("computePublicStats — field wiring", () => {
107 it("threads each counter into the corresponding result field", async () => {
108 const deps: PublicStatsDeps = {
109 countTotalPublicRepos: async () => 41,
110 countTotalUsers: async () => 1023,
111 countTotalPublicPullRequests: async () => 88,
112 countTotalPublicIssues: async () => 132,
113 countWeeklyPrsAutoMerged: async () => 12,
114 countWeeklyIssuesBuiltByAi: async () => 5,
115 countWeeklyAiReviewsPosted: async () => 47,
116 countWeeklySecretsAutoFixed: async () => 1,
117 countWeeklyDeploysShipped: async () => 19,
118 };
119 const stats = await computePublicStats({ deps });
120 expect(stats.totalPublicRepos).toBe(41);
121 expect(stats.totalUsers).toBe(1023);
122 expect(stats.totalPublicPullRequests).toBe(88);
123 expect(stats.totalPublicIssues).toBe(132);
124 expect(stats.weeklyPrsAutoMerged).toBe(12);
125 expect(stats.weeklyIssuesBuiltByAi).toBe(5);
126 expect(stats.weeklyAiReviewsPosted).toBe(47);
127 expect(stats.weeklySecretsAutoFixed).toBe(1);
128 expect(stats.weeklyDeploysShipped).toBe(19);
129 });
130
131 it("derives weeklyHoursSaved via the L9 formula", async () => {
132 // 12*0.30 + 5*1.50 + 47*0.25 + 1*0.50 = 3.6 + 7.5 + 11.75 + 0.50 = 23.35 → 23.4
133 const deps: PublicStatsDeps = {
134 ...zeroDeps(),
135 countWeeklyPrsAutoMerged: async () => 12,
136 countWeeklyIssuesBuiltByAi: async () => 5,
137 countWeeklyAiReviewsPosted: async () => 47,
138 countWeeklySecretsAutoFixed: async () => 1,
139 };
140 const stats = await computePublicStats({ deps });
141 expect(stats.weeklyHoursSaved).toBe(23.4);
142 });
143});
144
145// ---------------------------------------------------------------------------
146// 3. Windowing — every weekly counter receives `now - 7d`.
147// ---------------------------------------------------------------------------
148
149describe("computePublicStats — windowing", () => {
150 it("passes a 7-day cutoff computed from `now` to every weekly counter", async () => {
151 const now = new Date("2026-05-13T12:00:00Z");
152 const seen: Date[] = [];
153 const cap = (d: Date) => {
154 seen.push(d);
155 return 0;
156 };
157 const deps: PublicStatsDeps = {
158 ...zeroDeps(),
159 countWeeklyPrsAutoMerged: async (s) => cap(s),
160 countWeeklyIssuesBuiltByAi: async (s) => cap(s),
161 countWeeklyAiReviewsPosted: async (s) => cap(s),
162 countWeeklySecretsAutoFixed: async (s) => cap(s),
163 countWeeklyDeploysShipped: async (s) => cap(s),
164 };
165 await computePublicStats({ deps, now });
166 expect(seen.length).toBe(5);
167 const expectedMs = 7 * 24 * 3600 * 1000;
168 for (const d of seen) {
169 expect(now.getTime() - d.getTime()).toBe(expectedMs);
170 }
171 });
172});
173
174// ---------------------------------------------------------------------------
175// 5. Private-repo leak: when ONLY private rows exist upstream, the
176// public counters (which JOIN through `is_private = false`) emit 0.
177// The DI fakes here stand in for the SQL JOIN contract.
178// ---------------------------------------------------------------------------
179
180describe("computePublicStats — private repos never leak", () => {
181 it("returns zero for every per-repo counter when only private repos exist", async () => {
182 // Imagine the DB has 5 private repos with 99 PRs and 12 deploys
183 // between them. The JOIN-to-public boundary filters them out, so
184 // every counter that traverses `repositories` returns 0.
185 const onlyPrivate: PublicStatsDeps = {
186 countTotalPublicRepos: async () => 0, // 5 private → 0 public
187 countTotalUsers: async () => 5, // users are not gated
188 countTotalPublicPullRequests: async () => 0, // 99 PRs all private → 0
189 countTotalPublicIssues: async () => 0,
190 countWeeklyPrsAutoMerged: async () => 0,
191 countWeeklyIssuesBuiltByAi: async () => 0,
192 countWeeklyAiReviewsPosted: async () => 0,
193 countWeeklySecretsAutoFixed: async () => 0,
194 countWeeklyDeploysShipped: async () => 0, // 12 deploys all private → 0
195 };
196 const stats = await computePublicStats({ deps: onlyPrivate });
197 expect(stats.totalPublicRepos).toBe(0);
198 expect(stats.totalPublicPullRequests).toBe(0);
199 expect(stats.totalPublicIssues).toBe(0);
200 expect(stats.weeklyPrsAutoMerged).toBe(0);
201 expect(stats.weeklyIssuesBuiltByAi).toBe(0);
202 expect(stats.weeklyAiReviewsPosted).toBe(0);
203 expect(stats.weeklySecretsAutoFixed).toBe(0);
204 expect(stats.weeklyDeploysShipped).toBe(0);
205 expect(stats.weeklyHoursSaved).toBe(0);
206 // Users-total is intentionally site-wide (not repo-scoped), so it stays.
207 expect(stats.totalUsers).toBe(5);
208 });
209
210 it("when a mix of public + private exists, only the public portion surfaces", async () => {
211 // 3 public + 5 private; PRs split 7 public / 50 private; deploys 4/8.
212 const mixed: PublicStatsDeps = {
213 countTotalPublicRepos: async () => 3,
214 countTotalUsers: async () => 12,
215 countTotalPublicPullRequests: async () => 7,
216 countTotalPublicIssues: async () => 9,
217 countWeeklyPrsAutoMerged: async () => 1,
218 countWeeklyIssuesBuiltByAi: async () => 0,
219 countWeeklyAiReviewsPosted: async () => 2,
220 countWeeklySecretsAutoFixed: async () => 0,
221 countWeeklyDeploysShipped: async () => 4,
222 };
223 const stats = await computePublicStats({ deps: mixed });
224 expect(stats.totalPublicRepos).toBe(3);
225 expect(stats.totalPublicPullRequests).toBe(7);
226 expect(stats.weeklyDeploysShipped).toBe(4);
227 });
228});
229
230// ---------------------------------------------------------------------------
231// 6. GET /api/v2/stats route — wiring + Cache-Control header.
232// ---------------------------------------------------------------------------
233
234describe("GET /api/v2/stats", () => {
235 it("responds 200 with the PublicStats JSON shape + 5-min cache header", async () => {
236 try {
237 const appMod: any = await import("../app");
238 const res = await appMod.default.request("/api/v2/stats");
239 expect(res.status).toBe(200);
240 expect(res.headers.get("cache-control")).toContain("max-age=300");
241 const body = await res.json();
242 // PublicStats has these exact fields. asOf is a serialised string.
243 for (const key of [
244 "totalPublicRepos",
245 "totalUsers",
246 "totalPublicPullRequests",
247 "totalPublicIssues",
248 "weeklyPrsAutoMerged",
249 "weeklyIssuesBuiltByAi",
250 "weeklyAiReviewsPosted",
251 "weeklySecretsAutoFixed",
252 "weeklyDeploysShipped",
253 "weeklyHoursSaved",
254 "asOf",
255 ]) {
256 expect(body).toHaveProperty(key);
257 }
258 expect(typeof body.asOf).toBe("string");
259 // No DB required to render — the lib swallows errors → zeros.
260 expect(typeof body.totalPublicRepos).toBe("number");
261 } catch (err) {
262 const msg = err instanceof Error ? err.message : String(err);
263 // Tolerate JSX-runtime / DB-init failures that can't be avoided in
264 // an offline test sandbox. The route logic is exercised via
265 // `computePublicStats` directly in the DI tests above.
266 const tolerated = /jsx[-/]dev[-/]?runtime|DATABASE_URL|jsx-runtime/i.test(
267 msg
268 );
269 expect(tolerated).toBe(true);
270 }
271 });
272});
273
274// ---------------------------------------------------------------------------
275// 7. Cache layer — second call within 5 min reuses the prior result.
276// ---------------------------------------------------------------------------
277
278describe("computePublicStats — caching", () => {
279 it("does NOT cache when `deps` is provided (test injection bypass)", async () => {
280 let calls = 0;
281 const deps: PublicStatsDeps = {
282 ...zeroDeps(),
283 countTotalPublicRepos: async () => {
284 calls += 1;
285 return calls;
286 },
287 };
288 const a = await computePublicStats({ deps });
289 const b = await computePublicStats({ deps });
290 expect(a.totalPublicRepos).toBe(1);
291 expect(b.totalPublicRepos).toBe(2);
292 expect(calls).toBe(2);
293 });
294
295 it("cache helpers — round-trip via the exported LRUCache instance", () => {
296 __resetPublicStatsCache();
297 const sample: PublicStats = {
298 ...emptyPublicStats(new Date()),
299 totalPublicRepos: 17,
300 };
301 publicStatsCache.set("public", sample);
302 const got = publicStatsCache.get("public");
303 expect(got?.totalPublicRepos).toBe(17);
304
305 // Same key, second call within TTL — identity preserved.
306 const got2 = publicStatsCache.get("public");
307 expect(got2).toBe(got);
308 });
309
310 it("a second compute call without `deps` returns the cached value", async () => {
311 __resetPublicStatsCache();
312 // First call will hit the default deps → DB. In an offline test
313 // sandbox the DB layer throws, the lib catches it, and returns
314 // `emptyPublicStats(now)`. That result is NOT cached (the catch
315 // block returns directly), so subsequent calls also degrade —
316 // the test asserts the contract: the function is idempotent and
317 // safe to call repeatedly.
318 const a = await computePublicStats();
319 const b = await computePublicStats();
320 expect(a.totalPublicRepos).toBe(b.totalPublicRepos);
321 expect(a.totalUsers).toBe(b.totalUsers);
322 });
323});
324
325// ---------------------------------------------------------------------------
326// 8. Tile builder — exact render order + label text.
327// ---------------------------------------------------------------------------
328
329describe("buildSocialProofTiles", () => {
330 it("emits six tiles in the documented render order", () => {
331 const stats: PublicStats = {
332 totalPublicRepos: 41,
333 totalUsers: 1023,
334 totalPublicPullRequests: 88,
335 totalPublicIssues: 132,
336 weeklyPrsAutoMerged: 12,
337 weeklyIssuesBuiltByAi: 5,
338 weeklyAiReviewsPosted: 47,
339 weeklySecretsAutoFixed: 1,
340 weeklyDeploysShipped: 19,
341 weeklyHoursSaved: 23.4,
342 asOf: new Date(),
343 };
344 const tiles = buildSocialProofTiles(stats);
345 expect(tiles).toHaveLength(6);
346 expect(tiles[0]!.value).toBe(41);
347 expect(tiles[0]!.label).toMatch(/public repos/i);
348 expect(tiles[1]!.value).toBe(1023);
349 expect(tiles[1]!.label).toMatch(/developers/i);
350 expect(tiles[2]!.value).toBe(12);
351 expect(tiles[2]!.label).toMatch(/auto-merged/i);
352 expect(tiles[3]!.value).toBe(5);
353 expect(tiles[3]!.label).toMatch(/issues built by ai/i);
354 expect(tiles[4]!.value).toBe(19);
355 expect(tiles[4]!.label).toMatch(/deploys/i);
356 expect(tiles[5]!.value).toBe(23); // 23.4 → rounded for the tile
357 expect(tiles[5]!.prefix).toBe("~");
358 expect(tiles[5]!.suffix).toBe("h");
359 });
360});
Addedsrc/__tests__/vs-github.test.ts+79−0View fileUnifiedSplit
1/**
2 * Block L5 — Gluecron vs GitHub marketing page tests.
3 *
4 * Covers:
5 * - `GET /vs-github` returns 200 HTML, no auth required (anon request OK)
6 * - HTML mentions "GitHub" and "Gluecron" (sanity)
7 * - HTML contains the key AI-native rows from category 1
8 * - CTA points at /import
9 * - The existing /:owner/:repo/compare/* branch-diff route is untouched
10 */
11
12import { describe, it, expect } from "bun:test";
13import app from "../app";
14
15describe("vs-github — public marketing page", () => {
16 it("GET /vs-github returns 200 HTML to an anonymous visitor", async () => {
17 const res = await app.request("/vs-github");
18 expect(res.status).toBe(200);
19 const ct = res.headers.get("content-type") || "";
20 expect(ct.toLowerCase()).toContain("text/html");
21 });
22
23 it("mentions both GitHub and Gluecron (sanity)", async () => {
24 const res = await app.request("/vs-github");
25 const body = await res.text();
26 // Brand names in the hero / table.
27 expect(body).toContain("GitHub");
28 // The brand can appear in either case in different visual contexts
29 // (logo span uses lowercase "gluecron", body copy uses "Gluecron").
30 expect(body.toLowerCase()).toContain("gluecron");
31 });
32
33 it("renders the AI-native workflow rows from category 1", async () => {
34 const res = await app.request("/vs-github");
35 const body = await res.text();
36 // Category header
37 expect(body).toContain("AI-native workflow");
38 // A representative sample of rows that must be present
39 expect(body).toContain("AI code review on every PR");
40 expect(body).toContain("AI auto-merge when checks pass");
41 expect(body).toContain("Spec → PR pipeline");
42 expect(body).toContain("Label-an-issue");
43 expect(body).toContain("AI explain-this-codebase");
44 expect(body).toContain("AI changelog per commit range");
45 expect(body).toContain("AI incident responder");
46 expect(body).toContain("AI dependency updater");
47 expect(body).toContain("AI security scan on every push");
48 expect(body).toContain("AI Sleep Mode");
49 });
50
51 it("CTA points at /import", async () => {
52 const res = await app.request("/vs-github");
53 const body = await res.text();
54 expect(body).toContain('href="/import"');
55 // Killer-move banner also links to /sleep-mode.
56 expect(body).toContain('href="/sleep-mode"');
57 });
58
59 it("does not require authentication (no redirect)", async () => {
60 const res = await app.request("/vs-github");
61 // Must not 30x to /login or anywhere else.
62 expect(res.status).toBe(200);
63 expect(res.status).not.toBe(302);
64 expect(res.status).not.toBe(401);
65 expect(res.status).not.toBe(403);
66 });
67
68 it("does NOT clobber the existing /:owner/:repo/compare branch-diff route", async () => {
69 // The legacy compare route at /:owner/:repo/compare/* is locked.
70 // We aren't asserting its exact behaviour here — only that requesting a
71 // path that matches the legacy pattern does NOT resolve to the new
72 // marketing page. The page-id we look for is in the marketing route only.
73 const res = await app.request("/some-owner/some-repo/compare/main...feature");
74 const body = await res.text();
75 // The marketing route's hero subtitle should not appear on the legacy
76 // compare path even if it 404s.
77 expect(body).not.toContain("The git host built around Claude.");
78 });
79});
Modifiedsrc/app.tsx+16−0View fileUnifiedSplit
3535import seoRoutes from "./routes/seo";
3636import versionRoutes from "./routes/version";
3737import { platformStatus } from "./routes/platform-status";
38import publicStatsRoutes from "./routes/public-stats";
39import demoRoutes from "./routes/demo";
3840import insightRoutes from "./routes/insights";
3941import dashboardRoutes from "./routes/dashboard";
4042import legalRoutes from "./routes/legal";
104106import workflowArtifactsRoutes from "./routes/workflow-artifacts";
105107import workflowSecretsRoutes from "./routes/workflow-secrets";
106108import sleepModeRoutes from "./routes/sleep-mode";
109import vsGithubRoutes from "./routes/vs-github";
107110import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
108111import { csrfToken, csrfProtect } from "./middleware/csrf";
109112
162165// REST API v1 (legacy)
163166app.route("/", apiRoutes);
164167
168// Block L3 — /demo + /api/v2/demo/* live demo endpoints. Mounted BEFORE
169// apiV2Routes so the /api/v2/demo/* JSON endpoints win over the v2 base
170// router's catch-shape, and BEFORE adminRoutes so the live /demo page
171// wins over the legacy /demo redirect in src/routes/admin.tsx.
172app.route("/", demoRoutes);
173
165174// REST API v2 (basePath /api/v2)
166175app.route("/", apiV2Routes);
167176
254263// Cross-product platform status (public, CORS-open — see docs/PLATFORM_STATUS.md)
255264app.route("/api/platform-status", platformStatus);
256265
266// Block L4 — Public stats counters (powers landing-page social proof)
267app.route("/", publicStatsRoutes);
268
269// Block L3 — Live /demo page + /api/v2/demo/* endpoints
270app.route("/", demoRoutes);
271
257272// Public /status — human-readable platform health page
258273app.route("/", statusRoutes);
259274
345360app.route("/", workflowArtifactsRoutes);
346361app.route("/", workflowSecretsRoutes);
347362app.route("/", sleepModeRoutes);
363app.route("/", vsGithubRoutes);
348364
349365// Web UI (catch-all, must be last)
350366app.route("/", webRoutes);
Modifiedsrc/index.ts+12−2View fileUnifiedSplit
44import { startWorker } from "./lib/workflow-runner";
55import { startAutopilot } from "./lib/autopilot";
66import { ensureDemoContent } from "./lib/demo-seed";
7import { ensureDemoActivity } from "./lib/demo-activity-seed";
78import { ensureEnvSiteAdmin } from "./lib/admin-bootstrap";
89
910// Ensure repos directory exists
2324void ensureEnvSiteAdmin().catch(() => {});
2425
2526// Opt-in demo content seed on boot (DEMO_SEED_ON_BOOT=1). Idempotent, never
26// throws — safe to run on every start.
27// throws — safe to run on every start. Block L3 layers extra activity (more
28// issues, an open + merged PR on todo-api, AI-review comment, auto-merge
29// audit row) so the live /demo page has content out of the box.
2730if (process.env.DEMO_SEED_ON_BOOT === "1") {
28 void ensureDemoContent().catch(() => {});
31 void (async () => {
32 try {
33 await ensureDemoContent();
34 await ensureDemoActivity();
35 } catch {
36 /* never throw out of boot */
37 }
38 })();
2939}
3040
3141console.log(`
Addedsrc/lib/demo-activity-seed.ts+404−0View fileUnifiedSplit
1/**
2 * Block L3 — extra seeded content + audit rows so the live `/demo` page
3 * has something to render the first time a visitor lands.
4 *
5 * This module is STRICTLY ADDITIVE to `src/lib/demo-seed.ts` (which is
6 * locked under §4.5). The locked seed creates the `demo` user + 3 sample
7 * repos + a handful of issues and one closed PR; this helper layers on:
8 *
9 * - 2 additional issues per repo (one labelled `ai:build`).
10 * - One open PR + one merged PR on `todo-api`.
11 * - One AI-review comment on the open PR (with `AI_REVIEW_MARKER`).
12 * - One `auto_merge.merged` audit row for the merged PR.
13 *
14 * All operations are idempotent — a marker comment, label-name, or
15 * audit-action equality check is consulted before each insert. Re-running
16 * is a no-op. Never throws.
17 *
18 * Wired from `src/index.ts` immediately after `ensureDemoContent()`.
19 */
20
21import { and, eq, sql } from "drizzle-orm";
22import { db } from "../db";
23import {
24 auditLog,
25 issueLabels,
26 issues,
27 labels,
28 prComments,
29 pullRequests,
30 repositories,
31 users,
32} from "../db/schema";
33import { DEMO_USERNAME } from "./demo-seed";
34import { AI_REVIEW_MARKER } from "./ai-review";
35
36const AI_BUILD_LABEL = "ai:build";
37const DEMO_ACTIVITY_MARKER = "<!-- gluecron:demo-activity:v1 -->";
38
39export interface DemoActivitySeedResult {
40 added: {
41 issues: number;
42 labels: number;
43 issueLabels: number;
44 prs: number;
45 prComments: number;
46 auditRows: number;
47 };
48 errors: string[];
49}
50
51interface DemoRepo {
52 id: string;
53 name: string;
54 ownerId: string;
55}
56
57async function loadDemoRepos(): Promise<DemoRepo[]> {
58 try {
59 const [demo] = await db
60 .select({ id: users.id })
61 .from(users)
62 .where(eq(users.username, DEMO_USERNAME))
63 .limit(1);
64 if (!demo) return [];
65 const rows = await db
66 .select({
67 id: repositories.id,
68 name: repositories.name,
69 ownerId: repositories.ownerId,
70 })
71 .from(repositories)
72 .where(eq(repositories.ownerId, demo.id));
73 return rows;
74 } catch {
75 return [];
76 }
77}
78
79async function ensureLabel(
80 repoId: string,
81 name: string,
82 color: string
83): Promise<string | null> {
84 try {
85 const [existing] = await db
86 .select({ id: labels.id })
87 .from(labels)
88 .where(and(eq(labels.repositoryId, repoId), eq(labels.name, name)))
89 .limit(1);
90 if (existing) return existing.id;
91 const [inserted] = await db
92 .insert(labels)
93 .values({
94 repositoryId: repoId,
95 name,
96 color,
97 })
98 .returning({ id: labels.id });
99 return inserted?.id ?? null;
100 } catch {
101 return null;
102 }
103}
104
105async function findIssueByTitle(
106 repoId: string,
107 title: string
108): Promise<{ id: string } | null> {
109 try {
110 const [row] = await db
111 .select({ id: issues.id })
112 .from(issues)
113 .where(and(eq(issues.repositoryId, repoId), eq(issues.title, title)))
114 .limit(1);
115 return row ?? null;
116 } catch {
117 return null;
118 }
119}
120
121async function ensureIssueLabel(
122 issueId: string,
123 labelId: string
124): Promise<boolean> {
125 try {
126 const [existing] = await db
127 .select({ id: issueLabels.id })
128 .from(issueLabels)
129 .where(
130 and(
131 eq(issueLabels.issueId, issueId),
132 eq(issueLabels.labelId, labelId)
133 )
134 )
135 .limit(1);
136 if (existing) return false;
137 await db.insert(issueLabels).values({ issueId, labelId });
138 return true;
139 } catch {
140 return false;
141 }
142}
143
144async function findPrByTitle(
145 repoId: string,
146 title: string
147): Promise<{ id: string; number: number; state: string; mergedAt: Date | null } | null> {
148 try {
149 const [row] = await db
150 .select({
151 id: pullRequests.id,
152 number: pullRequests.number,
153 state: pullRequests.state,
154 mergedAt: pullRequests.mergedAt,
155 })
156 .from(pullRequests)
157 .where(
158 and(
159 eq(pullRequests.repositoryId, repoId),
160 eq(pullRequests.title, title)
161 )
162 )
163 .limit(1);
164 return row ?? null;
165 } catch {
166 return null;
167 }
168}
169
170/**
171 * Idempotently seed extra demo content + activity for the live `/demo` page.
172 * Never throws. Re-runnable.
173 */
174export async function ensureDemoActivity(): Promise<DemoActivitySeedResult> {
175 const result: DemoActivitySeedResult = {
176 added: {
177 issues: 0,
178 labels: 0,
179 issueLabels: 0,
180 prs: 0,
181 prComments: 0,
182 auditRows: 0,
183 },
184 errors: [],
185 };
186
187 const repos = await loadDemoRepos();
188 if (repos.length === 0) return result;
189
190 // Find the demo user id for `authorId` on issues/PRs/comments.
191 const demoUserId = repos[0].ownerId;
192
193 for (const repo of repos) {
194 // ── Issue 1: an `ai:build`-labelled issue per repo.
195 const aiIssueTitle = `[AI] Add /metrics endpoint to ${repo.name}`;
196 const aiIssueBody =
197 `Expose a Prometheus-style \`/metrics\` endpoint with request counts ` +
198 `and p95 latency. Auto-build candidate; the gluecron autopilot should ` +
199 `pick this up and open a draft PR.`;
200 try {
201 let existing = await findIssueByTitle(repo.id, aiIssueTitle);
202 if (!existing) {
203 const [inserted] = await db
204 .insert(issues)
205 .values({
206 repositoryId: repo.id,
207 authorId: demoUserId,
208 title: aiIssueTitle,
209 body: aiIssueBody,
210 state: "open",
211 })
212 .returning({ id: issues.id });
213 if (inserted) {
214 existing = { id: inserted.id };
215 result.added.issues += 1;
216 }
217 }
218 if (existing) {
219 const labelId = await ensureLabel(repo.id, AI_BUILD_LABEL, "#8c6dff");
220 if (labelId) {
221 // Track label count only on first insert per repo.
222 // ensureLabel doesn't expose "newly inserted" so this is an
223 // approximation; the counter is for observability only.
224 if (await ensureIssueLabel(existing.id, labelId)) {
225 result.added.issueLabels += 1;
226 }
227 }
228 }
229 } catch (err: any) {
230 result.errors.push(
231 `ai:build issue(${repo.name}): ${String(err?.message || err)}`
232 );
233 }
234
235 // ── Issue 2: a plain triage issue (one extra per repo so each repo has
236 // 3+ issues total once the locked seed's single issue is counted).
237 const triageTitle = `[triage] Investigate flaky tests in ${repo.name}`;
238 try {
239 const existing = await findIssueByTitle(repo.id, triageTitle);
240 if (!existing) {
241 await db.insert(issues).values({
242 repositoryId: repo.id,
243 authorId: demoUserId,
244 title: triageTitle,
245 body:
246 "Several CI runs have shown intermittent failures. Likely a " +
247 "timing-sensitive assertion. Worth a 15-minute investigation.",
248 state: "open",
249 });
250 result.added.issues += 1;
251 }
252 } catch (err: any) {
253 result.errors.push(
254 `triage issue(${repo.name}): ${String(err?.message || err)}`
255 );
256 }
257 }
258
259 // ── PR seeding + AI review + audit row are scoped to todo-api.
260 const todoApi = repos.find((r) => r.name === "todo-api");
261 if (todoApi) {
262 // Open PR (carries an AI review comment).
263 const openPrTitle = "feat: add /metrics endpoint";
264 try {
265 let openPr = await findPrByTitle(todoApi.id, openPrTitle);
266 if (!openPr) {
267 const [inserted] = await db
268 .insert(pullRequests)
269 .values({
270 repositoryId: todoApi.id,
271 authorId: demoUserId,
272 title: openPrTitle,
273 body:
274 "Adds a Prometheus-style `/metrics` endpoint. " +
275 DEMO_ACTIVITY_MARKER,
276 state: "open",
277 baseBranch: "main",
278 headBranch: "demo/metrics-endpoint",
279 })
280 .returning({
281 id: pullRequests.id,
282 number: pullRequests.number,
283 state: pullRequests.state,
284 mergedAt: pullRequests.mergedAt,
285 });
286 if (inserted) {
287 openPr = inserted;
288 result.added.prs += 1;
289 }
290 }
291
292 if (openPr) {
293 // Add an AI review comment with the canonical marker so the page's
294 // "AI reviews posted today" tile has content. Idempotent — we
295 // refuse to add a second AI review on this PR.
296 const [existingReview] = await db
297 .select({ id: prComments.id })
298 .from(prComments)
299 .where(
300 and(
301 eq(prComments.pullRequestId, openPr.id),
302 eq(prComments.isAiReview, true)
303 )
304 )
305 .limit(1);
306 if (!existingReview) {
307 await db.insert(prComments).values({
308 pullRequestId: openPr.id,
309 authorId: demoUserId,
310 isAiReview: true,
311 body:
312 `${AI_REVIEW_MARKER}\n## AI Code Review\n\n` +
313 "**Verdict: looks good.** The new `/metrics` handler is small, " +
314 "side-effect-free, and adds a Prometheus-format counter for " +
315 "request totals. No blocking findings.",
316 });
317 result.added.prComments += 1;
318 }
319 }
320 } catch (err: any) {
321 result.errors.push(
322 `open PR(todo-api): ${String(err?.message || err)}`
323 );
324 }
325
326 // Merged PR + matching audit row.
327 const mergedPrTitle = "chore: bump hono to ^4.6.0";
328 try {
329 let mergedPr = await findPrByTitle(todoApi.id, mergedPrTitle);
330 if (!mergedPr) {
331 const now = new Date();
332 const [inserted] = await db
333 .insert(pullRequests)
334 .values({
335 repositoryId: todoApi.id,
336 authorId: demoUserId,
337 title: mergedPrTitle,
338 body:
339 "Routine dep bump — Hono 4.6.0 ships small bugfixes. " +
340 DEMO_ACTIVITY_MARKER,
341 state: "merged",
342 baseBranch: "main",
343 headBranch: "demo/hono-4-6",
344 mergedAt: now,
345 mergedBy: demoUserId,
346 closedAt: now,
347 })
348 .returning({
349 id: pullRequests.id,
350 number: pullRequests.number,
351 state: pullRequests.state,
352 mergedAt: pullRequests.mergedAt,
353 });
354 if (inserted) {
355 mergedPr = inserted;
356 result.added.prs += 1;
357 }
358 }
359
360 if (mergedPr) {
361 // Check for an existing auto_merge.merged audit row on this PR.
362 const [existingAudit] = await db
363 .select({ id: auditLog.id })
364 .from(auditLog)
365 .where(
366 and(
367 eq(auditLog.action, "auto_merge.merged"),
368 eq(auditLog.repositoryId, todoApi.id),
369 eq(auditLog.targetId, mergedPr.id)
370 )
371 )
372 .limit(1);
373 if (!existingAudit) {
374 await db.insert(auditLog).values({
375 repositoryId: todoApi.id,
376 action: "auto_merge.merged",
377 targetType: "pull_request",
378 targetId: mergedPr.id,
379 metadata: JSON.stringify({
380 prNumber: mergedPr.number,
381 baseBranch: "main",
382 headBranch: "demo/hono-4-6",
383 source: "demo-activity-seed",
384 }),
385 });
386 result.added.auditRows += 1;
387 }
388 }
389 } catch (err: any) {
390 result.errors.push(
391 `merged PR(todo-api): ${String(err?.message || err)}`
392 );
393 }
394 }
395
396 return result;
397}
398
399/** Test-only re-exports. */
400export const __test = {
401 AI_BUILD_LABEL,
402 DEMO_ACTIVITY_MARKER,
403 loadDemoRepos,
404};
Addedsrc/lib/demo-activity.ts+510−0View fileUnifiedSplit
1/**
2 * Block L3 — Demo activity helpers.
3 *
4 * Read-only feed helpers used by the public `/demo` landing page and its
5 * companion `/api/v2/demo/*` JSON endpoints. All helpers are scoped to
6 * repositories owned by the seeded `demo` user (`DEMO_USERNAME` from
7 * `src/lib/demo-seed.ts`).
8 *
9 * Defensive on every public function:
10 * - Never throws. On any DB hiccup or unexpected shape, returns `[]` (or 0).
11 * - Results are cached in-process for 30 seconds via `LRUCache` from
12 * `src/lib/cache.ts`. Cache key includes the helper name and limit so
13 * two callers asking for different page sizes don't poison each other.
14 *
15 * Intentionally pure-where-possible: only DB reads, no writes, no spawns,
16 * no side effects.
17 */
18
19import { and, desc, eq, gte, inArray, like, sql } from "drizzle-orm";
20import { db } from "../db";
21import {
22 auditLog,
23 issueLabels,
24 issues,
25 labels,
26 prComments,
27 pullRequests,
28 repositories,
29 users,
30} from "../db/schema";
31import { LRUCache } from "./cache";
32import { DEMO_USERNAME } from "./demo-seed";
33import { AI_BUILD_MARKER } from "./ai-build-tasks";
34
35const DEFAULT_LIMIT = 5;
36const DEFAULT_FEED_LIMIT = 20;
37const DEFAULT_SINCE_HOURS = 24;
38
39const CACHE_TTL_MS = 30 * 1000;
40const demoActivityCache = new LRUCache<unknown>(64, CACHE_TTL_MS);
41
42export interface QueuedAiBuildIssue {
43 repo: string;
44 number: number;
45 title: string;
46 createdAt: Date;
47}
48
49export interface RecentAutoMerge {
50 repo: string;
51 number: number;
52 title: string;
53 mergedAt: Date;
54}
55
56export interface RecentAiReview {
57 repo: string;
58 prNumber: number;
59 commentSnippet: string;
60 createdAt: Date;
61}
62
63export type DemoActivityKind =
64 | "auto_merge.merged"
65 | "ai_build.dispatched"
66 | "ai_review.posted";
67
68export interface DemoActivityEntry {
69 kind: DemoActivityKind;
70 repo: string;
71 ref: { type: "issue" | "pr"; number: number };
72 at: Date;
73}
74
75interface DemoRepoRow {
76 id: string;
77 name: string;
78}
79
80/**
81 * Look up the demo user + their repos. Returns `null` on any DB failure or
82 * if the demo user doesn't exist yet (boot-time race or no seed run).
83 */
84async function loadDemoRepos(): Promise<{
85 userId: string;
86 repos: DemoRepoRow[];
87} | null> {
88 try {
89 const [demo] = await db
90 .select({ id: users.id })
91 .from(users)
92 .where(eq(users.username, DEMO_USERNAME))
93 .limit(1);
94 if (!demo) return null;
95
96 const repos = await db
97 .select({ id: repositories.id, name: repositories.name })
98 .from(repositories)
99 .where(eq(repositories.ownerId, demo.id));
100
101 return { userId: demo.id, repos };
102 } catch {
103 return null;
104 }
105}
106
107function cacheKey(name: string, ...parts: (string | number)[]): string {
108 return [name, ...parts.map((p) => String(p))].join("|");
109}
110
111async function memo<T>(
112 key: string,
113 factory: () => Promise<T>,
114 fallback: T
115): Promise<T> {
116 const existing = demoActivityCache.get(key) as T | undefined;
117 if (existing !== undefined) return existing;
118 try {
119 const value = await factory();
120 demoActivityCache.set(key, value as unknown);
121 return value;
122 } catch {
123 return fallback;
124 }
125}
126
127/**
128 * Open issues across demo repos labelled `ai:build` that haven't yet been
129 * dispatched (no comment carrying the `AI_BUILD_MARKER`). Newest first.
130 */
131export async function listQueuedAiBuildIssues(
132 limit: number = DEFAULT_LIMIT
133): Promise<QueuedAiBuildIssue[]> {
134 const lim = Math.max(1, Math.min(50, limit | 0 || DEFAULT_LIMIT));
135 return memo(
136 cacheKey("queued", lim),
137 async () => {
138 const ctx = await loadDemoRepos();
139 if (!ctx || ctx.repos.length === 0) return [];
140 const repoIds = ctx.repos.map((r) => r.id);
141 const nameById = new Map(ctx.repos.map((r) => [r.id, r.name] as const));
142
143 // Find issues labelled "ai:build" (case-insensitive). The label is
144 // per-repo, so we filter via the join across the demo repo set.
145 const rows = await db
146 .select({
147 id: issues.id,
148 repositoryId: issues.repositoryId,
149 number: issues.number,
150 title: issues.title,
151 body: issues.body,
152 createdAt: issues.createdAt,
153 })
154 .from(issues)
155 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
156 .innerJoin(labels, eq(labels.id, issueLabels.labelId))
157 .where(
158 and(
159 inArray(issues.repositoryId, repoIds),
160 eq(issues.state, "open"),
161 sql`lower(${labels.name}) = 'ai:build'`
162 )
163 )
164 .orderBy(desc(issues.createdAt))
165 .limit(lim * 4); // over-fetch to allow for marker filtering
166
167 // Filter out issues whose body already carries the marker (the
168 // marker can also be in a comment but the conservative "body or any
169 // comment" check needs another roundtrip; doing the body check here
170 // mirrors the dispatch sentinel and is sufficient for the demo).
171 const filtered: QueuedAiBuildIssue[] = [];
172 for (const r of rows) {
173 if (r.body && r.body.includes(AI_BUILD_MARKER)) continue;
174 const repo = nameById.get(r.repositoryId);
175 if (!repo) continue;
176 filtered.push({
177 repo,
178 number: r.number,
179 title: r.title,
180 createdAt: r.createdAt,
181 });
182 if (filtered.length >= lim) break;
183 }
184 return filtered;
185 },
186 []
187 );
188}
189
190/**
191 * Recent `auto_merge.merged` audit rows scoped to demo repos. Pulls the PR
192 * title via the targetId (which is the pull_request UUID).
193 */
194export async function listRecentAutoMerges(
195 limit: number = DEFAULT_LIMIT,
196 sinceHours: number = DEFAULT_SINCE_HOURS
197): Promise<RecentAutoMerge[]> {
198 const lim = Math.max(1, Math.min(50, limit | 0 || DEFAULT_LIMIT));
199 const hrs = Math.max(1, Math.min(720, sinceHours | 0 || DEFAULT_SINCE_HOURS));
200 return memo(
201 cacheKey("merges", lim, hrs),
202 async () => {
203 const ctx = await loadDemoRepos();
204 if (!ctx || ctx.repos.length === 0) return [];
205 const repoIds = ctx.repos.map((r) => r.id);
206 const nameById = new Map(ctx.repos.map((r) => [r.id, r.name] as const));
207 const since = new Date(Date.now() - hrs * 60 * 60 * 1000);
208
209 const rows = await db
210 .select({
211 repositoryId: auditLog.repositoryId,
212 targetId: auditLog.targetId,
213 createdAt: auditLog.createdAt,
214 })
215 .from(auditLog)
216 .where(
217 and(
218 eq(auditLog.action, "auto_merge.merged"),
219 inArray(auditLog.repositoryId, repoIds),
220 gte(auditLog.createdAt, since)
221 )
222 )
223 .orderBy(desc(auditLog.createdAt))
224 .limit(lim);
225
226 if (rows.length === 0) return [];
227
228 const prIds = rows
229 .map((r) => r.targetId)
230 .filter((id): id is string => !!id);
231 const prRows = prIds.length
232 ? await db
233 .select({
234 id: pullRequests.id,
235 number: pullRequests.number,
236 title: pullRequests.title,
237 })
238 .from(pullRequests)
239 .where(inArray(pullRequests.id, prIds))
240 : [];
241 const prById = new Map(prRows.map((p) => [p.id, p] as const));
242
243 const result: RecentAutoMerge[] = [];
244 for (const r of rows) {
245 const repo = r.repositoryId ? nameById.get(r.repositoryId) : undefined;
246 const pr = r.targetId ? prById.get(r.targetId) : undefined;
247 if (!repo || !pr) continue;
248 result.push({
249 repo,
250 number: pr.number,
251 title: pr.title,
252 mergedAt: r.createdAt,
253 });
254 }
255 return result;
256 },
257 []
258 );
259}
260
261/**
262 * Recent AI-review PR comments (is_ai_review=true) on demo repos. Returns
263 * a short snippet of the comment body for the tile.
264 */
265export async function listRecentAiReviews(
266 limit: number = DEFAULT_LIMIT,
267 sinceHours: number = DEFAULT_SINCE_HOURS
268): Promise<RecentAiReview[]> {
269 const lim = Math.max(1, Math.min(50, limit | 0 || DEFAULT_LIMIT));
270 const hrs = Math.max(1, Math.min(720, sinceHours | 0 || DEFAULT_SINCE_HOURS));
271 return memo(
272 cacheKey("reviews", lim, hrs),
273 async () => {
274 const ctx = await loadDemoRepos();
275 if (!ctx || ctx.repos.length === 0) return [];
276 const repoIds = ctx.repos.map((r) => r.id);
277 const nameById = new Map(ctx.repos.map((r) => [r.id, r.name] as const));
278 const since = new Date(Date.now() - hrs * 60 * 60 * 1000);
279
280 const rows = await db
281 .select({
282 repositoryId: pullRequests.repositoryId,
283 prNumber: pullRequests.number,
284 body: prComments.body,
285 createdAt: prComments.createdAt,
286 })
287 .from(prComments)
288 .innerJoin(
289 pullRequests,
290 eq(pullRequests.id, prComments.pullRequestId)
291 )
292 .where(
293 and(
294 eq(prComments.isAiReview, true),
295 inArray(pullRequests.repositoryId, repoIds),
296 gte(prComments.createdAt, since)
297 )
298 )
299 .orderBy(desc(prComments.createdAt))
300 .limit(lim);
301
302 const result: RecentAiReview[] = [];
303 for (const r of rows) {
304 const repo = nameById.get(r.repositoryId);
305 if (!repo) continue;
306 const stripped = (r.body ?? "")
307 .replace(/<!--[\s\S]*?-->/g, "")
308 .replace(/\s+/g, " ")
309 .trim();
310 const snippet =
311 stripped.length > 120
312 ? stripped.slice(0, 117).trimEnd() + "..."
313 : stripped;
314 result.push({
315 repo,
316 prNumber: r.prNumber,
317 commentSnippet: snippet,
318 createdAt: r.createdAt,
319 });
320 }
321 return result;
322 },
323 []
324 );
325}
326
327/**
328 * Count AI reviews posted in the last `sinceHours` hours across demo repos.
329 * Pure summary — used by the small counter tile.
330 */
331export async function countAiReviewsSince(
332 sinceHours: number = DEFAULT_SINCE_HOURS
333): Promise<number> {
334 const hrs = Math.max(1, Math.min(720, sinceHours | 0 || DEFAULT_SINCE_HOURS));
335 return memo(
336 cacheKey("review-count", hrs),
337 async () => {
338 const ctx = await loadDemoRepos();
339 if (!ctx || ctx.repos.length === 0) return 0;
340 const repoIds = ctx.repos.map((r) => r.id);
341 const since = new Date(Date.now() - hrs * 60 * 60 * 1000);
342
343 const rows = await db
344 .select({ n: sql<number>`count(*)::int` })
345 .from(prComments)
346 .innerJoin(
347 pullRequests,
348 eq(pullRequests.id, prComments.pullRequestId)
349 )
350 .where(
351 and(
352 eq(prComments.isAiReview, true),
353 inArray(pullRequests.repositoryId, repoIds),
354 gte(prComments.createdAt, since)
355 )
356 );
357 return Number(rows[0]?.n ?? 0);
358 },
359 0
360 );
361}
362
363/**
364 * Combined activity feed: auto_merge.merged + ai_build.dispatched audit
365 * rows interleaved with recent AI-review PR comments (synthesised as
366 * `ai_review.posted` entries since there's no dedicated audit action).
367 * Most-recent first, capped at `limit`.
368 */
369export async function listDemoActivityFeed(
370 limit: number = DEFAULT_FEED_LIMIT
371): Promise<DemoActivityEntry[]> {
372 const lim = Math.max(1, Math.min(100, limit | 0 || DEFAULT_FEED_LIMIT));
373 return memo(
374 cacheKey("feed", lim),
375 async () => {
376 const ctx = await loadDemoRepos();
377 if (!ctx || ctx.repos.length === 0) return [];
378 const repoIds = ctx.repos.map((r) => r.id);
379 const nameById = new Map(ctx.repos.map((r) => [r.id, r.name] as const));
380
381 // Audit rows: auto_merge.merged + ai_build.dispatched.
382 const auditRows = await db
383 .select({
384 action: auditLog.action,
385 repositoryId: auditLog.repositoryId,
386 targetType: auditLog.targetType,
387 targetId: auditLog.targetId,
388 createdAt: auditLog.createdAt,
389 })
390 .from(auditLog)
391 .where(
392 and(
393 inArray(auditLog.action, [
394 "auto_merge.merged",
395 "ai_build.dispatched",
396 ]),
397 inArray(auditLog.repositoryId, repoIds)
398 )
399 )
400 .orderBy(desc(auditLog.createdAt))
401 .limit(lim);
402
403 // PR comments flagged as AI reviews — used to synthesise
404 // `ai_review.posted` entries.
405 const aiReviewRows = await db
406 .select({
407 repositoryId: pullRequests.repositoryId,
408 prNumber: pullRequests.number,
409 createdAt: prComments.createdAt,
410 })
411 .from(prComments)
412 .innerJoin(
413 pullRequests,
414 eq(pullRequests.id, prComments.pullRequestId)
415 )
416 .where(
417 and(
418 eq(prComments.isAiReview, true),
419 inArray(pullRequests.repositoryId, repoIds)
420 )
421 )
422 .orderBy(desc(prComments.createdAt))
423 .limit(lim);
424
425 const entries: DemoActivityEntry[] = [];
426
427 // Resolve PR/issue number from targetId for auto_merge.merged /
428 // ai_build.dispatched rows. Cheap: collect ids, hit the table once.
429 const prTargetIds = auditRows
430 .filter((r) => r.action === "auto_merge.merged" && !!r.targetId)
431 .map((r) => r.targetId as string);
432 const issueTargetIds = auditRows
433 .filter((r) => r.action === "ai_build.dispatched" && !!r.targetId)
434 .map((r) => r.targetId as string);
435
436 const prById = prTargetIds.length
437 ? new Map(
438 (
439 await db
440 .select({
441 id: pullRequests.id,
442 number: pullRequests.number,
443 })
444 .from(pullRequests)
445 .where(inArray(pullRequests.id, prTargetIds))
446 ).map((p) => [p.id, p.number] as const)
447 )
448 : new Map<string, number>();
449
450 const issueById = issueTargetIds.length
451 ? new Map(
452 (
453 await db
454 .select({
455 id: issues.id,
456 number: issues.number,
457 })
458 .from(issues)
459 .where(inArray(issues.id, issueTargetIds))
460 ).map((i) => [i.id, i.number] as const)
461 )
462 : new Map<string, number>();
463
464 for (const r of auditRows) {
465 const repo = r.repositoryId ? nameById.get(r.repositoryId) : undefined;
466 if (!repo) continue;
467 if (r.action === "auto_merge.merged") {
468 const n = r.targetId ? prById.get(r.targetId) : undefined;
469 if (n === undefined) continue;
470 entries.push({
471 kind: "auto_merge.merged",
472 repo,
473 ref: { type: "pr", number: n },
474 at: r.createdAt,
475 });
476 } else if (r.action === "ai_build.dispatched") {
477 const n = r.targetId ? issueById.get(r.targetId) : undefined;
478 if (n === undefined) continue;
479 entries.push({
480 kind: "ai_build.dispatched",
481 repo,
482 ref: { type: "issue", number: n },
483 at: r.createdAt,
484 });
485 }
486 }
487
488 for (const r of aiReviewRows) {
489 const repo = nameById.get(r.repositoryId);
490 if (!repo) continue;
491 entries.push({
492 kind: "ai_review.posted",
493 repo,
494 ref: { type: "pr", number: r.prNumber },
495 at: r.createdAt,
496 });
497 }
498
499 entries.sort((a, b) => b.at.getTime() - a.at.getTime());
500 return entries.slice(0, lim);
501 },
502 []
503 );
504}
505
506/** Test-only export — exposed for unit tests, not part of the public surface. */
507export const __test = {
508 loadDemoRepos,
509 demoActivityCache,
510};
Addedsrc/lib/public-stats.ts+363−0View fileUnifiedSplit
1/**
2 * Block L4 — Public stats counters.
3 *
4 * Site-wide, PUBLIC-ONLY counters that power the marketing landing-page
5 * social-proof tiles ("X PRs auto-merged this week", "Y deploys shipped
6 * overnight", etc.).
7 *
8 * Hard contract — PUBLIC repos only.
9 * Every counter that touches per-repo data joins through `repositories`
10 * and filters `is_private = false AND is_archived = false`. Private
11 * repos must NEVER leak into these numbers. The whole point of the
12 * widget is honest, public social proof.
13 *
14 * Never throws. On any DB error every counter degrades to zero and the
15 * report is returned with `asOf = now`. The landing page would rather
16 * render zeros than 500.
17 *
18 * Caching. The marketing page is a hot path; recomputing eight queries
19 * on every render would hammer the DB. Results are memoised in a tiny
20 * `LRUCache` with a 5-minute TTL (`publicStatsCache`).
21 *
22 * DI seam (`PublicStatsDeps`) mirrors the L9 pattern so tests inject
23 * deterministic counters without spinning up Postgres.
24 */
25
26import { and, eq, gte, sql } from "drizzle-orm";
27import { db } from "../db";
28import {
29 auditLog,
30 deployments,
31 gateRuns,
32 issues,
33 prComments,
34 pullRequests,
35 repositories,
36 users,
37} from "../db/schema";
38import { LRUCache } from "./cache";
39import { computeHoursSaved } from "./ai-hours-saved";
40
41// ───────────────────────────────────────────────────────────────────
42// Types
43// ───────────────────────────────────────────────────────────────────
44
45export type PublicStats = {
46 // Lifetime counters
47 totalPublicRepos: number;
48 totalUsers: number;
49 totalPublicPullRequests: number;
50 totalPublicIssues: number;
51 // Trailing-7-days "AI did this" highlights
52 weeklyPrsAutoMerged: number;
53 weeklyIssuesBuiltByAi: number;
54 weeklyAiReviewsPosted: number;
55 weeklySecretsAutoFixed: number;
56 weeklyDeploysShipped: number;
57 // Derived
58 weeklyHoursSaved: number;
59 asOf: Date;
60};
61
62/** Zero-valued stats — used as the fallback on any DB error. */
63export function emptyPublicStats(asOf: Date): PublicStats {
64 return {
65 totalPublicRepos: 0,
66 totalUsers: 0,
67 totalPublicPullRequests: 0,
68 totalPublicIssues: 0,
69 weeklyPrsAutoMerged: 0,
70 weeklyIssuesBuiltByAi: 0,
71 weeklyAiReviewsPosted: 0,
72 weeklySecretsAutoFixed: 0,
73 weeklyDeploysShipped: 0,
74 weeklyHoursSaved: 0,
75 asOf,
76 };
77}
78
79// ───────────────────────────────────────────────────────────────────
80// Audit-log action constants. Importing from `auto-merge.ts` /
81// `ai-build-tasks.ts` would risk a circular-import in some test
82// setups, so the literals are duplicated here. Mirrors the same
83// trade-off `ai-hours-saved.ts` made.
84// ───────────────────────────────────────────────────────────────────
85
86export const PUBLIC_STATS_ACTIONS = {
87 AUTO_MERGE_MERGED: "auto_merge.merged",
88 AI_BUILD_DISPATCHED: "ai_build.dispatched",
89} as const;
90
91const SECRET_GATE_NAME_PATTERNS = ["%secret%", "%Secret%"];
92
93// ───────────────────────────────────────────────────────────────────
94// DI seam
95// ───────────────────────────────────────────────────────────────────
96
97export interface PublicStatsDeps {
98 countTotalPublicRepos: () => Promise<number>;
99 countTotalUsers: () => Promise<number>;
100 countTotalPublicPullRequests: () => Promise<number>;
101 countTotalPublicIssues: () => Promise<number>;
102 countWeeklyPrsAutoMerged: (since: Date) => Promise<number>;
103 countWeeklyIssuesBuiltByAi: (since: Date) => Promise<number>;
104 countWeeklyAiReviewsPosted: (since: Date) => Promise<number>;
105 countWeeklySecretsAutoFixed: (since: Date) => Promise<number>;
106 countWeeklyDeploysShipped: (since: Date) => Promise<number>;
107}
108
109// ───────────────────────────────────────────────────────────────────
110// Default DB-backed implementations.
111//
112// Every per-repo counter JOINs through `repositories` with a hard
113// filter on `is_private = false AND is_archived = false`. That join
114// is the privacy boundary — under no circumstance should it be removed.
115// ───────────────────────────────────────────────────────────────────
116
117async function defaultCountTotalPublicRepos(): Promise<number> {
118 const rows = await db
119 .select({ n: sql<number>`count(*)::int` })
120 .from(repositories)
121 .where(
122 and(
123 eq(repositories.isPrivate, false),
124 eq(repositories.isArchived, false)
125 )
126 );
127 return Number(rows[0]?.n ?? 0);
128}
129
130async function defaultCountTotalUsers(): Promise<number> {
131 const rows = await db
132 .select({ n: sql<number>`count(*)::int` })
133 .from(users);
134 return Number(rows[0]?.n ?? 0);
135}
136
137async function defaultCountTotalPublicPullRequests(): Promise<number> {
138 const rows = await db
139 .select({ n: sql<number>`count(*)::int` })
140 .from(pullRequests)
141 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
142 .where(eq(repositories.isPrivate, false));
143 return Number(rows[0]?.n ?? 0);
144}
145
146async function defaultCountTotalPublicIssues(): Promise<number> {
147 const rows = await db
148 .select({ n: sql<number>`count(*)::int` })
149 .from(issues)
150 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
151 .where(eq(repositories.isPrivate, false));
152 return Number(rows[0]?.n ?? 0);
153}
154
155async function defaultCountWeeklyPrsAutoMerged(since: Date): Promise<number> {
156 const rows = await db
157 .select({ n: sql<number>`count(*)::int` })
158 .from(auditLog)
159 .innerJoin(repositories, eq(auditLog.repositoryId, repositories.id))
160 .where(
161 and(
162 eq(auditLog.action, PUBLIC_STATS_ACTIONS.AUTO_MERGE_MERGED),
163 eq(repositories.isPrivate, false),
164 gte(auditLog.createdAt, since)
165 )
166 );
167 return Number(rows[0]?.n ?? 0);
168}
169
170async function defaultCountWeeklyIssuesBuiltByAi(since: Date): Promise<number> {
171 const rows = await db
172 .select({ n: sql<number>`count(*)::int` })
173 .from(auditLog)
174 .innerJoin(repositories, eq(auditLog.repositoryId, repositories.id))
175 .where(
176 and(
177 eq(auditLog.action, PUBLIC_STATS_ACTIONS.AI_BUILD_DISPATCHED),
178 eq(repositories.isPrivate, false),
179 gte(auditLog.createdAt, since)
180 )
181 );
182 return Number(rows[0]?.n ?? 0);
183}
184
185async function defaultCountWeeklyAiReviewsPosted(since: Date): Promise<number> {
186 const rows = await db
187 .select({ n: sql<number>`count(*)::int` })
188 .from(prComments)
189 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
190 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
191 .where(
192 and(
193 eq(prComments.isAiReview, true),
194 eq(repositories.isPrivate, false),
195 gte(prComments.createdAt, since)
196 )
197 );
198 return Number(rows[0]?.n ?? 0);
199}
200
201async function defaultCountWeeklySecretsAutoFixed(since: Date): Promise<number> {
202 // `gate_name LIKE '%secret%'` — case-insensitive via ILIKE so we catch
203 // "Secret scan", "Secret Scan", "secret-scan", etc. Restrict to
204 // `status = 'repaired'` so we only count auto-repair successes.
205 const rows = await db
206 .select({ n: sql<number>`count(*)::int` })
207 .from(gateRuns)
208 .innerJoin(repositories, eq(gateRuns.repositoryId, repositories.id))
209 .where(
210 and(
211 eq(gateRuns.status, "repaired"),
212 sql`${gateRuns.gateName} ILIKE ${SECRET_GATE_NAME_PATTERNS[0]}`,
213 eq(repositories.isPrivate, false),
214 gte(gateRuns.createdAt, since)
215 )
216 );
217 return Number(rows[0]?.n ?? 0);
218}
219
220async function defaultCountWeeklyDeploysShipped(since: Date): Promise<number> {
221 // Spec says `status='succeeded'`; the schema currently emits `success`
222 // (see `src/db/schema.ts` deployments.status enum comment + every call
223 // site in `src/lib/deploy-pipeline.ts`). Accept BOTH so the counter
224 // remains correct whether a future migration normalises the spelling.
225 const rows = await db
226 .select({ n: sql<number>`count(*)::int` })
227 .from(deployments)
228 .innerJoin(repositories, eq(deployments.repositoryId, repositories.id))
229 .where(
230 and(
231 sql`${deployments.status} IN ('success','succeeded')`,
232 eq(repositories.isPrivate, false),
233 gte(deployments.createdAt, since)
234 )
235 );
236 return Number(rows[0]?.n ?? 0);
237}
238
239const DEFAULT_DEPS: PublicStatsDeps = {
240 countTotalPublicRepos: defaultCountTotalPublicRepos,
241 countTotalUsers: defaultCountTotalUsers,
242 countTotalPublicPullRequests: defaultCountTotalPublicPullRequests,
243 countTotalPublicIssues: defaultCountTotalPublicIssues,
244 countWeeklyPrsAutoMerged: defaultCountWeeklyPrsAutoMerged,
245 countWeeklyIssuesBuiltByAi: defaultCountWeeklyIssuesBuiltByAi,
246 countWeeklyAiReviewsPosted: defaultCountWeeklyAiReviewsPosted,
247 countWeeklySecretsAutoFixed: defaultCountWeeklySecretsAutoFixed,
248 countWeeklyDeploysShipped: defaultCountWeeklyDeploysShipped,
249};
250
251// ───────────────────────────────────────────────────────────────────
252// Caching
253// ───────────────────────────────────────────────────────────────────
254
255const CACHE_TTL_MS = 5 * 60 * 1000;
256
257/** In-memory cache for the computed PublicStats. Single key: "public". */
258export const publicStatsCache = new LRUCache<PublicStats>(4, CACHE_TTL_MS);
259
260/** Clear the cache. Test-only. */
261export function __resetPublicStatsCache(): void {
262 publicStatsCache.clear();
263}
264
265// ───────────────────────────────────────────────────────────────────
266// Public orchestrator
267// ───────────────────────────────────────────────────────────────────
268
269export interface ComputePublicStatsOpts {
270 now?: Date;
271 deps?: PublicStatsDeps;
272 /** When true, skip the in-memory cache layer (test seam). */
273 noCache?: boolean;
274}
275
276/**
277 * Compute the site-wide public stats report.
278 *
279 * Trailing window for the "weekly" counters is 7 days. The lifetime
280 * counters are unbounded. Never throws — DB errors degrade to
281 * `emptyPublicStats(now)`.
282 */
283export async function computePublicStats(
284 opts: ComputePublicStatsOpts = {}
285): Promise<PublicStats> {
286 const now = opts.now ?? new Date();
287 const deps = opts.deps ?? DEFAULT_DEPS;
288 const useCache = !opts.noCache && !opts.deps;
289
290 if (useCache) {
291 const hit = publicStatsCache.get("public");
292 if (hit) return hit;
293 }
294
295 const since = new Date(now.getTime() - 7 * 24 * 3600 * 1000);
296
297 try {
298 const [
299 totalPublicRepos,
300 totalUsers,
301 totalPublicPullRequests,
302 totalPublicIssues,
303 weeklyPrsAutoMerged,
304 weeklyIssuesBuiltByAi,
305 weeklyAiReviewsPosted,
306 weeklySecretsAutoFixed,
307 weeklyDeploysShipped,
308 ] = await Promise.all([
309 deps.countTotalPublicRepos(),
310 deps.countTotalUsers(),
311 deps.countTotalPublicPullRequests(),
312 deps.countTotalPublicIssues(),
313 deps.countWeeklyPrsAutoMerged(since),
314 deps.countWeeklyIssuesBuiltByAi(since),
315 deps.countWeeklyAiReviewsPosted(since),
316 deps.countWeeklySecretsAutoFixed(since),
317 deps.countWeeklyDeploysShipped(since),
318 ]);
319
320 // Reuse the L9 hours-saved formula so the public number stays in
321 // lock-step with the per-user dashboard widget. The triage / commit-
322 // message / non-secret-gate buckets aren't surfaced site-wide, so
323 // they're zeroed here — the formula degrades gracefully.
324 const weeklyHoursSaved = computeHoursSaved({
325 prsAutoMerged: weeklyPrsAutoMerged,
326 issuesBuiltByAi: weeklyIssuesBuiltByAi,
327 aiReviewsPosted: weeklyAiReviewsPosted,
328 aiTriagesPosted: 0,
329 aiCommitMsgs: 0,
330 secretsAutoRepaired: weeklySecretsAutoFixed,
331 gateAutoRepairs: 0,
332 });
333
334 const stats: PublicStats = {
335 totalPublicRepos,
336 totalUsers,
337 totalPublicPullRequests,
338 totalPublicIssues,
339 weeklyPrsAutoMerged,
340 weeklyIssuesBuiltByAi,
341 weeklyAiReviewsPosted,
342 weeklySecretsAutoFixed,
343 weeklyDeploysShipped,
344 weeklyHoursSaved,
345 asOf: now,
346 };
347
348 if (useCache) publicStatsCache.set("public", stats);
349 return stats;
350 } catch (err) {
351 console.error("[public-stats] degraded to zeros:", err);
352 return emptyPublicStats(now);
353 }
354}
355
356// ───────────────────────────────────────────────────────────────────
357// Test-only seam
358// ───────────────────────────────────────────────────────────────────
359
360export const __test = {
361 DEFAULT_DEPS,
362 CACHE_TTL_MS,
363};
Addedsrc/routes/demo.tsx+409−0View fileUnifiedSplit
1/**
2 * Block L3 — public `/demo` landing page + companion JSON endpoints.
3 *
4 * Anonymous-friendly. Demonstrates Gluecron's AI features in real time by
5 * surfacing live counts off the audit log + `pr_comments` table for the
6 * seeded demo repos.
7 *
8 * Routes mounted from `src/app.tsx`. Must come BEFORE `routes/admin.tsx`
9 * in mount order so this handler wins over the legacy `/demo` redirect.
10 *
11 * GET /demo → SSR landing page (graceful no-JS,
12 * polls JSON endpoints every 30s
13 * when JS is available)
14 * GET /api/v2/demo/activity → combined activity feed JSON
15 * GET /api/v2/demo/queued → queued ai:build issues JSON
16 * GET /api/v2/demo/merges → recent auto-merges JSON
17 * GET /api/v2/demo/reviews → recent AI reviews JSON
18 *
19 * All JSON endpoints serve `Cache-Control: public, max-age=30` so the demo
20 * page is cheap to refresh and external dashboards can embed safely.
21 */
22
23import { Hono } from "hono";
24import { Layout } from "../views/layout";
25import { softAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { DEMO_USERNAME } from "../lib/demo-seed";
28import {
29 countAiReviewsSince,
30 listDemoActivityFeed,
31 listQueuedAiBuildIssues,
32 listRecentAiReviews,
33 listRecentAutoMerges,
34 type DemoActivityEntry,
35} from "../lib/demo-activity";
36
37const app = new Hono<AuthEnv>();
38
39const POLL_INTERVAL_MS = 30_000;
40
41function jsonCacheHeaders(): Record<string, string> {
42 return {
43 "Content-Type": "application/json; charset=utf-8",
44 "Cache-Control": "public, max-age=30",
45 };
46}
47
48// ───────────────────────────────────────────────────────────────────
49// JSON endpoints — cheap, public, cacheable.
50// ───────────────────────────────────────────────────────────────────
51
52app.get("/api/v2/demo/activity", async (c) => {
53 const feed = await listDemoActivityFeed(20);
54 return new Response(
55 JSON.stringify({
56 entries: feed.map((e) => ({
57 kind: e.kind,
58 repo: e.repo,
59 ref: e.ref,
60 at: e.at.toISOString(),
61 })),
62 }),
63 { status: 200, headers: jsonCacheHeaders() }
64 );
65});
66
67app.get("/api/v2/demo/queued", async (c) => {
68 const items = await listQueuedAiBuildIssues(5);
69 return new Response(
70 JSON.stringify({
71 items: items.map((i) => ({
72 repo: i.repo,
73 number: i.number,
74 title: i.title,
75 createdAt: i.createdAt.toISOString(),
76 })),
77 }),
78 { status: 200, headers: jsonCacheHeaders() }
79 );
80});
81
82app.get("/api/v2/demo/merges", async (c) => {
83 const items = await listRecentAutoMerges(5, 24);
84 return new Response(
85 JSON.stringify({
86 items: items.map((m) => ({
87 repo: m.repo,
88 number: m.number,
89 title: m.title,
90 mergedAt: m.mergedAt.toISOString(),
91 })),
92 }),
93 { status: 200, headers: jsonCacheHeaders() }
94 );
95});
96
97app.get("/api/v2/demo/reviews", async (c) => {
98 const [items, count] = await Promise.all([
99 listRecentAiReviews(5, 24),
100 countAiReviewsSince(24),
101 ]);
102 return new Response(
103 JSON.stringify({
104 count,
105 items: items.map((r) => ({
106 repo: r.repo,
107 prNumber: r.prNumber,
108 commentSnippet: r.commentSnippet,
109 createdAt: r.createdAt.toISOString(),
110 })),
111 }),
112 { status: 200, headers: jsonCacheHeaders() }
113 );
114});
115
116// ───────────────────────────────────────────────────────────────────
117// HTML landing page.
118// ───────────────────────────────────────────────────────────────────
119
120function escapeHtml(s: string): string {
121 return String(s).replace(/[&<>"']/g, (c) =>
122 c === "&"
123 ? "&amp;"
124 : c === "<"
125 ? "&lt;"
126 : c === ">"
127 ? "&gt;"
128 : c === '"'
129 ? "&quot;"
130 : "&#39;"
131 );
132}
133
134function relativeTime(d: Date): string {
135 const ms = Date.now() - d.getTime();
136 const s = Math.max(0, Math.floor(ms / 1000));
137 if (s < 60) return `${s}s ago`;
138 const m = Math.floor(s / 60);
139 if (m < 60) return `${m}m ago`;
140 const h = Math.floor(m / 60);
141 if (h < 48) return `${h}h ago`;
142 const days = Math.floor(h / 24);
143 return `${days}d ago`;
144}
145
146function activityLabel(kind: DemoActivityEntry["kind"]): string {
147 switch (kind) {
148 case "auto_merge.merged":
149 return "auto-merged";
150 case "ai_build.dispatched":
151 return "AI-build dispatched";
152 case "ai_review.posted":
153 return "AI review posted";
154 }
155}
156
157app.get("/demo", softAuth, async (c) => {
158 const user = c.get("user") ?? null;
159
160 // Server-side render the initial snapshot. The JS poller refreshes it
161 // every 30s but the no-JS experience still works.
162 const [queued, merges, reviewsCountAndList, feed] = await Promise.all([
163 listQueuedAiBuildIssues(5),
164 listRecentAutoMerges(5, 24),
165 Promise.all([listRecentAiReviews(5, 24), countAiReviewsSince(24)]),
166 listDemoActivityFeed(20),
167 ]);
168 const [reviews, reviewCount] = reviewsCountAndList;
169
170 const demoUserUrl = `/${DEMO_USERNAME}`;
171 const demoRepos: { name: string; tagline: string }[] = [
172 { name: "hello-python", tagline: "Tiny Python app — labels, issues, gates." },
173 { name: "todo-api", tagline: "Hono todo API — PRs, AI review, auto-merge." },
174 { name: "design-docs", tagline: "ADRs + architecture docs." },
175 ];
176
177 // Poller — refreshes the four tiles every 30s. Plain vanilla JS, no
178 // framework. Mirrors the same pattern as `src/lib/sse-client.ts`.
179 const pollerScript = `
180(function(){try{
181 var INTERVAL=${POLL_INTERVAL_MS};
182 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
183 function rel(iso){try{var ms=Date.now()-new Date(iso).getTime();var s=Math.max(0,Math.floor(ms/1000));if(s<60)return s+'s ago';var m=Math.floor(s/60);if(m<60)return m+'m ago';var h=Math.floor(m/60);if(h<48)return h+'h ago';return Math.floor(h/24)+'d ago';}catch(e){return '';}}
184 function pollQueued(){fetch('/api/v2/demo/queued').then(function(r){return r.json();}).then(function(d){
185 var el=document.getElementById('tile-queued-list');if(!el)return;
186 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No queued AI builds — quiet right now.</li>';return;}
187 el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/issues/'+i.number+'">#'+i.number+' '+esc(i.title)+'</a> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.createdAt)+'</span></li>';}).join('');
188 }).catch(function(){});}
189 function pollMerges(){fetch('/api/v2/demo/merges').then(function(r){return r.json();}).then(function(d){
190 var el=document.getElementById('tile-merges-list');if(!el)return;
191 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No auto-merges in the last 24h.</li>';return;}
192 el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/pulls/'+i.number+'">#'+i.number+' '+esc(i.title)+'</a> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.mergedAt)+'</span></li>';}).join('');
193 }).catch(function(){});}
194 function pollReviews(){fetch('/api/v2/demo/reviews').then(function(r){return r.json();}).then(function(d){
195 var c=document.getElementById('tile-reviews-count');if(c)c.textContent=String(d.count||0);
196 var el=document.getElementById('tile-reviews-list');if(!el)return;
197 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No AI reviews in the last 24h.</li>';return;}
198 el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/pulls/'+i.prNumber+'">#'+i.prNumber+'</a> <span class="demo-snippet">'+esc(i.commentSnippet)+'</span> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.createdAt)+'</span></li>';}).join('');
199 }).catch(function(){});}
200 function pollFeed(){fetch('/api/v2/demo/activity').then(function(r){return r.json();}).then(function(d){
201 var el=document.getElementById('demo-feed-list');if(!el)return;
202 if(!d.entries||d.entries.length===0){el.innerHTML='<li class="demo-empty">Quiet right now — push a commit to a demo repo to see live updates.</li>';return;}
203 el.innerHTML=d.entries.map(function(e){var label=e.kind==='auto_merge.merged'?'auto-merged':(e.kind==='ai_build.dispatched'?'AI-build dispatched':'AI review posted');var path=e.ref.type==='pr'?'pulls':'issues';return '<li><span class="demo-kind demo-kind-'+esc(e.kind.replace(/\\./g,'-'))+'">'+esc(label)+'</span> <a href="/${DEMO_USERNAME}/'+esc(e.repo)+'/'+path+'/'+e.ref.number+'">'+esc(e.repo)+' #'+e.ref.number+'</a> <span class="demo-meta">'+rel(e.at)+'</span></li>';}).join('');
204 }).catch(function(){});}
205 function tick(){pollQueued();pollMerges();pollReviews();pollFeed();}
206 // Initial refresh after a short delay so the SSR snapshot stays visible
207 // a beat — keeps the page from "flashing" identical content on load.
208 setInterval(tick,INTERVAL);
209}catch(e){}})();
210`.trim();
211
212 return c.html(
213 <Layout title="Live demo" user={user}>
214 <style dangerouslySetInnerHTML={{ __html: DEMO_CSS }} />
215 <div class="demo-page">
216 <div class="demo-hero">
217 <div class="demo-hero-inner">
218 <div class="eyebrow">
219 <span class="demo-pulse" />
220 Live · pulled from production · refreshes every 30s
221 </div>
222 <h1 class="demo-title">
223 Watch Claude <span class="gradient-text">build software, live.</span>
224 </h1>
225 <p class="demo-sub">
226 Every tile below is real audit data from the seeded demo repos.
227 File an issue tagged <code>ai:build</code> and the gluecron autopilot
228 opens a PR. Land an AI review. Merge it automatically when gates
229 go green. No human in the loop for the routine cases.
230 </p>
231 </div>
232 <div class="demo-hero-cta">
233 <a href="/register" class="btn btn-primary btn-lg">
234 Sign up free <span aria-hidden="true">{"→"}</span>
235 </a>
236 </div>
237 </div>
238
239 <div class="demo-tiles">
240 <section class="demo-tile" aria-labelledby="tile-queued-h">
241 <h2 id="tile-queued-h" class="demo-tile-title">
242 Issues queued for AI build
243 </h2>
244 <ul id="tile-queued-list" class="demo-list">
245 {queued.length === 0 ? (
246 <li class="demo-empty">No queued AI builds — quiet right now.</li>
247 ) : (
248 queued.map((i) => (
249 <li>
250 <a href={`/${DEMO_USERNAME}/${i.repo}/issues/${i.number}`}>
251 #{i.number} {i.title}
252 </a>{" "}
253 <span class="demo-meta">
254 {i.repo} · {relativeTime(i.createdAt)}
255 </span>
256 </li>
257 ))
258 )}
259 </ul>
260 </section>
261
262 <section class="demo-tile" aria-labelledby="tile-merges-h">
263 <h2 id="tile-merges-h" class="demo-tile-title">
264 PRs auto-merged in the last 24h
265 </h2>
266 <ul id="tile-merges-list" class="demo-list">
267 {merges.length === 0 ? (
268 <li class="demo-empty">
269 No auto-merges in the last 24h.
270 </li>
271 ) : (
272 merges.map((m) => (
273 <li>
274 <a href={`/${DEMO_USERNAME}/${m.repo}/pulls/${m.number}`}>
275 #{m.number} {m.title}
276 </a>{" "}
277 <span class="demo-meta">
278 {m.repo} · {relativeTime(m.mergedAt)}
279 </span>
280 </li>
281 ))
282 )}
283 </ul>
284 </section>
285
286 <section class="demo-tile" aria-labelledby="tile-reviews-h">
287 <h2 id="tile-reviews-h" class="demo-tile-title">
288 AI reviews posted today
289 </h2>
290 <div class="demo-bigcount">
291 <span id="tile-reviews-count">{reviewCount}</span>
292 <span class="demo-bigcount-label">in the last 24h</span>
293 </div>
294 <ul id="tile-reviews-list" class="demo-list demo-list-small">
295 {reviews.length === 0 ? (
296 <li class="demo-empty">
297 No AI reviews in the last 24h.
298 </li>
299 ) : (
300 reviews.map((r) => (
301 <li>
302 <a href={`/${DEMO_USERNAME}/${r.repo}/pulls/${r.prNumber}`}>
303 #{r.prNumber}
304 </a>{" "}
305 <span class="demo-snippet">{r.commentSnippet}</span>{" "}
306 <span class="demo-meta">
307 {r.repo} · {relativeTime(r.createdAt)}
308 </span>
309 </li>
310 ))
311 )}
312 </ul>
313 </section>
314 </div>
315
316 <section class="demo-repos" aria-labelledby="demo-repos-h">
317 <h2 id="demo-repos-h" class="demo-section-title">
318 Dig into the demo repos
319 </h2>
320 <div class="demo-repos-grid">
321 {demoRepos.map((r) => (
322 <a class="demo-repo-card" href={`${demoUserUrl}/${r.name}`}>
323 <div class="demo-repo-name">
324 {DEMO_USERNAME}/{r.name}
325 </div>
326 <div class="demo-repo-tag">{r.tagline}</div>
327 </a>
328 ))}
329 </div>
330 </section>
331
332 <section class="demo-feed" aria-labelledby="demo-feed-h">
333 <h2 id="demo-feed-h" class="demo-section-title">
334 Live activity
335 </h2>
336 <ul id="demo-feed-list" class="demo-feed-list">
337 {feed.length === 0 ? (
338 <li class="demo-empty">
339 Quiet right now — push a commit to a demo repo to see live updates.
340 </li>
341 ) : (
342 feed.map((e) => {
343 const path = e.ref.type === "pr" ? "pulls" : "issues";
344 return (
345 <li>
346 <span
347 class={`demo-kind demo-kind-${e.kind.replace(/\./g, "-")}`}
348 >
349 {activityLabel(e.kind)}
350 </span>{" "}
351 <a href={`/${DEMO_USERNAME}/${e.repo}/${path}/${e.ref.number}`}>
352 {e.repo} #{e.ref.number}
353 </a>{" "}
354 <span class="demo-meta">{relativeTime(e.at)}</span>
355 </li>
356 );
357 })
358 )}
359 </ul>
360 </section>
361 </div>
362 <script dangerouslySetInnerHTML={{ __html: pollerScript }} />
363 </Layout>
364 );
365});
366
367// Minimal CSS, scoped under .demo-page so it doesn't bleed into other views.
368const DEMO_CSS = `
369.demo-page { max-width: 1100px; margin: 0 auto; padding: 32px 20px 64px; }
370.demo-hero { display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start; justify-content: space-between; margin-bottom: 32px; }
371.demo-hero-inner { flex: 1 1 480px; }
372.demo-pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,0.7); margin-right: 8px; vertical-align: middle; animation: demo-pulse 1.6s infinite; }
373@keyframes demo-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
374.demo-title { font-size: 36px; line-height: 1.1; margin: 12px 0 16px; }
375.demo-sub { color: var(--text-muted); max-width: 640px; font-size: 15px; line-height: 1.55; }
376.demo-sub code { background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px; font-size: 13px; }
377.demo-hero-cta { flex: 0 0 auto; }
378.demo-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 32px; }
379.demo-tile { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
380.demo-tile-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 0 0 12px; }
381.demo-list { list-style: none; margin: 0; padding: 0; font-size: 14px; line-height: 1.5; }
382.demo-list li { padding: 6px 0; border-bottom: 1px solid var(--border); }
383.demo-list li:last-child { border-bottom: 0; }
384.demo-list-small li { font-size: 13px; }
385.demo-list a { color: var(--text-strong); text-decoration: none; }
386.demo-list a:hover { text-decoration: underline; }
387.demo-meta { color: var(--text-muted); font-size: 12px; }
388.demo-snippet { color: var(--text-muted); font-style: italic; font-size: 12px; }
389.demo-empty { color: var(--text-muted); font-size: 13px; padding: 6px 0; }
390.demo-bigcount { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
391.demo-bigcount span:first-child { font-size: 32px; font-weight: 700; color: var(--text-strong); }
392.demo-bigcount-label { color: var(--text-muted); font-size: 12px; }
393.demo-section-title { font-size: 18px; margin: 16px 0 12px; }
394.demo-repos { margin-bottom: 32px; }
395.demo-repos-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
396.demo-repo-card { display: block; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; text-decoration: none; color: inherit; transition: border-color 0.15s; }
397.demo-repo-card:hover { border-color: var(--accent, #8c6dff); }
398.demo-repo-name { font-weight: 600; font-size: 14px; color: var(--text-strong); margin-bottom: 4px; }
399.demo-repo-tag { font-size: 12px; color: var(--text-muted); }
400.demo-feed { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
401.demo-feed-list { list-style: none; margin: 0; padding: 0; font-size: 13px; line-height: 1.6; }
402.demo-feed-list li { padding: 4px 0; }
403.demo-kind { display: inline-block; padding: 1px 7px; border-radius: 9999px; font-size: 11px; font-weight: 500; }
404.demo-kind-auto_merge-merged, .demo-kind-auto-merge-merged { background: rgba(52,211,153,0.12); color: #34d399; }
405.demo-kind-ai_build-dispatched, .demo-kind-ai-build-dispatched { background: rgba(140,109,255,0.15); color: #8c6dff; }
406.demo-kind-ai_review-posted, .demo-kind-ai-review-posted { background: rgba(54,197,214,0.15); color: #36c5d6; }
407`;
408
409export default app;
Modifiedsrc/routes/marketing.tsx+0−484View fileUnifiedSplit
11611161 }
11621162`;
11631163
1164// ============================================================
1165// /vs-github
1166// ============================================================
1167
1168marketing.get("/vs-github", (c) => {
1169 const user = c.get("user");
1170 return c.html(
1171 <Layout title="vs GitHub — gluecron" user={user}>
1172 <VsGithubPage />
1173 </Layout>,
1174 );
1175});
1176
1177const VsGithubPage: FC = () => (
1178 <>
1179 <style dangerouslySetInnerHTML={{ __html: vsGithubCss }} />
1180 <div class="mkt-root">
1181 <header class="mkt-hero vs-hero">
1182 <div class="eyebrow vs-eyebrow">
1183 <span class="vs-eyebrow-dot" />
1184 The honest comparison
1185 </div>
1186 <h1 class="display vs-hero-title">
1187 GitHub,{" "}
1188 <span class="gradient-text">but kind.</span>
1189 </h1>
1190 <p class="mkt-hero-sub">
1191 GitHub treats AI as a paid sidebar. Failures are your problem.
1192 Every feature you actually need is gated behind Enterprise.
1193 We rebuilt the platform around a simple idea:{" "}
1194 <strong>your tools should work for you, not against you.</strong>
1195 </p>
1196 <div class="vs-hero-stats">
1197 <div class="vs-hero-stat">
1198 <div class="vs-hero-stat-num gradient-text">100%</div>
1199 <div class="vs-hero-stat-lbl">PRs reviewed by AI<br/>(free, every tier)</div>
1200 </div>
1201 <div class="vs-hero-stat">
1202 <div class="vs-hero-stat-num gradient-text">~70%</div>
1203 <div class="vs-hero-stat-lbl">CI failures auto-repaired<br/>before you see them</div>
1204 </div>
1205 <div class="vs-hero-stat">
1206 <div class="vs-hero-stat-num gradient-text">$0</div>
1207 <div class="vs-hero-stat-lbl">Self-host forever<br/>vs $21/user/mo GHE</div>
1208 </div>
1209 <div class="vs-hero-stat">
1210 <div class="vs-hero-stat-num gradient-text"></div>
1211 <div class="vs-hero-stat-lbl">CI minutes<br/>vs GitHub's metered tax</div>
1212 </div>
1213 </div>
1214 </header>
1215
1216 {/* ───────── The kill shot ───────── */}
1217 <section class="mkt-section vs-killshot">
1218 <div class="vs-killshot-card">
1219 <div class="vs-killshot-row">
1220 <div class="vs-killshot-col vs-them">
1221 <div class="vs-killshot-label">GitHub</div>
1222 <p class="vs-killshot-quote">
1223 "Push code. Hope it doesn't break. If it breaks,
1224 you deal with it. Want AI? Pay $19/mo. Want SSO?
1225 $21/user/mo. Want decent rulesets? Enterprise tier."
1226 </p>
1227 </div>
1228 <div class="vs-killshot-vs">VS</div>
1229 <div class="vs-killshot-col vs-us">
1230 <div class="vs-killshot-label">Gluecron</div>
1231 <p class="vs-killshot-quote">
1232 "Push code. AI reviews it. AI fixes it.
1233 AI ships it. We tell you about it after.
1234 Self-host free. No seat tax."
1235 </p>
1236 </div>
1237 </div>
1238 </div>
1239 </section>
1240
1241 {/* ───────── Side-by-side feature table ───────── */}
1242 <section class="mkt-section">
1243 <div class="section-header">
1244 <div class="eyebrow">Feature-for-feature</div>
1245 <h2>Everything they have. Plus the parts they didn't build.</h2>
1246 </div>
1247 <div class="vs-table">
1248 <VsRow feat="Smart-HTTP git clone + push" them="✓" us="✓" />
1249 <VsRow feat="Pull requests + review + merge" them="✓" us="✓" />
1250 <VsRow feat="Issues + projects + wiki + gists" them="✓" us="✓" />
1251 <VsRow feat="GitHub Actions equivalent" them="paid minutes" us="self-hosted, unmetered" win />
1252 <VsRow feat="AI code review on every PR" them="Copilot subscription" us="built-in, free" win />
1253 <VsRow feat="Auto-repair of failed CI" them="—" us="✓" win />
1254 <VsRow feat="Spec-to-PR (NL → draft PR)" them="—" us="✓" win />
1255 <VsRow feat="Real-time SSE for logs / comments" them="polling" us="streaming" win />
1256 <VsRow feat="MCP server (Claude / Cursor)" them="—" us="✓ native" win />
1257 <VsRow feat="Required status checks" them="Enterprise tier" us="free, all tiers" win />
1258 <VsRow feat="Repository rulesets" them="Enterprise tier" us="free, all tiers" win />
1259 <VsRow feat="Merge queue" them="Enterprise tier" us="free, all tiers" win />
1260 <VsRow feat="Audit log (per-user + per-repo)" them="Enterprise tier" us="free, all tiers" win />
1261 <VsRow feat="SSO (OIDC)" them="Enterprise tier" us="all tiers" win />
1262 <VsRow feat="Self-host on your hardware" them="$21/user/mo" us="free forever" win />
1263 <VsRow feat="Pre-receive policy enforcement" them="Enterprise rulesets" us="✓" />
1264 <VsRow feat="Branch protection + 2FA + Passkeys" them="✓" us="✓" />
1265 <VsRow feat="Webhooks (HMAC signed)" them="✓" us="✓" />
1266 <VsRow feat="Pages / static hosting" them="✓" us="✓" />
1267 <VsRow feat="Packages registry (npm)" them="✓" us="✓" />
1268 <VsRow feat="Native mobile apps" them="✓" us="PWA only" lose />
1269 <VsRow feat="Marketplace of pre-built actions" them="huge ecosystem" us="building" lose />
1270 <VsRow feat="Public Sponsors w/ payments" them="✓" us="post-launch" lose />
1271 </div>
1272 <p class="vs-table-foot">
1273 We're behind on three things and ahead on twelve. Three of those
1274 twelve are genuinely new categories no one has built before.
1275 </p>
1276 </section>
1277
1278 {/* ───────── The pricing knife ───────── */}
1279 <section class="mkt-section">
1280 <div class="section-header">
1281 <div class="eyebrow">Pricing philosophy</div>
1282 <h2>Stop taxing seats. Start pricing what costs us money.</h2>
1283 <p>
1284 GitHub charges $4-21 per user per month, plus minutes, plus
1285 features. We don't tax seats. We price the AI calls — the
1286 actual cost we incur. Static self-hosters pay nothing.
1287 </p>
1288 </div>
1289 <div class="vs-pricing-grid">
1290 <div class="vs-pricing-them">
1291 <div class="vs-pricing-label">GitHub</div>
1292 <ul class="vs-pricing-list">
1293 <li><span class="vs-pricing-x"></span> Free: limited private repos, paid minutes</li>
1294 <li><span class="vs-pricing-x"></span> Pro: <strong>$4/user/mo</strong> + paid minutes</li>
1295 <li><span class="vs-pricing-x"></span> Team: <strong>$4/user/mo</strong> + everything metered</li>
1296 <li><span class="vs-pricing-x"></span> Enterprise: <strong>$21/user/mo</strong> + extras</li>
1297 <li><span class="vs-pricing-x"></span> Copilot: <strong>$19/user/mo</strong> on top</li>
1298 <li><span class="vs-pricing-x"></span> Self-host: only at Enterprise tier</li>
1299 </ul>
1300 </div>
1301 <div class="vs-pricing-us">
1302 <div class="vs-pricing-label">Gluecron</div>
1303 <ul class="vs-pricing-list">
1304 <li><span class="vs-pricing-check"></span> Free: unlimited public, 3 private, AI included</li>
1305 <li><span class="vs-pricing-check"></span> Pro: <strong>$12/user/mo</strong> all-in (AI included)</li>
1306 <li><span class="vs-pricing-check"></span> Team: <strong>$29/user/mo</strong> unlimited AI + SSO + audit</li>
1307 <li><span class="vs-pricing-check"></span> Enterprise: custom + on-prem + DPA</li>
1308 <li><span class="vs-pricing-check"></span> CI minutes: <strong>unmetered</strong>, hosted or self</li>
1309 <li><span class="vs-pricing-check"></span> Self-host: <strong>$0 forever</strong>, all tiers</li>
1310 </ul>
1311 </div>
1312 </div>
1313 </section>
1314
1315 {/* ───────── Why we exist ───────── */}
1316 <section class="mkt-section">
1317 <div class="section-header">
1318 <div class="eyebrow">Why this exists</div>
1319 <h2>Most code in 2026 is written by AI. Most platforms aren't built for that.</h2>
1320 </div>
1321 <div class="vs-rationale">
1322 <p>
1323 GitHub was designed in 2008 for a world where one human
1324 engineer wrote every commit. The platform's defaults assume
1325 <em> you're the bottleneck</em>. Red X's demand your attention.
1326 Failed checks page you at 3am. Every AI feature is a
1327 $19/user upsell because, for them, AI is a bolt-on.
1328 </p>
1329 <p>
1330 For us, AI is the<strong> default teammate</strong>. It commits
1331 with its own bot identity. It opens PRs. It reviews diffs.
1332 It fixes regressions before you wake up. It tells you what
1333 broke and why. <strong>The platform works for you, not the
1334 other way around.</strong>
1335 </p>
1336 <p>
1337 We're not anti-GitHub. We use it. We came from there. But
1338 after watching engineers spend 30% of their week dealing with
1339 CI failures, infrastructure pain, and Copilot subscriptions
1340 that don't actually help, we got tired of the cruelty —
1341 so we rebuilt the parts that mattered.
1342 </p>
1343 </div>
1344 </section>
1345
1346 {/* ───────── The closing ask ───────── */}
1347 <section class="mkt-cta">
1348 <div class="mkt-cta-card">
1349 <div class="mkt-cta-bg" aria-hidden="true" />
1350 <div class="eyebrow">Try it</div>
1351 <h2 class="mkt-cta-title">
1352 Migrate from GitHub in 60 seconds.<br />
1353 <span class="gradient-text">Stay because it's actually better.</span>
1354 </h2>
1355 <p class="mkt-hero-sub" style="max-width:600px;margin:0 auto var(--s-7)">
1356 Paste a GitHub token, mirror your whole org. Your existing
1357 workflows just work. Your team keeps shipping. Nothing
1358 breaks. Everything gets kinder.
1359 </p>
1360 <div class="mkt-cta-buttons">
1361 <a href="/register" class="btn btn-primary btn-xl">
1362 Start free
1363 <span aria-hidden="true">{"→"}</span>
1364 </a>
1365 <a href="/import" class="btn btn-ghost btn-xl">
1366 Migrate from GitHub
1367 </a>
1368 </div>
1369 </div>
1370 </section>
1371 </div>
1372 </>
1373);
1374
1375const VsRow: FC<{
1376 feat: string;
1377 them: string;
1378 us: string;
1379 win?: boolean;
1380 lose?: boolean;
1381}> = ({ feat, them, us, win, lose }) => (
1382 <div
1383 class={`vs-row${win ? " vs-row-win" : ""}${lose ? " vs-row-lose" : ""}`}
1384 >
1385 <div class="vs-row-feat">{feat}</div>
1386 <div class="vs-row-them">{them}</div>
1387 <div class="vs-row-us">{us}</div>
1388 </div>
1389);
1390
1391const vsGithubCss = sharedMktCss + `
1392 /* Hero with even bigger drama */
1393 .vs-hero { padding-bottom: var(--s-8); }
1394 .vs-eyebrow { color: var(--accent); }
1395 .vs-eyebrow-dot {
1396 width: 7px; height: 7px;
1397 border-radius: 50%;
1398 background: var(--accent);
1399 box-shadow: 0 0 0 0 rgba(140,109,255,0.55);
1400 animation: vs-pulse 1.8s ease-out infinite;
1401 }
1402 @keyframes vs-pulse {
1403 0% { box-shadow: 0 0 0 0 rgba(140,109,255,0.55); }
1404 70% { box-shadow: 0 0 0 10px rgba(140,109,255,0); }
1405 100% { box-shadow: 0 0 0 0 rgba(140,109,255,0); }
1406 }
1407 .vs-hero-title {
1408 font-size: clamp(40px, 8vw, 100px);
1409 line-height: 0.98;
1410 letter-spacing: -0.04em;
1411 font-weight: 700;
1412 margin: 0 0 var(--s-5);
1413 }
1414 .vs-hero-stats {
1415 display: grid;
1416 grid-template-columns: repeat(4, 1fr);
1417 gap: var(--s-6);
1418 max-width: 880px;
1419 margin: var(--s-10) auto 0;
1420 text-align: center;
1421 }
1422 .vs-hero-stat-num {
1423 font-family: var(--font-display);
1424 font-size: clamp(36px, 4.5vw, 56px);
1425 font-weight: 700;
1426 line-height: 1;
1427 letter-spacing: -0.03em;
1428 margin-bottom: var(--s-2);
1429 }
1430 .vs-hero-stat-lbl {
1431 font-family: var(--font-mono);
1432 font-size: 11px;
1433 text-transform: uppercase;
1434 letter-spacing: 0.12em;
1435 color: var(--text-faint);
1436 line-height: 1.5;
1437 }
1438 @media (max-width: 720px) {
1439 .vs-hero-stats { grid-template-columns: repeat(2, 1fr); gap: var(--s-5); }
1440 }
1441
1442 /* The kill-shot card */
1443 .vs-killshot { margin-top: var(--s-4); }
1444 .vs-killshot-card {
1445 max-width: 980px;
1446 margin: 0 auto;
1447 padding: var(--s-10) var(--s-7);
1448 background:
1449 linear-gradient(135deg, rgba(140,109,255,0.06), transparent 50%, rgba(54,197,214,0.04)),
1450 var(--bg-elevated);
1451 border: 1px solid var(--border-strong);
1452 border-radius: var(--r-2xl);
1453 box-shadow: var(--elev-2);
1454 }
1455 .vs-killshot-row {
1456 display: grid;
1457 grid-template-columns: 1fr auto 1fr;
1458 gap: var(--s-7);
1459 align-items: center;
1460 }
1461 .vs-killshot-col { padding: var(--s-4); }
1462 .vs-killshot-label {
1463 font-family: var(--font-mono);
1464 font-size: 11px;
1465 text-transform: uppercase;
1466 letter-spacing: 0.16em;
1467 margin-bottom: var(--s-3);
1468 }
1469 .vs-them .vs-killshot-label { color: var(--red); }
1470 .vs-us .vs-killshot-label { color: var(--green); }
1471 .vs-killshot-quote {
1472 font-size: 17px;
1473 line-height: 1.55;
1474 margin: 0;
1475 color: var(--text);
1476 font-style: italic;
1477 }
1478 .vs-them .vs-killshot-quote { color: var(--text-muted); }
1479 .vs-killshot-vs {
1480 font-family: var(--font-display);
1481 font-size: 36px;
1482 font-weight: 700;
1483 color: var(--text-faint);
1484 letter-spacing: -0.02em;
1485 }
1486 @media (max-width: 720px) {
1487 .vs-killshot-row { grid-template-columns: 1fr; gap: var(--s-3); text-align: left; }
1488 .vs-killshot-vs { text-align: center; }
1489 }
1490
1491 /* Comparison table */
1492 .vs-table {
1493 max-width: 1080px;
1494 margin: 0 auto;
1495 border: 1px solid var(--border);
1496 border-radius: var(--r-lg);
1497 overflow: hidden;
1498 background: var(--bg-elevated);
1499 }
1500 .vs-row {
1501 display: grid;
1502 grid-template-columns: 1.6fr 1fr 1fr;
1503 align-items: center;
1504 padding: 14px 22px;
1505 border-bottom: 1px solid var(--border-subtle);
1506 font-size: var(--t-sm);
1507 transition: background var(--t-fast) var(--ease);
1508 }
1509 .vs-row:last-child { border-bottom: none; }
1510 .vs-row:hover { background: var(--bg-hover); }
1511 .vs-row-feat { color: var(--text-strong); font-weight: 500; }
1512 .vs-row-them, .vs-row-us {
1513 text-align: center;
1514 font-family: var(--font-mono);
1515 font-size: 12px;
1516 }
1517 .vs-row-them { color: var(--text-muted); }
1518 .vs-row-us { color: var(--text); }
1519 .vs-row-win .vs-row-us {
1520 color: var(--accent);
1521 font-weight: 600;
1522 }
1523 .vs-row-win .vs-row-feat::after {
1524 content: 'WIN';
1525 margin-left: 10px;
1526 padding: 2px 7px;
1527 border-radius: 4px;
1528 background: var(--accent-gradient-faint);
1529 border: 1px solid rgba(140,109,255,0.30);
1530 color: var(--accent);
1531 font-family: var(--font-mono);
1532 font-size: 9px;
1533 letter-spacing: 0.12em;
1534 font-weight: 700;
1535 vertical-align: 1px;
1536 }
1537 .vs-row-lose .vs-row-us {
1538 color: var(--text-faint);
1539 }
1540 .vs-row-lose .vs-row-feat::after {
1541 content: 'GAP';
1542 margin-left: 10px;
1543 padding: 2px 7px;
1544 border-radius: 4px;
1545 background: rgba(251,191,36,0.10);
1546 border: 1px solid rgba(251,191,36,0.30);
1547 color: var(--yellow);
1548 font-family: var(--font-mono);
1549 font-size: 9px;
1550 letter-spacing: 0.12em;
1551 font-weight: 700;
1552 vertical-align: 1px;
1553 }
1554 .vs-table-foot {
1555 text-align: center;
1556 color: var(--text-muted);
1557 font-size: var(--t-sm);
1558 margin-top: var(--s-5);
1559 max-width: 720px;
1560 margin-left: auto;
1561 margin-right: auto;
1562 line-height: 1.55;
1563 }
1564 @media (max-width: 720px) {
1565 .vs-row { grid-template-columns: 1fr 90px 90px; padding: 12px 14px; }
1566 .vs-row-win .vs-row-feat::after,
1567 .vs-row-lose .vs-row-feat::after { display: none; }
1568 }
1569
1570 /* Pricing knife */
1571 .vs-pricing-grid {
1572 display: grid;
1573 grid-template-columns: 1fr 1fr;
1574 gap: var(--s-5);
1575 max-width: 980px;
1576 margin: 0 auto;
1577 }
1578 .vs-pricing-them, .vs-pricing-us {
1579 padding: var(--s-7);
1580 border: 1px solid var(--border);
1581 border-radius: var(--r-lg);
1582 background: var(--bg-elevated);
1583 }
1584 .vs-pricing-them {
1585 border-color: rgba(248,113,113,0.20);
1586 }
1587 .vs-pricing-us {
1588 border-color: rgba(140,109,255,0.30);
1589 box-shadow: 0 0 0 1px rgba(140,109,255,0.20), var(--elev-2);
1590 background:
1591 linear-gradient(180deg, rgba(140,109,255,0.04), transparent 60%),
1592 var(--bg-elevated);
1593 }
1594 .vs-pricing-label {
1595 font-family: var(--font-display);
1596 font-size: 22px;
1597 font-weight: 700;
1598 margin-bottom: var(--s-5);
1599 letter-spacing: -0.02em;
1600 }
1601 .vs-pricing-them .vs-pricing-label { color: var(--text-muted); }
1602 .vs-pricing-us .vs-pricing-label { color: var(--accent); }
1603 .vs-pricing-list {
1604 list-style: none;
1605 margin: 0;
1606 padding: 0;
1607 display: flex;
1608 flex-direction: column;
1609 gap: 10px;
1610 font-size: var(--t-sm);
1611 line-height: 1.55;
1612 }
1613 .vs-pricing-list li {
1614 display: flex;
1615 gap: 10px;
1616 align-items: flex-start;
1617 }
1618 .vs-pricing-x {
1619 color: var(--red);
1620 font-weight: 700;
1621 flex-shrink: 0;
1622 }
1623 .vs-pricing-check {
1624 color: var(--green);
1625 font-weight: 700;
1626 flex-shrink: 0;
1627 }
1628 .vs-pricing-them li { color: var(--text-muted); }
1629 .vs-pricing-us li { color: var(--text); }
1630 @media (max-width: 720px) {
1631 .vs-pricing-grid { grid-template-columns: 1fr; }
1632 }
1633
1634 /* Rationale prose */
1635 .vs-rationale {
1636 max-width: 720px;
1637 margin: 0 auto;
1638 }
1639 .vs-rationale p {
1640 color: var(--text);
1641 font-size: var(--t-md);
1642 line-height: 1.7;
1643 margin: 0 0 var(--s-4);
1644 }
1645 .vs-rationale strong { color: var(--text-strong); font-weight: 600; }
1646 .vs-rationale em { color: var(--text-strong); font-style: italic; }
1647`;
16481164
16491165export default marketing;
Addedsrc/routes/public-stats.ts+30−0View fileUnifiedSplit
1/**
2 * Block L4 — Public stats API endpoint.
3 *
4 * GET /api/v2/stats
5 *
6 * Public, no auth, CORS-open (inherits `/api/*` cors from `app.tsx`).
7 * Returns the same `PublicStats` JSON shape rendered on the marketing
8 * landing page. Cached at the lib layer (5 min in-memory LRU); the
9 * response carries `Cache-Control: public, max-age=300` so CDN /
10 * browser caches can hold it too.
11 *
12 * The handler never throws — `computePublicStats` degrades to all-zeros
13 * on DB error, so a 200 with zeros is the worst case here.
14 */
15
16import { Hono } from "hono";
17import { computePublicStats } from "../lib/public-stats";
18
19const publicStats = new Hono();
20
21publicStats.get("/api/v2/stats", async (c) => {
22 const stats = await computePublicStats();
23 c.header("cache-control", "public, max-age=300");
24 return c.json({
25 ...stats,
26 asOf: stats.asOf.toISOString(),
27 });
28});
29
30export default publicStats;
Addedsrc/routes/vs-github.tsx+660−0View fileUnifiedSplit
1/**
2 * Block L5 — Gluecron vs GitHub marketing page.
3 *
4 * Public, no auth. The pitch: a Claude-first developer's case for picking
5 * Gluecron over GitHub for their next project. Hero + honest side-by-side
6 * feature table + objections FAQ + CTA.
7 *
8 * Every comparison row is cross-referenced against BUILD_BIBLE §2 — we don't
9 * stretch and we acknowledge where GitHub legitimately wins (ecosystem
10 * maturity, Actions marketplace, third-party integrations).
11 *
12 * Visual family: mirrors `src/routes/sleep-mode.tsx` (L1) and re-uses
13 * `landing.tsx` CSS variables (`--accent-gradient`, `--bg-elevated`, etc.).
14 * Plain-HTML output — no external assets, no images.
15 */
16
17import { Hono } from "hono";
18import { Layout } from "../views/layout";
19import { softAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21
22const vsGithub = new Hono<AuthEnv>();
23vsGithub.use("*", softAuth);
24
25// ---------------------------------------------------------------------------
26// Comparison data — one row per feature, grouped by category.
27// gh/gc verdict strings appear verbatim in the rendered table.
28// ---------------------------------------------------------------------------
29
30type Verdict = "yes" | "partial" | "no";
31
32interface Row {
33 feature: string;
34 gh: { verdict: Verdict; note: string };
35 gc: { verdict: Verdict; note: string };
36}
37
38interface Category {
39 title: string;
40 rows: Row[];
41}
42
43const CATEGORIES: Category[] = [
44 {
45 title: "AI-native workflow",
46 rows: [
47 {
48 feature: "AI code review on every PR",
49 gh: { verdict: "partial", note: "Add-on (Copilot)" },
50 gc: { verdict: "yes", note: "Built-in (Sonnet 4)" },
51 },
52 {
53 feature: "AI auto-merge when checks pass",
54 gh: { verdict: "no", note: "Manual (third-party action)" },
55 gc: { verdict: "yes", note: "Built-in (K2, opt-in per branch)" },
56 },
57 {
58 feature: "Spec → PR pipeline",
59 gh: { verdict: "partial", note: "Copilot Workspace (beta)" },
60 gc: { verdict: "yes", note: "Generally available" },
61 },
62 {
63 feature: "Label-an-issue → AI builds it",
64 gh: { verdict: "no", note: "Not available" },
65 gc: { verdict: "yes", note: "ai:build label (autopilot)" },
66 },
67 {
68 feature: "AI explain-this-codebase",
69 gh: { verdict: "partial", note: "Copilot Chat" },
70 gc: { verdict: "yes", note: "Cached per commit" },
71 },
72 {
73 feature: "AI changelog per commit range",
74 gh: { verdict: "no", note: "Not available" },
75 gc: { verdict: "yes", note: "Built-in (D7)" },
76 },
77 {
78 feature: "AI incident responder",
79 gh: { verdict: "no", note: "Not available" },
80 gc: { verdict: "yes", note: "Auto-issue on deploy fail" },
81 },
82 {
83 feature: "AI dependency updater",
84 gh: { verdict: "partial", note: "Dependabot (no AI reasoning)" },
85 gc: { verdict: "yes", note: "Claude-driven bump table" },
86 },
87 {
88 feature: "AI security scan on every push",
89 gh: { verdict: "partial", note: "CodeQL" },
90 gc: { verdict: "yes", note: "15-pattern secret + Sonnet 4 review" },
91 },
92 {
93 feature: "AI Sleep Mode (overnight digest)",
94 gh: { verdict: "no", note: "Not available" },
95 gc: { verdict: "yes", note: "L1 — toggle and walk away" },
96 },
97 ],
98 },
99 {
100 title: "Developer integration",
101 rows: [
102 {
103 feature: "MCP server with write tools",
104 gh: { verdict: "partial", note: "External (mcp__github__*)" },
105 gc: { verdict: "yes", note: "Native (/mcp)" },
106 },
107 {
108 feature: "One-command install for Claude Desktop",
109 gh: { verdict: "no", note: "Manual config" },
110 gc: { verdict: "yes", note: "curl gluecron.com/install" },
111 },
112 {
113 feature: "Bundled Claude Code skills",
114 gh: { verdict: "no", note: "None" },
115 gc: { verdict: "yes", note: "Three skills shipped (L7)" },
116 },
117 {
118 feature: "VS Code extension",
119 gh: { verdict: "yes", note: "Yes" },
120 gc: { verdict: "yes", note: "Yes" },
121 },
122 {
123 feature: "Official CLI",
124 gh: { verdict: "yes", note: "gh" },
125 gc: { verdict: "yes", note: "gluecron" },
126 },
127 {
128 feature: "GraphQL API",
129 gh: { verdict: "yes", note: "Yes" },
130 gc: { verdict: "yes", note: "Yes (queries)" },
131 },
132 {
133 feature: "REST API",
134 gh: { verdict: "yes", note: "Yes" },
135 gc: { verdict: "yes", note: "Yes (v1 + v2)" },
136 },
137 ],
138 },
139 {
140 title: "Hosting + workflow",
141 rows: [
142 {
143 feature: "Workflow runner (Actions equivalent)",
144 gh: { verdict: "yes", note: "Actions (paid minutes)" },
145 gc: { verdict: "yes", note: ".gluecron/workflows/*.yml (free, your server)" },
146 },
147 {
148 feature: "Package registry",
149 gh: { verdict: "yes", note: "Multiple ecosystems" },
150 gc: { verdict: "partial", note: "npm protocol only" },
151 },
152 {
153 feature: "Pages / static hosting",
154 gh: { verdict: "yes", note: "Pages" },
155 gc: { verdict: "yes", note: "gh-pages branch" },
156 },
157 {
158 feature: "Self-hostable",
159 gh: { verdict: "partial", note: "Enterprise only" },
160 gc: { verdict: "yes", note: "Single binary" },
161 },
162 {
163 feature: "Single-tenant (your code stays yours)",
164 gh: { verdict: "no", note: "Shared multi-tenant" },
165 gc: { verdict: "yes", note: "Your DB, your disk" },
166 },
167 ],
168 },
169 {
170 title: "Pricing",
171 rows: [
172 {
173 feature: "Free for public repos",
174 gh: { verdict: "yes", note: "Yes" },
175 gc: { verdict: "yes", note: "Yes" },
176 },
177 {
178 feature: "Free private repos",
179 gh: { verdict: "partial", note: "Limited" },
180 gc: { verdict: "yes", note: "Your host, your rules" },
181 },
182 {
183 feature: "Paid Actions minutes",
184 gh: { verdict: "yes", note: "Yes (a cost)" },
185 gc: { verdict: "no", note: "Your server" },
186 },
187 {
188 feature: "Per-seat fees",
189 gh: { verdict: "yes", note: "$4–$21/user" },
190 gc: { verdict: "no", note: "Your server" },
191 },
192 ],
193 },
194];
195
196// ---------------------------------------------------------------------------
197// FAQ — honest answers to common objections.
198// ---------------------------------------------------------------------------
199
200interface Faq {
201 q: string;
202 a: string;
203}
204
205const FAQS: Faq[] = [
206 {
207 q: "What about GitHub's huge action ecosystem?",
208 a: "Fair point — GitHub's Actions marketplace is years ahead. Gluecron's workflow runner uses the same yaml shape and runs on your server, so most workflows port directly. For the long tail of third-party actions, you're on your own for now; that's the honest trade.",
209 },
210 {
211 q: "What if Claude is wrong about a review?",
212 a: "Claude's PR review posts inline comments — you're free to ignore them. Auto-merge is opt-in per branch protection rule and re-uses every gate your manual merge already enforces. If Claude blocks a good PR, override is one click; if Claude approves a bad one, your required checks still have to pass.",
213 },
214 {
215 q: "Can I migrate without downtime?",
216 a: "Yes. `/import` clones a single repo via PAT; `/import-bulk` mirrors an entire org in one pass. You can keep pushing to GitHub during the cutover and flip the default remote when you're ready. A migration verifier checks object counts and branch parity post-clone.",
217 },
218 {
219 q: "What about ecosystem (search, code intel, etc.)?",
220 a: "Per-repo ILIKE search, semantic embedding search, regex-based symbol nav, blame, and a dependency graph are all built in. They're not GitHub's curated index of every public repo — but for your code, they're tuned for the same Claude that's reviewing your PRs.",
221 },
222];
223
224// ---------------------------------------------------------------------------
225// Verdict glyph + label
226// ---------------------------------------------------------------------------
227
228function verdictIcon(v: Verdict): string {
229 if (v === "yes") return "✅"; // ✅
230 if (v === "partial") return "🟡"; // 🟡
231 return "❌"; // ❌
232}
233
234function verdictClass(v: Verdict): string {
235 if (v === "yes") return "vsg-cell-yes";
236 if (v === "partial") return "vsg-cell-partial";
237 return "vsg-cell-no";
238}
239
240// ---------------------------------------------------------------------------
241// Route
242// ---------------------------------------------------------------------------
243
244vsGithub.get("/vs-github", (c) => {
245 const user = c.get("user");
246 return c.html(
247 <Layout title="Gluecron vs GitHub" user={user}>
248 <style dangerouslySetInnerHTML={{ __html: pageCss }} />
249 <div class="vsg-root">
250 {/* ---------- Hero ---------- */}
251 <header class="vsg-hero">
252 <div class="eyebrow">Side by side</div>
253 <h1 class="display vsg-hero-title">
254 Gluecron <span class="vsg-vs">vs</span>{" "}
255 <span class="vsg-gh-word">GitHub</span>
256 </h1>
257 <p class="vsg-hero-sub">
258 The git host built around Claude.
259 </p>
260 <div class="vsg-logos">
261 <div class="vsg-logo-card vsg-logo-them">
262 <span class="vsg-logo-text">GitHub</span>
263 <span class="vsg-logo-sub">the incumbent</span>
264 </div>
265 <div class="vsg-logo-vs" aria-hidden="true">vs</div>
266 <div class="vsg-logo-card vsg-logo-us">
267 <span class="vsg-logo-text gradient-text">gluecron</span>
268 <span class="vsg-logo-sub">the Claude-native one</span>
269 </div>
270 </div>
271 <div class="vsg-hero-cta">
272 <a href="/import" class="btn btn-primary btn-lg">
273 Migrate from GitHub in 60 seconds &rarr;
274 </a>
275 <a href="/demo" class="btn btn-ghost btn-lg">
276 Try the demo
277 </a>
278 </div>
279 </header>
280
281 {/* ---------- Feature comparison table ---------- */}
282 <section class="vsg-section">
283 <div class="section-header">
284 <div class="eyebrow">Feature comparison</div>
285 <h2>What you actually get.</h2>
286 <p>
287 Every row cross-referenced against the public parity scorecard.
288 When GitHub legitimately wins on a row, we say so &mdash;
289 honesty makes the wins land harder.
290 </p>
291 </div>
292
293 <div class="vsg-table" role="table" aria-label="Gluecron vs GitHub feature comparison">
294 <div class="vsg-thead" role="row">
295 <div class="vsg-th vsg-th-feature" role="columnheader">Feature</div>
296 <div class="vsg-th vsg-th-them" role="columnheader">GitHub</div>
297 <div class="vsg-th vsg-th-us" role="columnheader">Gluecron</div>
298 </div>
299
300 {CATEGORIES.map((cat) => (
301 <>
302 <div class="vsg-cat-row" role="row">
303 <div class="vsg-cat-title" role="cell">
304 {cat.title}
305 </div>
306 </div>
307 {cat.rows.map((row) => (
308 <div class="vsg-row" role="row">
309 <div class="vsg-cell vsg-cell-feature" role="cell">
310 {row.feature}
311 </div>
312 <div
313 class={`vsg-cell vsg-cell-them ${verdictClass(row.gh.verdict)}`}
314 role="cell"
315 >
316 <span class="vsg-icon" aria-hidden="true">
317 {verdictIcon(row.gh.verdict)}
318 </span>
319 <span class="vsg-note">{row.gh.note}</span>
320 </div>
321 <div
322 class={`vsg-cell vsg-cell-us ${verdictClass(row.gc.verdict)}`}
323 role="cell"
324 >
325 <span class="vsg-icon" aria-hidden="true">
326 {verdictIcon(row.gc.verdict)}
327 </span>
328 <span class="vsg-note">{row.gc.note}</span>
329 </div>
330 </div>
331 ))}
332 </>
333 ))}
334 </div>
335 </section>
336
337 {/* ---------- The killer move ---------- */}
338 <section class="vsg-section vsg-killer">
339 <div class="vsg-killer-card">
340 <div class="eyebrow">The killer move</div>
341 <h2 class="vsg-killer-headline">
342 Toggle Sleep Mode, walk away,{" "}
343 <span class="gradient-text">wake up to a digest.</span>
344 </h2>
345 <p class="vsg-killer-sub">
346 GitHub: not possible. Gluecron: ships in L1. While you sleep,
347 Claude auto-merges green PRs, builds features from{" "}
348 <code>ai:build</code> issues, and patches the gates that fail.
349 </p>
350 <a href="/sleep-mode" class="btn btn-secondary btn-lg">
351 See how Sleep Mode works &rarr;
352 </a>
353 </div>
354 </section>
355
356 {/* ---------- Objections FAQ ---------- */}
357 <section class="vsg-section">
358 <div class="section-header">
359 <div class="eyebrow">But what about…</div>
360 <h2>The honest objections.</h2>
361 </div>
362 <div class="vsg-faq-grid">
363 {FAQS.map((f) => (
364 <div class="vsg-faq">
365 <h3 class="vsg-faq-q">{f.q}</h3>
366 <p class="vsg-faq-a">{f.a}</p>
367 </div>
368 ))}
369 </div>
370 </section>
371
372 {/* ---------- CTA ---------- */}
373 <section class="vsg-section vsg-cta-section">
374 <h2 class="vsg-cta-title">
375 Stop renting your repos.{" "}
376 <span class="gradient-text">Start owning your stack.</span>
377 </h2>
378 <p class="vsg-cta-sub">
379 One command imports a GitHub repo. One toggle hands the night
380 shift to Claude. One binary self-hosts the whole thing.
381 </p>
382 <div class="vsg-cta-buttons">
383 <a href="/import" class="btn btn-primary btn-lg">
384 Migrate from GitHub in 60 seconds &rarr;
385 </a>
386 <a href="/demo" class="btn btn-ghost btn-lg">
387 Try the demo
388 </a>
389 </div>
390 </section>
391 </div>
392 </Layout>
393 );
394});
395
396const pageCss = `
397 .vsg-root {
398 max-width: 1120px;
399 margin: 0 auto;
400 padding: 48px 24px 80px;
401 }
402
403 /* ---------- Hero ---------- */
404 .vsg-hero { text-align: center; padding: 32px 0 48px; }
405 .vsg-hero-title {
406 font-size: clamp(40px, 7vw, 80px);
407 line-height: 1.05;
408 letter-spacing: -0.03em;
409 margin: 16px 0 16px;
410 color: var(--text-strong);
411 }
412 .vsg-vs {
413 font-family: var(--font-mono);
414 font-size: 0.55em;
415 color: var(--text-faint);
416 text-transform: lowercase;
417 letter-spacing: 0.06em;
418 vertical-align: 0.18em;
419 padding: 0 0.2em;
420 }
421 .vsg-gh-word { color: var(--text-muted); }
422 .vsg-hero-sub {
423 max-width: 640px;
424 margin: 0 auto 32px;
425 color: var(--text-muted);
426 font-size: clamp(15px, 1.6vw, 19px);
427 line-height: 1.55;
428 }
429
430 .vsg-logos {
431 display: flex;
432 align-items: center;
433 justify-content: center;
434 gap: 18px;
435 margin: 32px auto 28px;
436 flex-wrap: wrap;
437 }
438 .vsg-logo-card {
439 background: var(--bg-elevated);
440 border: 1px solid var(--border);
441 border-radius: 14px;
442 padding: 20px 32px;
443 min-width: 220px;
444 display: flex;
445 flex-direction: column;
446 gap: 6px;
447 align-items: center;
448 }
449 .vsg-logo-us {
450 border-color: rgba(140,109,255,0.35);
451 box-shadow: 0 0 0 1px rgba(140,109,255,0.18), 0 12px 32px -10px rgba(140,109,255,0.30);
452 }
453 .vsg-logo-text {
454 font-family: var(--font-display);
455 font-size: 32px;
456 font-weight: 700;
457 letter-spacing: -0.025em;
458 line-height: 1;
459 }
460 .vsg-logo-them .vsg-logo-text { color: var(--text); }
461 .vsg-logo-sub {
462 font-family: var(--font-mono);
463 font-size: 10.5px;
464 letter-spacing: 0.14em;
465 text-transform: uppercase;
466 color: var(--text-faint);
467 }
468 .vsg-logo-vs {
469 font-family: var(--font-mono);
470 font-size: 14px;
471 color: var(--text-faint);
472 letter-spacing: 0.08em;
473 text-transform: uppercase;
474 }
475
476 .vsg-hero-cta {
477 display: flex;
478 gap: 12px;
479 justify-content: center;
480 flex-wrap: wrap;
481 margin-top: 28px;
482 }
483
484 /* ---------- Section base ---------- */
485 .vsg-section { margin: 64px 0; }
486
487 /* ---------- Comparison table ---------- */
488 .vsg-table {
489 margin: 32px auto 0;
490 border: 1px solid var(--border);
491 border-radius: 14px;
492 overflow: hidden;
493 background: var(--bg-elevated);
494 }
495 .vsg-thead {
496 display: grid;
497 grid-template-columns: 1.7fr 1fr 1fr;
498 align-items: center;
499 padding: 14px 20px;
500 border-bottom: 1px solid var(--border);
501 background: var(--bg-hover, rgba(255,255,255,0.02));
502 }
503 .vsg-th {
504 font-family: var(--font-mono);
505 font-size: 11px;
506 letter-spacing: 0.14em;
507 text-transform: uppercase;
508 color: var(--text-faint);
509 font-weight: 600;
510 }
511 .vsg-th-them, .vsg-th-us { text-align: left; }
512 .vsg-th-us { color: var(--accent); }
513
514 .vsg-cat-row {
515 padding: 14px 20px 6px;
516 border-top: 1px solid var(--border-subtle, var(--border));
517 background: var(--accent-gradient-faint, rgba(140,109,255,0.04));
518 }
519 .vsg-cat-row:first-of-type { border-top: none; }
520 .vsg-cat-title {
521 font-family: var(--font-mono);
522 font-size: 11px;
523 letter-spacing: 0.16em;
524 text-transform: uppercase;
525 color: var(--accent);
526 font-weight: 700;
527 }
528
529 .vsg-row {
530 display: grid;
531 grid-template-columns: 1.7fr 1fr 1fr;
532 align-items: stretch;
533 padding: 12px 20px;
534 border-top: 1px solid var(--border-subtle, var(--border));
535 font-size: 14px;
536 transition: background var(--t-fast, 0.15s) ease;
537 }
538 .vsg-row:hover { background: var(--bg-hover, rgba(255,255,255,0.02)); }
539 .vsg-cell {
540 display: flex;
541 align-items: center;
542 gap: 8px;
543 padding-right: 12px;
544 }
545 .vsg-cell-feature {
546 color: var(--text-strong);
547 font-weight: 500;
548 }
549 .vsg-icon {
550 flex-shrink: 0;
551 font-size: 14px;
552 line-height: 1;
553 }
554 .vsg-note {
555 color: var(--text-muted);
556 font-size: 13px;
557 line-height: 1.4;
558 }
559 .vsg-cell-yes .vsg-note { color: var(--text); }
560 .vsg-cell-partial .vsg-note { color: var(--text-muted); }
561 .vsg-cell-no .vsg-note { color: var(--text-faint); }
562
563 @media (max-width: 720px) {
564 .vsg-thead, .vsg-row { grid-template-columns: 1.4fr 1fr 1fr; padding: 10px 12px; }
565 .vsg-note { font-size: 12px; }
566 }
567
568 /* ---------- Killer move ---------- */
569 .vsg-killer-card {
570 padding: 40px 32px;
571 border: 1px solid rgba(140,109,255,0.35);
572 border-radius: 18px;
573 background:
574 linear-gradient(135deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06)),
575 var(--bg-elevated);
576 text-align: center;
577 }
578 .vsg-killer-headline {
579 font-size: clamp(24px, 3.8vw, 40px);
580 line-height: 1.15;
581 margin: 12px 0 16px;
582 letter-spacing: -0.025em;
583 }
584 .vsg-killer-sub {
585 max-width: 620px;
586 margin: 0 auto 24px;
587 color: var(--text-muted);
588 line-height: 1.55;
589 font-size: 15px;
590 }
591 .vsg-killer-sub code {
592 background: rgba(255,255,255,0.06);
593 border: 1px solid rgba(255,255,255,0.10);
594 padding: 1px 6px;
595 border-radius: 4px;
596 font-size: 12.5px;
597 color: var(--accent);
598 }
599
600 /* ---------- FAQ ---------- */
601 .vsg-faq-grid {
602 margin-top: 32px;
603 display: grid;
604 grid-template-columns: repeat(2, 1fr);
605 gap: 18px;
606 }
607 .vsg-faq {
608 background: var(--bg-elevated);
609 border: 1px solid var(--border);
610 border-radius: 12px;
611 padding: 22px;
612 }
613 .vsg-faq-q {
614 margin: 0 0 10px;
615 font-size: 16px;
616 font-weight: 600;
617 color: var(--text-strong);
618 letter-spacing: -0.015em;
619 }
620 .vsg-faq-a {
621 margin: 0;
622 color: var(--text-muted);
623 font-size: 14px;
624 line-height: 1.6;
625 }
626 @media (max-width: 720px) {
627 .vsg-faq-grid { grid-template-columns: 1fr; }
628 }
629
630 /* ---------- CTA ---------- */
631 .vsg-cta-section {
632 text-align: center;
633 padding: 56px 24px;
634 background: var(--accent-gradient-soft, rgba(140,109,255,0.06));
635 border: 1px solid var(--border);
636 border-radius: 18px;
637 }
638 .vsg-cta-title {
639 font-size: clamp(26px, 4vw, 44px);
640 line-height: 1.1;
641 margin: 0 0 14px;
642 letter-spacing: -0.025em;
643 color: var(--text-strong);
644 }
645 .vsg-cta-sub {
646 max-width: 560px;
647 margin: 0 auto 28px;
648 color: var(--text-muted);
649 line-height: 1.55;
650 font-size: 15px;
651 }
652 .vsg-cta-buttons {
653 display: flex;
654 gap: 12px;
655 justify-content: center;
656 flex-wrap: wrap;
657 }
658`;
659
660export default vsGithub;
Modifiedsrc/routes/web.tsx+10−1View fileUnifiedSplit
4949import type { AuthEnv } from "../middleware/auth";
5050import { trackByName } from "../lib/traffic";
5151import { LandingPage } from "../views/landing";
52import { computePublicStats, type PublicStats } from "../lib/public-stats";
5253
5354const web = new Hono<AuthEnv>();
5455
6465 }
6566
6667 let stats: { publicRepos?: number; users?: number } | undefined;
68 let publicStats: PublicStats | null = null;
6769 try {
6870 const [repoRow] = await db
6971 .select({ n: sql<number>`count(*)::int` })
8082 stats = undefined;
8183 }
8284
85 // Block L4 — public stats counters (5-min in-memory cache; never throws).
86 try {
87 publicStats = await computePublicStats();
88 } catch {
89 publicStats = null;
90 }
91
8392 return c.html(
8493 <Layout user={null}>
85 <LandingPage stats={stats} />
94 <LandingPage stats={stats} publicStats={publicStats} />
8695 </Layout>
8796 );
8897});
Modifiedsrc/views/landing.tsx+186−1View fileUnifiedSplit
1010 */
1111
1212import type { FC } from "hono/jsx";
13import type { PublicStats } from "../lib/public-stats";
1314
1415export interface LandingPageProps {
1516 stats?: {
1617 publicRepos?: number;
1718 users?: number;
1819 };
20 /**
21 * Block L4 — full public-stats payload (lifetime + trailing-7-day
22 * AI-highlight counters). When present, the hero renders an animated
23 * six-tile social-proof row beneath the eyebrow.
24 */
25 publicStats?: PublicStats | null;
1926}
2027
21export const LandingHero: FC<LandingPageProps> = ({ stats } = {}) => {
28export const LandingHero: FC<LandingPageProps> = ({ stats, publicStats } = {}) => {
2229 const hasStats =
2330 stats &&
2431 ((stats.publicRepos !== undefined && stats.publicRepos > 0) ||
2532 (stats.users !== undefined && stats.users > 0));
2633
34 // Block L4 — six-tile social proof row. Rendered only when the
35 // cached public-stats payload is available; absent → fall back to
36 // the small text-only `landing-stats` row.
37 const tiles = publicStats
38 ? buildSocialProofTiles(publicStats)
39 : null;
40
2741 return (
2842 <>
2943 <style dangerouslySetInnerHTML={{ __html: landingCss }} />
6377 <a href="/explore" class="btn btn-secondary btn-xl">
6478 Explore repos
6579 </a>
80 <a href="/vs-github" class="btn btn-ghost btn-xl">
81 Compare to GitHub
82 </a>
6683 </div>
6784
6885 <p class="landing-hero-caption">
104121 </div>
105122 </section>
106123
124 {/* ---------- L4 social-proof counters (animated count-up) ---------- */}
125 {tiles && (
126 <section class="landing-counters" aria-label="Gluecron live counters">
127 <div class="landing-counters-grid">
128 {tiles.map((t) => (
129 <div class="landing-counter">
130 <div
131 class="landing-counter-num"
132 data-counter-target={String(t.value)}
133 data-counter-suffix={t.suffix ?? ""}
134 data-counter-prefix={t.prefix ?? ""}
135 >
136 {t.prefix ?? ""}
137 {t.value.toLocaleString()}
138 {t.suffix ?? ""}
139 </div>
140 <div class="landing-counter-label">{t.label}</div>
141 </div>
142 ))}
143 </div>
144 <script dangerouslySetInnerHTML={{ __html: landingCountersJs }} />
145 </section>
146 )}
147
107148 {/* ---------- Capability strip — uppercase tracked grid (crontech-style) ---------- */}
108149 <section class="landing-caps">
109150 <div class="landing-caps-grid">
452493 </div>
453494);
454495
496// ─────────────────────────────────────────────────────────────────
497// Block L4 — social-proof tile builder.
498//
499// Pure: takes the cached PublicStats payload and emits the six
500// landing-page tiles in render order. Exported so tests / future
501// surfaces (dashboard, /about, …) can share the exact same copy.
502// ─────────────────────────────────────────────────────────────────
503
504export interface SocialProofTile {
505 label: string;
506 value: number;
507 prefix?: string;
508 suffix?: string;
509}
510
511export function buildSocialProofTiles(s: PublicStats): SocialProofTile[] {
512 return [
513 { label: "Public repos", value: s.totalPublicRepos },
514 { label: "Developers", value: s.totalUsers },
515 {
516 label: "PRs auto-merged this week",
517 value: s.weeklyPrsAutoMerged,
518 },
519 {
520 label: "Issues built by AI this week",
521 value: s.weeklyIssuesBuiltByAi,
522 },
523 {
524 label: "Deploys shipped this week",
525 value: s.weeklyDeploysShipped,
526 },
527 {
528 label: "Hours saved this week",
529 // Round to whole hours for the tile — the precise 0.1 figure
530 // lives on the dashboard widget; the marketing surface keeps
531 // the number scannable.
532 value: Math.round(s.weeklyHoursSaved),
533 prefix: "~",
534 suffix: "h",
535 },
536 ];
537}
538
455539// Backwards-compatible default — web.tsx imports `LandingPage`.
456540export const LandingPage: FC<LandingPageProps> = (props) => (
457541 <LandingHero {...props} />
14131497 .landing-cta-card { padding: var(--s-10) var(--s-5); }
14141498 .landing-cta-buttons .btn { width: 100%; justify-content: center; }
14151499 }
1500
1501 /* ---------- L4 social-proof counters ---------- */
1502 .landing-counters {
1503 margin: var(--s-10) auto var(--s-12);
1504 max-width: 1180px;
1505 padding: 0 var(--s-4);
1506 }
1507 .landing-counters-grid {
1508 display: grid;
1509 grid-template-columns: repeat(6, 1fr);
1510 gap: 20px;
1511 text-align: left;
1512 }
1513 .landing-counter {
1514 padding: var(--s-3) 0;
1515 border-top: 1px solid var(--border-subtle);
1516 }
1517 .landing-counter-num {
1518 font-family: var(--font-display);
1519 font-size: clamp(24px, 3vw, 38px);
1520 line-height: 1.05;
1521 letter-spacing: -0.03em;
1522 font-weight: 700;
1523 margin-bottom: 6px;
1524 font-feature-settings: 'tnum';
1525 background-image: var(--accent-gradient);
1526 -webkit-background-clip: text;
1527 background-clip: text;
1528 -webkit-text-fill-color: transparent;
1529 color: transparent;
1530 }
1531 .landing-counter-label {
1532 font-family: var(--font-mono);
1533 font-size: 10.5px;
1534 font-weight: 500;
1535 text-transform: uppercase;
1536 letter-spacing: 0.12em;
1537 color: var(--text-faint);
1538 line-height: 1.4;
1539 }
1540 @media (max-width: 960px) {
1541 .landing-counters-grid { grid-template-columns: repeat(3, 1fr); gap: 20px 16px; }
1542 }
1543 @media (max-width: 540px) {
1544 .landing-counters-grid { grid-template-columns: repeat(2, 1fr); gap: 18px 12px; }
1545 }
1546`;
1547
1548/**
1549 * Block L4 — count-up animation.
1550 *
1551 * Reads each `[data-counter-target]` and animates the in-DOM text from
1552 * 0 → target over ~1.2s when the element first scrolls into view.
1553 *
1554 * Render-once semantics: each tile already contains the final value as
1555 * HTML, so visitors with JS disabled — or anyone before the script
1556 * loads — sees the correct number. The script just animates the text.
1557 *
1558 * Falls back to the static value (no animation) when IntersectionObserver
1559 * isn't available, or when the user prefers reduced motion.
1560 */
1561const landingCountersJs = `
1562(function(){
1563 try {
1564 var els = document.querySelectorAll('[data-counter-target]');
1565 if (!els.length) return;
1566 var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1567 if (reduced || typeof IntersectionObserver !== 'function') return;
1568
1569 function animate(el) {
1570 var target = parseInt(el.getAttribute('data-counter-target') || '0', 10);
1571 if (!isFinite(target) || target <= 0) return;
1572 var prefix = el.getAttribute('data-counter-prefix') || '';
1573 var suffix = el.getAttribute('data-counter-suffix') || '';
1574 var duration = 1200;
1575 var start = performance.now();
1576 function frame(now) {
1577 var t = Math.min(1, (now - start) / duration);
1578 // ease-out cubic
1579 var eased = 1 - Math.pow(1 - t, 3);
1580 var v = Math.floor(eased * target);
1581 el.textContent = prefix + v.toLocaleString() + suffix;
1582 if (t < 1) requestAnimationFrame(frame);
1583 else el.textContent = prefix + target.toLocaleString() + suffix;
1584 }
1585 // Reset to zero before animating in.
1586 el.textContent = prefix + '0' + suffix;
1587 requestAnimationFrame(frame);
1588 }
1589
1590 var io = new IntersectionObserver(function(entries) {
1591 entries.forEach(function(entry){
1592 if (entry.isIntersecting) {
1593 animate(entry.target);
1594 io.unobserve(entry.target);
1595 }
1596 });
1597 }, { threshold: 0.4 });
1598 els.forEach(function(el){ io.observe(el); });
1599 } catch (_) { /* swallow — static numbers remain */ }
1600})();
14161601`;
14171602