seo-intel 1.5.38 → 1.5.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.40 (2026-05-28)
4
+
5
+ ### Setup wizard — daily notifications now self-install
6
+ The notify loop is now opt-in via a single click. Setup-wizard's Done step gains a small "Daily problem notifications" card that toggles a managed `crontab` line via `lib/cron.js`. No more manual `crontab -e`.
7
+
8
+ - **New CLI:** `seo-intel install-cron [--schedule "0 9 * * *"] [--open] [--remove]` — install / replace / remove a managed cron line. Idempotent. macOS + Linux. Windows returns a clear "use Task Scheduler" message.
9
+ - **New library:** `lib/cron.js` exports `installNotifyCron`, `removeNotifyCron`, `getNotifyCronStatus`. Uses a `# managed-by-seo-intel` marker comment so we only touch our own entries — the user's other cron jobs are never modified.
10
+ - **Setup wizard card** (Step 6, Done): live status pill, one-click Enable/Disable button, default schedule 9am daily, intel-blue accent matching the visual brief tokens. Polls `/api/setup/cron` on step entry; toggle hits `/api/setup/cron POST { action: 'install'|'remove' }`.
11
+ - Cron line writes the absolute `process.execPath` so the scheduled job works regardless of the user's PATH inside cron's minimal environment (a classic foot-gun avoided).
12
+
13
+ Full lifecycle verified: status read → install (`0 9 * * *` line written, crontab confirms) → status reports installed → remove → crontab clean → status reports clean. Smoke 10/10.
14
+
15
+ ## 1.5.39 (2026-05-27)
16
+
17
+ ### Dashboard — Problems card as the landing surface (Ahrefs-style "what's broken")
18
+ The biggest UX shift since MCP shipped. Opening the dashboard now greets the user with a unified Problems card at the very top of every project panel — same data backing the `list_problems` MCP tool, finally surfaced for humans too.
19
+
20
+ - **New `buildProblemsCard()`** renders Ahrefs-style: big counters (Critical / Warn / Info) using the v1.5.33 visual-brief `.vb-score-big` numerals, top 12 issues table with severity dots, category, fix-difficulty stars (1–5), and an expandable "Fix" disclosure per row showing the agent-friendly `fix_template`.
21
+ - Single source of truth: same `getProblems()` library function that powers `list_problems` MCP tool. Dashboard and AI agents see identical data; closing one closes both.
22
+ - "Showing top 12 of 190 — query the rest via MCP: `list_problems("carbium", limit=190)`" — makes the agent escape hatch visible from the dashboard itself.
23
+ - Empty state: "all clear" message when no problems pending.
24
+
25
+ ### Dashboard — AI Citability card polished to brief spec
26
+ - Inline colors (`#4ade80`, `#facc15`, `#ff8c00`, `#ef4444`) swapped to brief signal tokens (`var(--signal-good)`, `var(--signal-warn)`, `var(--signal-bad)`). One color system, no drift.
27
+ - Score gradient aligned with `lib/problems.js` severity buckets: ≥60 good, 35–59 warn, <35 bad.
28
+ - New `.vb-pill` header chip with the "weakest signal" caption ("weakest: answer density") so the user sees the headline takeaway at a glance.
29
+ - Existing signal bars + page-score table preserved — minimal disruption, maximum polish.
30
+
31
+ **Verified live against carbium / risunouto / ukkometa:** 36 severity dots rendered across three Problems cards, 3 MCP-hint references, citability cards on each pro panel, no existing functionality broken. Smoke 10/10. HTML size unchanged (2.4MB).
32
+
33
+ Next: setup-wizard cron-entry installer (v1.5.40), then per-page polish for Site Watch timeline / Competitive Radar / Action Export modal.
34
+
3
35
  ## 1.5.38 (2026-05-23)
4
36
 
5
37
  ### Fix — LM Studio model count was always 0 (wrong endpoint + wrong parser)
package/cli.js CHANGED
@@ -1511,6 +1511,41 @@ program
1511
1511
  if (fired === 0) console.log(chalk.dim(' ✓ No projects need attention.'));
1512
1512
  });
1513
1513
 
