SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%

Dark mode (theme tokens, toggle, boot script), theme-aware chart palette, home hero/ticker, sitemap secondary nav, deploy manifest without ngrok

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent a2acf97

11 changed files +224 −36

modified CLAUDE.md +6 −4
@@ -40,8 +40,8 @@ packages/shared ids, provenance types, normalization, logger
40 40 packages/database Drizzle schema (snake_case casing), migrations, seed (metrics + geographies only)
41 41 packages/ontology qualifier rules, CancerResolver (alias/code reconciliation), TOP_LEVEL_CANCERS
42 42 packages/connectors SDK (manifest, HttpClient, RawLake, RunContext, validators) + connectors/<id>/{manifest.ts,index.ts,fixtures/,*.test.ts}
43 −packages/ranking counters (entity_counters), ranking engine (snapshots + lineage), trace
44 −scripts/ci.ts operator CLI: `pnpm cix connectors|run <id>|run-all|health|sources:sync|counters|rank|stats|trace`
43 +packages/ranking counters (entity_counters), intelligence (trial_intelligence, trial sites, drug_pipeline, research_gap_components), ranking engine (snapshots + lineage), trace
44 +scripts/ci.ts operator CLI: `pnpm cix connectors|run <id>|run-all|health|sources:sync|counters|intel|reconcile-drugs|rank|stats|trace|doctor|alerts`
45 45 docs/ architecture, data model, methodology, source policy, ADRs, connector docs
46 46 data/raw data lake (gitignored) — {source}/{date}/{entity}/{runId}-{part}.jsonl.gz
47 47 ```
@@ -78,5 +78,7 @@ pnpm dev:api & pnpm dev:web
78 78 ## Deployment
79 79 MacLustr cluster via `mld` (gateway M1M32): manifest `~/dispatch/apps/cancerindex.json`,
80 80 `mld stage <dir> cancerindex` then `mld deploy cancerindex`. Web :8250, API :8251, PM2 processes
81 −`cancerindex-web|api|worker|ngrok`, domain www.cancerindex.io (ngrok reserved). Postgres 17 +
82 −pgvector on the node, database `cancerindex`. Source of truth for the code: spbgit `cancerindex.git`.
81 +`cancerindex-web|api|worker|backup`, domain www.cancerindex.io served by the MacLustr Tunnel
82 +(WireGuard + Caddy on BHS64; no ngrok). Postgres 17 + pgvector on the node, database `cancerindex`.
83 +Source of truth for the code: spbgit `cancerindex.git`. Daily maintenance (UTC, worker): counters
84 +06:00 → intelligence 06:15 → rankings 06:30; `pnpm cix intel` recomputes the derived layer by hand.
modified apps/web/src/app/globals.css +97 −2
@@ -31,10 +31,88 @@
31 31 --color-ok: #2d5f2e;
32 32 --color-ok-soft: #e1ecdf;
33 33
34 + --color-series-1: #0f5f63;
35 + --color-series-2: #8a4b0a;
36 + --color-series-3: #4a4a46;
37 + --color-series-4: #2d5f2e;
38 + --color-series-5: #8b1e2d;
39 + --color-series-6: #7b7b75;
40 + --color-series-7: #5b3a8c;
41 + --color-series-8: #1f5fa8;
42 +
34 43 --radius-sm: 2px;
35 44 --radius-md: 3px;
36 45 }
37 46
47 +/*
48 + Dark mode (SPEC §86). Same tokens, inverted values; charts and badges reference the tokens only.
49 + `--color-white` is re-pointed so Tailwind's `bg-white` form controls follow the theme.
50 + Applied when the user chose "dark" (data-theme) or follows the OS preference (no explicit choice).
51 +*/
52 +:root[data-theme='dark'],
53 +:root:not([data-theme='light']):not([data-theme='dark']).ci-system-dark {
54 + color-scheme: dark;
55 + --color-paper: #151514;
56 + --color-paper-2: #1d1d1b;
57 + --color-paper-3: #282826;
58 + --color-ink: #ecece6;
59 + --color-ink-2: #c3c3bb;
60 + --color-ink-3: #8f8f88;
61 + --color-ink-4: #626259;
62 + --color-rule: #2f2f2c;
63 + --color-rule-strong: #47473f;
64 + --color-accent: #6cc3c6;
65 + --color-accent-2: #8fd3d5;
66 + --color-accent-soft: #16353a;
67 + --color-warn: #e0a860;
68 + --color-warn-soft: #3a2a13;
69 + --color-danger: #ee8a95;
70 + --color-danger-soft: #3f1c22;
71 + --color-ok: #93c692;
72 + --color-ok-soft: #1d301d;
73 + --color-white: #1d1d1b;
74 + --color-series-1: #6cc3c6;
75 + --color-series-2: #e0a860;
76 + --color-series-3: #b9b9b1;
77 + --color-series-4: #93c692;
78 + --color-series-5: #ee8a95;
79 + --color-series-6: #8f8f88;
80 + --color-series-7: #b39ddb;
81 + --color-series-8: #7fb2ea;
82 +}
83 +@media (prefers-color-scheme: dark) {
84 + :root:not([data-theme='light']) {
85 + color-scheme: dark;
86 + --color-paper: #151514;
87 + --color-paper-2: #1d1d1b;
88 + --color-paper-3: #282826;
89 + --color-ink: #ecece6;
90 + --color-ink-2: #c3c3bb;
91 + --color-ink-3: #8f8f88;
92 + --color-ink-4: #626259;
93 + --color-rule: #2f2f2c;
94 + --color-rule-strong: #47473f;
95 + --color-accent: #6cc3c6;
96 + --color-accent-2: #8fd3d5;
97 + --color-accent-soft: #16353a;
98 + --color-warn: #e0a860;
99 + --color-warn-soft: #3a2a13;
100 + --color-danger: #ee8a95;
101 + --color-danger-soft: #3f1c22;
102 + --color-ok: #93c692;
103 + --color-ok-soft: #1d301d;
104 + --color-white: #1d1d1b;
105 + --color-series-1: #6cc3c6;
106 + --color-series-2: #e0a860;
107 + --color-series-3: #b9b9b1;
108 + --color-series-4: #93c692;
109 + --color-series-5: #ee8a95;
110 + --color-series-6: #8f8f88;
111 + --color-series-7: #b39ddb;
112 + --color-series-8: #7fb2ea;
113 + }
114 +}
115 +
38 116 :root {
39 117 color-scheme: light;
40 118 --ci-header-h: 56px;
@@ -224,9 +302,9 @@ kbd,
224 302 top: calc(100% + 4px);
225 303 min-width: 260px;
226 304 max-width: 360px;
227 − background: #fff;
305 + background: var(--color-paper);
228 306 border: 1px solid var(--color-rule-strong);
229 − box-shadow: 0 6px 24px -10px rgba(0, 0, 0, 0.25);
307 + box-shadow: 0 6px 24px -10px rgba(0, 0, 0, 0.35);
230 308 padding: 10px 12px;
231 309 font-size: 12.5px;
232 310 line-height: 1.45;
@@ -600,3 +678,20 @@ abbr.no-underline {
600 678 .ci-evidence td.ev details p {
601 679 margin-top: 4px;
602 680 }
681 +
682 +/* Theme toggle (header) */
683 +.ci-theme-toggle {
684 + display: inline-flex;
685 + align-items: center;
686 + gap: 4px;
687 + border: 1px solid var(--color-rule);
688 + padding: 5px 7px;
689 + color: var(--color-ink-2);
690 + background: transparent;
691 + cursor: pointer;
692 + line-height: 1;
693 +}
694 +.ci-theme-toggle:hover {
695 + border-color: var(--color-accent);
696 + color: var(--color-accent);
697 +}
modified apps/web/src/app/layout.tsx +11 −2
@@ -22,12 +22,21 @@ export const metadata: Metadata = {
22 22 export const viewport: Viewport = {
23 23 width: 'device-width',
24 24 initialScale: 1,
25 − themeColor: '#fafaf7',
25 + themeColor: [
26 + { media: '(prefers-color-scheme: light)', color: '#fafaf7' },
27 + { media: '(prefers-color-scheme: dark)', color: '#151514' },
28 + ],
26 29 };
27 30
31 +/** Applies the stored theme before first paint (no flash); "system" = no attribute. */
32 +const THEME_BOOT = `try{var t=localStorage.getItem('ci-theme');if(t==='dark'||t==='light'){document.documentElement.setAttribute('data-theme',t)}}catch(e){}`;
33 +
28 34 export default function RootLayout({ children }: { children: React.ReactNode }) {
29 35 return (
30 − <html lang="en" className={`${display.variable} ${ui.variable} ${code.variable} h-full antialiased`}>
36 + <html lang="en" className={`${display.variable} ${ui.variable} ${code.variable} h-full antialiased`} suppressHydrationWarning>
37 + <head>
38 + <script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} />
39 + </head>
31 40 <body className="flex min-h-full flex-col">
32 41 <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:border focus:border-accent focus:bg-paper focus:px-3 focus:py-2 focus:text-sm">
33 42 Skip to content
modified apps/web/src/app/page.tsx +8 −8
@@ -30,11 +30,11 @@ export default async function HomePage() {
30 30 const topMatched = TOP_LEVEL_CANCERS.filter((t) => topLevel.has(t.ncit)).length;
31 31
32 32 const ticker: Array<{ label: string; value: number; note: string; href: string }> = [
33 − { label: 'Cancers indexed', value: counts.cancers, note: 'active malignant disease entities', href: '/cancers?malignant=1' },
34 − { label: 'Subtypes', value: counts.subtypes, note: 'histologic and molecular subtypes', href: '/cancers?entity_type=subtype' },
33 + { label: 'Cancers indexed', value: counts.cancers, note: 'active malignant disease entities (NCIt)', href: '/cancers?malignant=1' },
34 + { label: 'Active oncology trials', value: counts.activeTrials, note: 'interventional, recruiting or active (ClinicalTrials.gov)', href: '/trials?status=active' },
35 + { label: 'Recruiting Phase III', value: counts.recruitingPhase3, note: 'interventional Phase III studies recruiting now', href: '/trials?status=RECRUITING&phase=PHASE3' },
36 + { label: 'Approved oncology drugs', value: counts.approvedDrugs, note: `drugs with ≥ 1 approval record (${fmtInt(counts.approvals)} records, all authorities)`, href: '/approvals' },
35 37 { label: 'Genes', value: counts.genes, note: 'HGNC gene records', href: '/genes' },
36 − { label: 'Variants', value: counts.variants, note: 'distinct variant entities', href: '/genes' },
37 − { label: 'Clinical trials', value: counts.trials, note: 'ClinicalTrials.gov studies ingested', href: '/trials' },
38 38 { label: 'Publications', value: counts.publications, note: 'PubMed records linked', href: '/search' },
39 39 { label: 'Sources', value: counts.sources, note: `${counts.activeSources} active connector${counts.activeSources === 1 ? '' : 's'}`, href: '/sources' },
40 40 ];
@@ -43,17 +43,17 @@ export default async function HomePage() {
43 43 <div className="pb-8">
44 44 {/* Hero */}
45 45 <section className="pt-10 sm:pt-16" aria-labelledby="hero-title">
46 − <p className="ci-kicker mb-3">Provenance-first oncology intelligence</p>
46 + <p className="ci-kicker mb-3">Global cancer intelligence</p>
47 47 <h1 id="hero-title" className="max-w-4xl text-4xl leading-[1.05] sm:text-6xl">
48 − CancerIndex — <span className="italic text-ink-2">The global index of cancer.</span>
48 + Understand cancer <span className="italic text-ink-2">through data.</span>
49 49 </h1>
50 50 <p className="mt-4 max-w-2xl text-[16px] leading-relaxed text-ink-2">
51 − Every recognized malignant disease entity — taxonomy, epidemiology, genomics, variants, therapies, trials, literature and transparent rankings — with a source, a date and a formula version on every number.
51 + CancerIndex connects epidemiology, survival, genomics, treatments, clinical trials, research and regulatory decisions across every recognized cancer entity — with a source, a date and a formula version on every number.
52 52 </p>
53 53 <div className="mt-6 max-w-2xl">
54 54 <CommandPalette variant="hero" />
55 55 <p className="mt-2 text-[12.5px] text-ink-3">
56 − Try <Link className="ci-link" href="/search?q=glioma">glioma</Link>, <Link className="ci-link" href="/search?q=KRAS">KRAS</Link>, <Link className="ci-link" href="/search?q=osimertinib">osimertinib</Link> or an NCT number.
56 + Try <Link className="ci-link" href="/search?q=glioma">glioma</Link>, <Link className="ci-link" href="/search?q=KRAS">KRAS</Link>, <Link className="ci-link" href="/search?q=osimertinib">osimertinib</Link> or an NCT number · or open the <Link className="ci-link" href="/explore">Data explorer</Link>, the <Link className="ci-link" href="/trials/intelligence">trial intelligence</Link> table or the <Link className="ci-link" href="/graph">knowledge graph</Link>.
57 57 </p>
58 58 </div>
59 59 </section>
modified apps/web/src/components/charts/line-chart.tsx +1 −1
@@ -6,7 +6,7 @@ export interface Series {
6 6 dashed?: boolean; // e.g. estimated vs observed
7 7 }
8 8
9 −const SERIES_COLORS = ['var(--color-accent)', '#8a4b0a', '#4a4a46', '#2d5f2e', '#8b1e2d', '#7b7b75'];
9 +const SERIES_COLORS = ['var(--color-series-1)', 'var(--color-series-2)', 'var(--color-series-3)', 'var(--color-series-4)', 'var(--color-series-5)', 'var(--color-series-6)', 'var(--color-series-7)', 'var(--color-series-8)'];
10 10
11 11 /**
12 12 * Time-series line chart in pure SVG: years on x, values on y, optional confidence band.
modified apps/web/src/components/charts/trend-chart.tsx +1 −1
@@ -12,7 +12,7 @@ export interface TrendSeries {
12 12 * Eight-way colour set that stays distinguishable in print and for common colour-vision deficiencies;
13 13 * shape (dash) and the legend label carry the meaning too — colour is never the only carrier.
14 14 */
15 −const TREND_COLORS = ['#0f5f63', '#8a4b0a', '#8b1e2d', '#2d5f2e', '#4a4a46', '#5b3a8c', '#b5651d', '#1f5fa8'];
15 +const TREND_COLORS = ['var(--color-series-1)', 'var(--color-series-2)', 'var(--color-series-5)', 'var(--color-series-4)', 'var(--color-series-3)', 'var(--color-series-7)', 'var(--color-series-6)', 'var(--color-series-8)'];
16 16
17 17 /**
18 18 * Multi-series yearly line chart in pure SVG for country trend panels (up to 8 series). Zero-based y axis,
modified apps/web/src/components/layout/site-header.tsx +2 −0
@@ -2,6 +2,7 @@ import Link from 'next/link';
2 2 import { NAV } from '@/lib/site';
3 3 import { CommandPalette } from './command-palette';
4 4 import { MobileNav } from './mobile-nav';
5 +import { ThemeToggle } from './theme-toggle';
5 6
6 7 export function SiteHeader() {
7 8 return (
@@ -20,6 +21,7 @@ export function SiteHeader() {
20 21 </nav>
21 22 <div className="ml-auto flex items-center gap-2">
22 23 <CommandPalette />
24 + <ThemeToggle />
23 25 <MobileNav />
24 26 </div>
25 27 </div>
added apps/web/src/components/layout/theme-toggle.tsx +40 −0
@@ -0,0 +1,40 @@
1 +'use client';
2 +
3 +import { useEffect, useState } from 'react';
4 +import { Moon, Sun, Monitor } from 'lucide-react';
5 +
6 +type Theme = 'light' | 'dark' | 'system';
7 +const KEY = 'ci-theme';
8 +
9 +/**
10 + * Light / dark / system toggle (SPEC §86). The choice is stored in localStorage and applied to
11 + * <html data-theme> before first paint by the inline script in layout.tsx, so there is no flash.
12 + * "system" removes the attribute and lets the prefers-color-scheme media query decide.
13 + */
14 +export function ThemeToggle() {
15 + const [theme, setTheme] = useState<Theme>('system');
16 + useEffect(() => {
17 + const stored = (typeof window !== 'undefined' && window.localStorage.getItem(KEY)) as Theme | null;
18 + if (stored === 'light' || stored === 'dark') setTheme(stored);
19 + }, []);
20 + const apply = (t: Theme) => {
21 + setTheme(t);
22 + const root = document.documentElement;
23 + if (t === 'system') {
24 + window.localStorage.removeItem(KEY);
25 + root.removeAttribute('data-theme');
26 + } else {
27 + window.localStorage.setItem(KEY, t);
28 + root.setAttribute('data-theme', t);
29 + }
30 + };
31 + const next: Theme = theme === 'system' ? 'dark' : theme === 'dark' ? 'light' : 'system';
32 + const label = theme === 'system' ? 'Theme: system' : theme === 'dark' ? 'Theme: dark' : 'Theme: light';
33 + const Icon = theme === 'system' ? Monitor : theme === 'dark' ? Moon : Sun;
34 + return (
35 + <button type="button" className="ci-theme-toggle" onClick={() => apply(next)} aria-label={`${label}. Switch to ${next}.`} title={`${label} — click for ${next}`}>
36 + <Icon className="h-3.5 w-3.5" aria-hidden />
37 + <span className="hidden text-[11px] sm:inline">{theme === 'system' ? 'Auto' : theme === 'dark' ? 'Dark' : 'Light'}</span>
38 + </button>
39 + );
40 +}
modified apps/web/src/lib/queries/stats.ts +13 −1
@@ -8,6 +8,10 @@ export interface SiteCounts {
8 8 genes: number;
9 9 variants: number;
10 10 trials: number;
11 + activeTrials: number; // interventional, active statuses
12 + recruitingPhase3: number;
13 + approvedDrugs: number; // distinct drugs with ≥ 1 approval (approved/accelerated/conditional) in any jurisdiction
14 + approvals: number; // approval records across authorities
11 15 publications: number;
12 16 drugs: number;
13 17 sources: number;
@@ -17,7 +21,7 @@ export interface SiteCounts {
17 21 lastIngestAt: Date | null;
18 22 }
19 23
20 −const EMPTY: SiteCounts = { cancers: 0, subtypes: 0, allEntities: 0, genes: 0, variants: 0, trials: 0, publications: 0, drugs: 0, sources: 0, activeSources: 0, evidenceItems: 0, epidemiologyObservations: 0, lastIngestAt: null };
24 +const EMPTY: SiteCounts = { cancers: 0, subtypes: 0, allEntities: 0, genes: 0, variants: 0, trials: 0, activeTrials: 0, recruitingPhase3: 0, approvedDrugs: 0, approvals: 0, publications: 0, drugs: 0, sources: 0, activeSources: 0, evidenceItems: 0, epidemiologyObservations: 0, lastIngestAt: null };
21 25
22 26 /** Live counts for the home ticker. Each label on the page states exactly what is counted (§248-249). */
23 27 export async function getSiteCounts(): Promise<SiteCounts> {
@@ -30,6 +34,10 @@ export async function getSiteCounts(): Promise<SiteCounts> {
30 34 (SELECT count(*) FROM genes) AS genes,
31 35 (SELECT count(*) FROM variants) AS variants,
32 36 (SELECT count(*) FROM clinical_trials) AS trials,
37 + (SELECT count(*) FROM clinical_trials WHERE study_type = 'INTERVENTIONAL' AND overall_status IN ('RECRUITING','NOT_YET_RECRUITING','ENROLLING_BY_INVITATION','ACTIVE_NOT_RECRUITING')) AS active_trials,
38 + (SELECT count(*) FROM clinical_trials WHERE study_type = 'INTERVENTIONAL' AND overall_status = 'RECRUITING' AND 'PHASE3' = ANY(phases)) AS recruiting_phase3,
39 + (SELECT count(DISTINCT drug_id) FROM drug_approvals WHERE status IN ('approved','accelerated','conditional')) AS approved_drugs,
40 + (SELECT count(*) FROM drug_approvals) AS approvals,
33 41 (SELECT count(*) FROM publications) AS publications,
34 42 (SELECT count(*) FROM drugs) AS drugs,
35 43 (SELECT count(*) FROM sources) AS sources,
@@ -46,6 +54,10 @@ export async function getSiteCounts(): Promise<SiteCounts> {
46 54 genes: Number(r.genes),
47 55 variants: Number(r.variants),
48 56 trials: Number(r.trials),
57 + activeTrials: Number(r.active_trials),
58 + recruitingPhase3: Number(r.recruiting_phase3),
59 + approvedDrugs: Number(r.approved_drugs),
60 + approvals: Number(r.approvals),
49 61 publications: Number(r.publications),
50 62 drugs: Number(r.drugs),
51 63 sources: Number(r.sources),
modified apps/web/src/lib/sitemap.ts +3 −2
@@ -1,5 +1,5 @@
1 1 import 'server-only';
2 −import { SITE_URL, NAV, FOOTER_NAV } from '@/lib/site';
2 +import { SITE_URL, NAV, MORE_NAV, FOOTER_NAV } from '@/lib/site';
3 3 import { cancerSlugsForSitemap, countActiveCancers } from '@/lib/queries/cancers';
4 4 import { geneSymbolsForSitemap, countGenes } from '@/lib/queries/genomics';
5 5 import { drugSlugsForSitemap, countDrugs } from '@/lib/queries/drugs';
@@ -48,7 +48,8 @@ export async function sitemapEntries(id: number): Promise<Entry[]> {
48 48 return [
49 49 { url: SITE_URL, lastModified: now, changeFrequency: 'daily', priority: 1 },
50 50 ...NAV.map((n) => ({ url: `${SITE_URL}${n.href}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.8 })),
51 − ...FOOTER_NAV.filter((f) => !NAV.some((n) => n.href === f.href)).map((n) => ({ url: `${SITE_URL}${n.href}`, lastModified: now, changeFrequency: 'weekly' as const, priority: 0.5 })),
51 + ...MORE_NAV.filter((f) => !NAV.some((n) => n.href === f.href)).map((n) => ({ url: `${SITE_URL}${n.href}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.7 })),
52 + ...FOOTER_NAV.filter((f) => !NAV.some((n) => n.href === f.href) && !MORE_NAV.some((m) => m.href === f.href)).map((n) => ({ url: `${SITE_URL}${n.href}`, lastModified: now, changeFrequency: 'weekly' as const, priority: 0.5 })),
52 53 ...sources.map((s) => ({ url: `${SITE_URL}/source/${s.slug}`, lastModified: toDate(s.updated_at) ?? now, changeFrequency: 'weekly' as const, priority: 0.6 })),
53 54 ...metrics.filter((m) => m.snapshot_count > 0).map((m) => ({ url: `${SITE_URL}/rankings/${m.slug}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.7 })),
54 55 ];
modified deploy/mld-manifest.cancerindex.json +42 −15
@@ -21,23 +21,30 @@
21 21 "apps/web/.next/"
22 22 ],
23 23 "requires": {
24 − "runtimes": ["pm2", "ngrok", "node", "pnpm", "postgresql@17"],
24 + "runtimes": [
25 + "pm2",
26 + "node",
27 + "pnpm",
28 + "postgresql@17"
29 + ],
25 30 "ram_gb": 6,
26 − "ports": [8250, 8251]
31 + "ports": [
32 + 8250,
33 + 8251
34 + ]
27 35 },
28 − "ram_mb_observed": null,
29 − "size_mb": null,
36 + "ram_mb_observed": 2500,
37 + "size_mb": 600,
30 38 "placement": {
31 39 "pin": null,
32 40 "prefer": "M4M64b",
33 − "avoid": ["M3U96b", "M1M32"],
41 + "avoid": [
42 + "M3U96b",
43 + "M1M32"
44 + ],
34 45 "reason": "M4M64b already runs Postgres 17 + pgvector, pnpm and redis with 772 GB of disk for the raw lake; M3U96b is reserved for hfmarketdata and M1M32 is the gateway."
35 46 },
36 − "ngrok": {
37 − "name": "cancerindex-ngrok",
38 − "url": "www.cancerindex.io",
39 − "port": 8250
40 − },
47 + "ngrok": null,
41 48 "launchd": [],
42 49 "env_overrides": {},
43 50 "hooks": {
@@ -62,7 +69,14 @@
62 69 "name": "cancerindex-web",
63 70 "manager": "pm2",
64 71 "script": "/opt/homebrew/bin/node",
65 − "args": ["node_modules/next/dist/bin/next", "start", "-p", "8250", "-H", "0.0.0.0"],
72 + "args": [
73 + "node_modules/next/dist/bin/next",
74 + "start",
75 + "-p",
76 + "8250",
77 + "-H",
78 + "0.0.0.0"
79 + ],
66 80 "interpreter": null,
67 81 "cwd": "{{HOME}}/apps/cancerindex/apps/web",
68 82 "env": {
@@ -88,7 +102,10 @@
88 102 "name": "cancerindex-api",
89 103 "manager": "pm2",
90 104 "script": "/opt/homebrew/bin/node",
91 − "args": ["node_modules/tsx/dist/cli.mjs", "apps/api/src/server.ts"],
105 + "args": [
106 + "node_modules/tsx/dist/cli.mjs",
107 + "apps/api/src/server.ts"
108 + ],
92 109 "interpreter": null,
93 110 "cwd": "{{HOME}}/apps/cancerindex",
94 111 "env": {
@@ -112,7 +129,10 @@
112 129 "name": "cancerindex-worker",
113 130 "manager": "pm2",
114 131 "script": "/opt/homebrew/bin/node",
115 − "args": ["node_modules/tsx/dist/cli.mjs", "workers/main.ts"],
132 + "args": [
133 + "node_modules/tsx/dist/cli.mjs",
134 + "workers/main.ts"
135 + ],
116 136 "interpreter": null,
117 137 "cwd": "{{HOME}}/apps/cancerindex",
118 138 "env": {
@@ -136,7 +156,9 @@
136 156 "name": "cancerindex-backup",
137 157 "manager": "pm2",
138 158 "script": "/bin/bash",
139 − "args": ["deploy/backup.sh"],
159 + "args": [
160 + "deploy/backup.sh"
161 + ],
140 162 "interpreter": null,
141 163 "cwd": "{{HOME}}/apps/cancerindex",
142 164 "env": {
@@ -151,5 +173,10 @@
151 173 "autorestart": false,
152 174 "max_memory_restart": null
153 175 }
154 − ]
176 + ],
177 + "tunnel": {
178 + "domain": "www.cancerindex.io",
179 + "gateway": "BHS64",
180 + "note": "MacLustr Tunnel (WireGuard + Caddy on BHS64) since 2026-09-10; ngrok retired"
181 + }
155 182 }
156 183