1514
+ // ── INSTALL-CRON (one-shot cron entry for daily notify) ────────────────────
1515
+ program
1516
+ .command('install-cron')
1517
+ .description('Install / remove the daily seo-intel notify cron entry (macOS + Linux)')
1518
+ .option('--remove', 'Remove the managed cron entry instead of installing')
1519
+ .option('--schedule <cron>', 'Cron schedule, 5 fields (default: 9am daily)', '0 9 * * *')
1520
+ .option('--open', 'Pass --open to the scheduled notify command (dashboard opens when fired)')
1521
+ .action(async (opts) => {
1522
+ const { installNotifyCron, removeNotifyCron, getNotifyCronStatus, DEFAULT_SCHEDULE } = await import('./lib/cron.js');
1523
+ if (opts.remove) {
1524
+ const r = removeNotifyCron();
1525
+ if (!r.ok) { console.error(chalk.red(`Failed: ${r.error}`)); process.exit(1); }
1526
+ console.log(chalk.green(r.removed ? ' ✓ Removed managed cron entry.' : ' (Nothing to remove.)'));
1527
+ return;
1528
+ }
1529
+ const status = getNotifyCronStatus();
1530
+ if (status.platform === 'win32') {
1531
+ console.log(chalk.yellow(' ⚠ Windows: use Task Scheduler manually.'));
1532
+ console.log(chalk.dim(' Daily task command:'));
1533
+ console.log(chalk.dim(` ${process.execPath} ${join(__dirname, 'cli.js')} notify`));
1534
+ return;
1535
+ }
1536
+ if (status.installed) {
1537
+ console.log(chalk.dim(` Existing entry detected (schedule: ${status.schedule}). Replacing…`));
1538
+ }
1539
+ const result = installNotifyCron({ schedule: opts.schedule, openOnFire: !!opts.open });
1540
+ if (!result.ok) { console.error(chalk.red(`Failed: ${result.error}`)); process.exit(1); }
1541
+ console.log(chalk.green(` ✓ Daily notification scheduled (cron: ${result.schedule})`));
1542
+ console.log(chalk.dim(` Cron line: ${result.line}`));
1543
+ console.log(chalk.dim(` Remove with: seo-intel install-cron --remove`));
1544
+ if (process.platform === 'darwin') {
1545
+ console.log(chalk.dim(` Note: macOS may prompt to authorise cron access on first fire — approve in System Settings if so.`));
1546
+ }
1547
+ });
1548
+
1514
1549
  // ── STATUS ─────────────────────────────────────────────────────────────────
1515
1550
  program
1516
1551
  .command('status')
package/lib/cron.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * lib/cron.js — Install / remove the daily `seo-intel notify` cron entry.
3
+ *
4
+ * The "user forgets to check SEO" defense from v1.5.34's delivery brainstorm.
5
+ * Adds a single managed crontab line tagged with a marker comment so we can
6
+ * find and replace/remove our own entry without touching the user's other
7
+ * cron jobs.
8
+ *
9
+ * macOS + Linux: uses crontab(1). On macOS the first install will prompt the
10
+ * user to approve calendar/automation access via the system permission dialog
11
+ * — that's normal, nothing we can do about it.
12
+ *
13
+ * Windows: returns ok:false with a hint pointing at Task Scheduler. Out of
14
+ * scope for v1.5.40.
15
+ */
16
+
17
+ import { spawnSync } from 'child_process';
18
+ import { fileURLToPath } from 'url';
19
+ import { dirname, join } from 'path';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const ROOT = join(__dirname, '..');
23
+ const NODE_BIN = process.execPath;
24
+ const MARKER = '# managed-by-seo-intel';
25
+
26
+ export const DEFAULT_SCHEDULE = '0 9 * * *'; // 9am every day
27
+
28
+ function readCrontab() {
29
+ const r = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
30
+ if (r.status === 0) return r.stdout || '';
31
+ // status !== 0 typically means "no crontab for user yet" — return empty
32
+ return '';
33
+ }
34
+
35
+ function writeCrontab(content) {
36
+ const text = (content || '').replace(/\n*$/, '\n'); // ensure single trailing newline
37
+ const r = spawnSync('crontab', ['-'], { input: text, encoding: 'utf8' });
38
+ if (r.status !== 0) {
39
+ throw new Error(`crontab write failed: ${r.stderr || 'unknown error'}`);
40
+ }
41
+ }
42
+
43
+ function isWindows() { return process.platform === 'win32'; }
44
+
45
+ /**
46
+ * @returns {{ installed: boolean, line: string|null, schedule: string|null, platform: string }}
47
+ */
48
+ export function getNotifyCronStatus() {
49
+ if (isWindows()) return { installed: false, line: null, schedule: null, platform: 'win32' };
50
+ const lines = readCrontab().split('\n').filter(l => l.includes(MARKER));
51
+ if (!lines.length) return { installed: false, line: null, schedule: null, platform: process.platform };
52
+ const line = lines[0];
53
+ // Schedule is the first 5 space-separated fields
54
+ const parts = line.trim().split(/\s+/);
55
+ const schedule = parts.slice(0, 5).join(' ');
56
+ return { installed: true, line, schedule, platform: process.platform };
57
+ }
58
+
59
+ /**
60
+ * Install (or replace) the managed cron line.
61
+ *
62
+ * @param {object} [opts]
63
+ * @param {string} [opts.schedule] Cron schedule, default DEFAULT_SCHEDULE (9am daily)
64
+ * @param {boolean} [opts.openOnFire] Append `--open` flag so the dashboard opens when fired
65
+ * @returns {{ ok: boolean, line?: string, schedule?: string, error?: string, hint?: string }}
66
+ */
67
+ export function installNotifyCron({ schedule = DEFAULT_SCHEDULE, openOnFire = false } = {}) {
68
+ if (isWindows()) {
69
+ return {
70
+ ok: false,
71
+ error: 'Windows not supported — use Task Scheduler manually',
72
+ hint: `Create a daily task running: ${NODE_BIN} "${join(ROOT, 'cli.js')}" notify`,
73
+ };
74
+ }
75
+ // Sanity-check schedule (5 fields, no shell metachars)
76
+ if (!/^[-*\/0-9, ]+$/.test(schedule) || schedule.trim().split(/\s+/).length !== 5) {
77
+ return { ok: false, error: `Invalid cron schedule "${schedule}". Expected 5 fields, e.g. "0 9 * * *".` };
78
+ }
79
+ const cmd = `cd ${ROOT} && ${NODE_BIN} cli.js notify${openOnFire ? ' --open' : ''}`;
80
+ const newLine = `${schedule} ${cmd} ${MARKER}`;
81
+ const current = readCrontab();
82
+ const kept = current.split('\n').filter(l => l && !l.includes(MARKER));
83
+ kept.push(newLine);
84
+ try {
85
+ writeCrontab(kept.join('\n'));
86
+ return { ok: true, line: newLine, schedule };
87
+ } catch (e) {
88
+ return { ok: false, error: e.message };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Remove the managed cron line (if any). Idempotent.
94
+ * @returns {{ ok: boolean, removed: boolean, error?: string }}
95
+ */
96
+ export function removeNotifyCron() {
97
+ if (isWindows()) return { ok: true, removed: false }; // nothing to remove
98
+ const current = readCrontab();
99
+ const before = current.split('\n').filter(Boolean).length;
100
+ const kept = current.split('\n').filter(l => l && !l.includes(MARKER));
101
+ if (kept.length === before) return { ok: true, removed: false };
102
+ try {
103
+ writeCrontab(kept.join('\n'));
104
+ return { ok: true, removed: true };
105
+ } catch (e) {
106
+ return { ok: false, removed: false, error: e.message };
107
+ }
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.38",
3
+ "version": "1.5.40",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -21,6 +21,7 @@ import { isPro } from '../lib/license.js';
21
21
  import { getActiveInsights } from '../db/db.js';
22
22
  import { getCitabilityScores } from '../analyses/aeo/index.js';
23
23
  import { getWatchData } from '../analyses/watch/index.js';
24
+ import { getProblems, getProblemCounts } from '../lib/problems.js';
24
25
 
25
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
27
 
@@ -127,6 +128,14 @@ export function gatherProjectData(db, project, config) {
127
128
  let citabilityData = null;
128
129
  try { citabilityData = getCitabilityScores(db, project); } catch { /* table may not exist yet */ }
129
130
 
131
+ // Problems (v1.5.39) — unified Ahrefs-style "what's broken" feed
132
+ let problems = [];
133
+ let problemCounts = null;
134
+ try {
135
+ problems = getProblems(db, project, { includePaid: isPro(), limit: 200 });
136
+ problemCounts = getProblemCounts(db, project, { includePaid: isPro() });
137
+ } catch { /* fresh DB / migration not run yet — silent */ }
138
+
130
139
  // Site Watch data
131
140
  let watchData = null;
132
141
  try { watchData = getWatchData(db, project); } catch { /* tables may not exist yet */ }
@@ -155,6 +164,7 @@ export function gatherProjectData(db, project, config) {
155
164
  gravityMap, contentTerrain, keywordVenn, performanceBubbles,
156
165
  headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
157
166
  keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData, watchData,
167
+ problems, problemCounts,
158
168
  };
159
169
 
160
170
  // Rollback the owned→target merge so the actual DB is unchanged
@@ -227,6 +237,7 @@ function buildHtmlTemplate(data, opts = {}) {
227
237
  gravityMap, contentTerrain, keywordVenn, performanceBubbles,
228
238
  headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
229
239
  keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData, watchData,
240
+ problems = [], problemCounts = null,
230
241
  } = data;
231
242
 
232
243
  const totalPages = domains.reduce((sum, d) => sum + d.page_count, 0);
@@ -2979,6 +2990,9 @@ function buildHtmlTemplate(data, opts = {}) {
2979
2990
 
2980
2991
  <div class="dashboard">
2981
2992
 
2993
+ <!-- ═══ PROBLEMS (v1.5.39 — Ahrefs-style landing card) ═══ -->
2994
+ ${buildProblemsCard(problems, problemCounts, escapeHtml, project)}
2995
+
2982
2996
  <!-- ═══ GSC PERFORMANCE TREND ═══ -->
2983
2997
  ${gscData ? (() => {
2984
2998
  const s = gscData.summary;
@@ -5849,6 +5863,115 @@ function buildMultiHtmlTemplate(allProjectData) {
5849
5863
 
5850
5864
  // ─── AEO Card Builder ────────────────────────────────────────────────────────
5851
5865
 
5866
+ // ─── Problems card (v1.5.39) — Ahrefs-style unified "what's broken" feed ──
5867
+ // Uses lib/problems.js getProblems() — single source of truth shared with MCP.
5868
+ function buildProblemsCard(problems, counts, escapeHtml, project) {
5869
+ if (!counts || counts.total === 0) {
5870
+ return `
5871
+ <div class="card full-width vb-card" id="problems-card" style="margin-bottom: 24px;">
5872
+ <div style="display:flex; align-items:center; gap:14px; margin-bottom:14px;">
5873
+ <span class="vb-pill">Problems</span>
5874
+ <span class="vb-label-caps">all clear</span>
5875
+ </div>
5876
+ <div style="color: var(--text-muted); font-size: 0.85rem;">
5877
+ <i class="fa-solid fa-check" style="color: var(--signal-good); margin-right: 6px;"></i>
5878
+ No pending issues detected for this project. Run a fresh crawl to refresh detection.
5879
+ </div>
5880
+ </div>`;
5881
+ }
5882
+
5883
+ const sev = (s) => s === 'critical' ? 'crit' : s === 'warn' ? 'warn' : 'info';
5884
+ const sevColor = (s) => s === 'critical' ? 'var(--signal-bad)' : s === 'warn' ? 'var(--signal-warn)' : 'var(--signal-good)';
5885
+ const sevLabel = (s) => s.toUpperCase();
5886
+ const diffStars = (n) => '●'.repeat(Math.max(1, Math.min(5, n))) + '○'.repeat(5 - Math.max(1, Math.min(5, n)));
5887
+
5888
+ const top = problems.slice(0, 12);
5889
+ const remaining = problems.length - top.length;
5890
+
5891
+ const rows = top.map(p => {
5892
+ const sevClass = sev(p.severity);
5893
+ const sevCol = sevColor(p.severity);
5894
+ const fix = (p.fix_template || '').slice(0, 200);
5895
+ const urls = (p.affected_urls || []).slice(0, 3).map(u => {
5896
+ try { return new URL(u).pathname || u; } catch { return u.slice(0, 50); }
5897
+ });
5898
+ return `
5899
+ <tr data-problem-id="${escapeHtml(p.id)}">
5900
+ <td style="vertical-align:top; padding-top: 14px;">
5901
+ <span class="vb-severity-dot ${sevClass}"></span>
5902
+ </td>
5903
+ <td style="vertical-align:top;">
5904
+ <div style="font-family: var(--font-display); font-weight: 700; font-size: 0.92rem; color: var(--text-primary); line-height: 1.3;">${escapeHtml(p.title)}</div>
5905
+ <div style="font-size: 0.72rem; color: var(--text-muted); margin-top: 4px; line-height: 1.5;">${escapeHtml(p.description)}</div>
5906
+ ${urls.length ? `<div class="vb-num-tabular" style="font-size: 0.68rem; color: var(--text-subtle); margin-top: 4px;">${urls.map(u => `<code style="background:transparent;">${escapeHtml(u)}</code>`).join(' · ')}</div>` : ''}
5907
+ </td>
5908
+ <td style="vertical-align:top; padding-top: 14px;">
5909
+ <span class="vb-label-caps" style="color: ${sevCol};">${sevLabel(p.severity)}</span>
5910
+ <div style="font-size: 0.65rem; color: var(--text-muted); margin-top: 2px;">${escapeHtml(p.category)}</div>
5911
+ </td>
5912
+ <td style="vertical-align:top; padding-top: 14px;" title="Fix difficulty: ${p.fix_difficulty}/5">
5913
+ <span style="color: var(--intel-blue); font-size: 0.7rem; letter-spacing: 1px;">${diffStars(p.fix_difficulty)}</span>
5914
+ </td>
5915
+ <td style="vertical-align:top; padding-top: 12px;">
5916
+ <details style="font-size: 0.7rem;">
5917
+ <summary style="cursor:pointer; color: var(--intel-blue); font-weight: 600; user-select: none;">Fix</summary>
5918
+ <div style="margin-top: 8px; padding: 10px; background: var(--surface-off); border-left: 2px solid var(--intel-blue); color: var(--text-secondary); line-height: 1.5; font-size: 0.72rem;">${escapeHtml(fix)}${(p.fix_template || '').length > 200 ? '…' : ''}</div>
5919
+ </details>
5920
+ </td>
5921
+ </tr>`;
5922
+ }).join('');
5923
+
5924
+ return `
5925
+ <div class="card full-width vb-card" id="problems-card" style="margin-bottom: 24px;">
5926
+ <div style="display:flex; align-items:center; gap:14px; margin-bottom: 18px; flex-wrap: wrap;">
5927
+ <span class="vb-pill">Problems</span>
5928
+ <span style="font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; color: var(--text-primary); letter-spacing: -0.02em;">${counts.total} issue${counts.total === 1 ? '' : 's'} pending</span>
5929
+ <span class="vb-label-caps" style="margin-left:auto; color: var(--text-subtle);">ahrefs-style site health</span>
5930
+ </div>
5931
+
5932
+ <div style="display:flex; gap: 32px; margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--surface-border); flex-wrap: wrap;">
5933
+ <div>
5934
+ <div class="vb-score-big ${counts.critical > 0 ? 'bad' : 'good'}">${counts.critical}</div>
5935
+ <div class="vb-label-caps" style="margin-top: 6px;">critical</div>
5936
+ </div>
5937
+ <div>
5938
+ <div class="vb-score-big ${counts.warn > 9 ? 'warn' : 'good'}">${counts.warn}</div>
5939
+ <div class="vb-label-caps" style="margin-top: 6px;">warn</div>
5940
+ </div>
5941
+ <div>
5942
+ <div class="vb-score-big good">${counts.info}</div>
5943
+ <div class="vb-label-caps" style="margin-top: 6px;">info</div>
5944
+ </div>
5945
+ <div style="margin-left:auto; max-width: 420px; align-self: center;">
5946
+ <div style="font-size: 0.78rem; color: var(--text-secondary); line-height: 1.6;">
5947
+ Each problem ships with a <strong style="color: var(--intel-blue);">fix template</strong> and <strong style="color: var(--intel-blue);">verification</strong> step — copy the Fix into an AI agent (via MCP <code style="color: var(--text-primary); background: var(--surface-off); padding: 1px 5px; border-radius: 3px; font-size: 0.7rem;">list_problems</code>) or apply manually.
5948
+ </div>
5949
+ </div>
5950
+ </div>
5951
+
5952
+ <table class="analysis-table" style="margin: 0;">
5953
+ <thead>
5954
+ <tr>
5955
+ <th style="width: 28px;"></th>
5956
+ <th>Issue</th>
5957
+ <th style="width: 90px;">Severity</th>
5958
+ <th style="width: 90px;">Difficulty</th>
5959
+ <th style="width: 100px;">Action</th>
5960
+ </tr>
5961
+ </thead>
5962
+ <tbody>${rows}</tbody>
5963
+ </table>
5964
+
5965
+ ${remaining > 0 ? `
5966
+ <div style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--surface-border); text-align: center;">
5967
+ <span style="color: var(--text-muted); font-size: 0.78rem;">
5968
+ Showing top ${top.length} of ${counts.total} — query the rest via MCP:
5969
+ <code style="color: var(--intel-blue); background: var(--surface-off); padding: 2px 6px; border-radius: 3px; margin-left: 4px;">list_problems("${escapeHtml(project)}", limit=${counts.total})</code>
5970
+ </span>
5971
+ </div>` : ''}
5972
+ </div>`;
5973
+ }
5974
+
5852
5975
  function buildAeoCard(citabilityData, escapeHtml, project) {
5853
5976
  const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
5854
5977
  const compScores = citabilityData.filter(s => s.role === 'competitor');
@@ -5867,7 +5990,8 @@ function buildAeoCard(citabilityData, escapeHtml, project) {
5867
5990
  avg: Math.round(targetScores.reduce((a, s) => a + (s[sig] || 0), 0) / targetScores.length),
5868
5991
  }));
5869
5992
 
5870
- const scoreColor = (s) => s >= 75 ? '#4ade80' : s >= 55 ? '#facc15' : s >= 35 ? '#ff8c00' : '#ef4444';
5993
+ // Brief gradient: 0–34 bad, 35–59 warn, 60+ good (matches lib/problems.js severity)
5994
+ const scoreColor = (s) => s >= 60 ? 'var(--signal-good)' : s >= 35 ? 'var(--signal-warn)' : 'var(--signal-bad)';
5871
5995
 
5872
5996
  // Page rows (worst first, limit 25)
5873
5997
  const pageRows = targetScores
@@ -5908,20 +6032,28 @@ function buildAeoCard(citabilityData, escapeHtml, project) {
5908
6032
  compStatHtml += `<div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgComp)}">${avgComp}</span><span class="ki-stat-label">Competitor Avg</span></div>`;
5909
6033
  }
5910
6034
  if (delta !== null) {
5911
- compStatHtml += `<div class="ki-stat"><span class="ki-stat-number" style="color:${delta >= 0 ? '#4ade80' : '#ef4444'}">${delta > 0 ? '+' : ''}${delta}</span><span class="ki-stat-label">Delta</span></div>`;
6035
+ compStatHtml += `<div class="ki-stat"><span class="ki-stat-number" style="color:${delta >= 0 ? 'var(--signal-good)' : 'var(--signal-bad)'}">${delta > 0 ? '+' : ''}${delta}</span><span class="ki-stat-label">Delta</span></div>`;
5912
6036
  }
5913
6037
 
6038
+ // Visual-brief pill header: pick the worst signal for the "weakest area" caption
6039
+ const weakestSignal = [...signalAvgs].sort((a, b) => a.avg - b.avg)[0];
6040
+
5914
6041
  return `
5915
6042
  <div class="card full-width" id="aeo-citability">
5916
6043
  ${cardExportHtml('aeo', project)}
5917
- <h2><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
6044
+ <div style="display:flex; align-items:center; gap: 14px; margin-bottom: 18px; flex-wrap: wrap;">
6045
+ <span class="vb-pill">AI Citability</span>
6046
+ <span style="font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; color: var(--text-primary); letter-spacing: -0.02em;">${targetScores.length} pages scored</span>
6047
+ ${weakestSignal ? `<span class="vb-label-caps" style="margin-left:auto; color: var(--text-subtle);">weakest: ${weakestSignal.label}</span>` : ''}
6048
+ </div>
6049
+ <h2 style="display:none;"><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
5918
6050
  <div class="ki-stat-bar">
5919
6051
  <div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgTarget)}">${avgTarget}</span><span class="ki-stat-label">Target Avg</span></div>
5920
6052
  ${compStatHtml}
5921
- <div class="ki-stat"><span class="ki-stat-number" style="color:#4ade80">${tierCounts.excellent}</span><span class="ki-stat-label">Excellent</span></div>
5922
- <div class="ki-stat"><span class="ki-stat-number" style="color:#facc15">${tierCounts.good}</span><span class="ki-stat-label">Good</span></div>
5923
- <div class="ki-stat"><span class="ki-stat-number" style="color:#ff8c00">${tierCounts.needs_work}</span><span class="ki-stat-label">Needs Work</span></div>
5924
- <div class="ki-stat"><span class="ki-stat-number" style="color:#ef4444">${tierCounts.poor}</span><span class="ki-stat-label">Poor</span></div>
6053
+ <div class="ki-stat"><span class="ki-stat-number" style="color:var(--signal-good)">${tierCounts.excellent}</span><span class="ki-stat-label">Excellent</span></div>
6054
+ <div class="ki-stat"><span class="ki-stat-number" style="color:var(--signal-warn)">${tierCounts.good}</span><span class="ki-stat-label">Good</span></div>
6055
+ <div class="ki-stat"><span class="ki-stat-number" style="color:var(--signal-bad);opacity:0.85">${tierCounts.needs_work}</span><span class="ki-stat-label">Needs Work</span></div>
6056
+ <div class="ki-stat"><span class="ki-stat-number" style="color:var(--signal-bad)">${tierCounts.poor}</span><span class="ki-stat-label">Poor</span></div>
5925
6057
  </div>
5926
6058
 
5927
6059
  <div style="display:flex;gap:2rem;margin:1.5rem 0;flex-wrap:wrap;">
@@ -128,6 +128,17 @@ export function handleSetupRequest(req, res, url) {
128
128
  return true;
129
129
  }
130
130
 
131
+ // GET /api/setup/cron — read notify-cron install state
132
+ if (path === '/api/setup/cron' && method === 'GET') {
133
+ handleCronStatus(req, res);
134
+ return true;
135
+ }
136
+ // POST /api/setup/cron — install / remove
137
+ if (path === '/api/setup/cron' && method === 'POST') {
138
+ handleCronAction(req, res);
139
+ return true;
140
+ }
141
+
131
142
  // POST /api/setup/save-env — save a key to .env
132
143
  if (path === '/api/setup/save-env' && method === 'POST') {
133
144
  handleSaveEnv(req, res);
@@ -341,6 +352,34 @@ async function handlePingOllama(req, res) {
341
352
  }
342
353
  }
343
354
 
355
+ async function handleCronStatus(req, res) {
356
+ try {
357
+ const { getNotifyCronStatus } = await import('../lib/cron.js');
358
+ jsonResponse(res, getNotifyCronStatus());
359
+ } catch (err) {
360
+ jsonResponse(res, { error: err.message }, 500);
361
+ }
362
+ }
363
+
364
+ async function handleCronAction(req, res) {
365
+ try {
366
+ const body = await readBody(req);
367
+ const { action, schedule, openOnFire } = body || {};
368
+ const { installNotifyCron, removeNotifyCron, getNotifyCronStatus } = await import('../lib/cron.js');
369
+ if (action === 'install') {
370
+ const result = installNotifyCron({ schedule, openOnFire: !!openOnFire });
371
+ jsonResponse(res, { ...result, status: getNotifyCronStatus() });
372
+ } else if (action === 'remove') {
373
+ const result = removeNotifyCron();
374
+ jsonResponse(res, { ...result, status: getNotifyCronStatus() });
375
+ } else {
376
+ jsonResponse(res, { error: 'action must be "install" or "remove"' }, 400);
377
+ }
378
+ } catch (err) {
379
+ jsonResponse(res, { error: err.message }, 500);
380
+ }
381
+ }
382
+
344
383
  async function handleSaveEnv(req, res) {
345
384
  try {
346
385
  const body = await readBody(req);
package/setup/wizard.html CHANGED
@@ -1862,6 +1862,21 @@ input::placeholder {
1862
1862
  <!-- Populated by JS -->
1863
1863
  </table>
1864
1864
 
1865
+ <!-- ── Daily notification scheduler (v1.5.40) ────────────────────────── -->
1866
+ <div id="notifyCronCard" style="margin-top:20px; padding:14px 16px; background:var(--bg-elevated); border:1px solid rgba(59,130,246,0.18); border-radius:var(--radius); display:flex; gap:14px; align-items:center; flex-wrap:wrap;">
1867
+ <i class="fa-solid fa-bell" style="font-size:1rem; color:#3b82f6;"></i>
1868
+ <div style="flex:1; min-width:200px;">
1869
+ <div style="font-size:0.8rem; font-weight:600; color:var(--text-primary); margin-bottom:2px;">Daily problem notifications</div>
1870
+ <div id="notifyCronHint" style="font-size:0.66rem; color:var(--text-muted); line-height:1.4;">
1871
+ Fires a native macOS / Linux notification each morning for projects with pending criticals or warns.
1872
+ </div>
1873
+ </div>
1874
+ <div id="notifyCronStatus" style="font-size:0.66rem; color:var(--text-muted);">…</div>
1875
+ <button id="notifyCronToggleBtn" class="btn btn-sm" onclick="toggleNotifyCron()" style="padding:5px 12px; font-size:0.7rem; min-width:90px;">
1876
+ <i class="fa-solid fa-spinner fa-spin"></i>
1877
+ </button>
1878
+ </div>
1879
+
1865
1880
  <h3 style="font-family:var(--font-display); font-size:0.85rem; color:var(--text-primary); margin: 20px 0 12px; font-weight:600;">
1866
1881
  <i class="fa-solid fa-rocket" style="color:var(--accent-gold); margin-right:6px;"></i> What's Next
1867
1882
  </h3>
@@ -3409,6 +3424,64 @@ input::placeholder {
3409
3424
  }
3410
3425
 
3411
3426
  goToStep(6);
3427
+ loadNotifyCronStatus();
3428
+ };
3429
+
3430
+ // ── Daily notification cron (v1.5.40) ──────────────────────────────────
3431
+ let _cronState = null;
3432
+ async function loadNotifyCronStatus() {
3433
+ const statusEl = document.getElementById('notifyCronStatus');
3434
+ const btn = document.getElementById('notifyCronToggleBtn');
3435
+ const hint = document.getElementById('notifyCronHint');
3436
+ if (!statusEl || !btn) return;
3437
+ try {
3438
+ const s = await API.get('/api/setup/cron');
3439
+ _cronState = s;
3440
+ if (s.platform === 'win32') {
3441
+ statusEl.textContent = 'Windows: use Task Scheduler';
3442
+ btn.disabled = true;
3443
+ btn.innerHTML = 'N/A';
3444
+ hint.textContent = 'Cron auto-install is macOS / Linux only — set up a daily Task Scheduler entry manually.';
3445
+ return;
3446
+ }
3447
+ if (s.installed) {
3448
+ statusEl.innerHTML = `<span style="color:var(--color-success);"><i class="fa-solid fa-check"></i> Active · ${s.schedule}</span>`;
3449
+ btn.innerHTML = '<i class="fa-solid fa-bell-slash"></i> Disable';
3450
+ btn.style.borderColor = 'rgba(244,123,93,0.4)';
3451
+ btn.style.color = '#f47b5d';
3452
+ } else {
3453
+ statusEl.innerHTML = `<span style="color:var(--text-muted);">Not scheduled</span>`;
3454
+ btn.innerHTML = '<i class="fa-solid fa-bell"></i> Enable';
3455
+ btn.style.borderColor = 'rgba(59,130,246,0.4)';
3456
+ btn.style.color = '#3b82f6';
3457
+ }
3458
+ btn.disabled = false;
3459
+ } catch (err) {
3460
+ statusEl.textContent = 'Status check failed';
3461
+ btn.disabled = true;
3462
+ }
3463
+ }
3464
+
3465
+ window.toggleNotifyCron = async function() {
3466
+ const btn = document.getElementById('notifyCronToggleBtn');
3467
+ if (!btn || btn.disabled) return;
3468
+ btn.disabled = true;
3469
+ btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
3470
+ const action = _cronState?.installed ? 'remove' : 'install';
3471
+ try {
3472
+ const r = await API.post('/api/setup/cron', { action, schedule: '0 9 * * *' });
3473
+ if (r.ok === false && r.error) {
3474
+ document.getElementById('notifyCronStatus').textContent = 'Error: ' + r.error;
3475
+ btn.disabled = false;
3476
+ btn.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i> Retry';
3477
+ return;
3478
+ }
3479
+ await loadNotifyCronStatus();
3480
+ } catch (err) {
3481
+ document.getElementById('notifyCronStatus').textContent = 'Error: ' + err.message;
3482
+ btn.disabled = false;
3483
+ btn.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i> Retry';
3484
+ }
3412
3485
  };
3413
3486
 
3414
3487
  window.copyCli = function() {