@ainyc/canonry 4.1.1 → 4.2.2

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.
@@ -4,8 +4,9 @@ import {
4
4
  configExists,
5
5
  loadConfig,
6
6
  saveConfigPatch
7
- } from "./chunk-KCETXLDF.js";
7
+ } from "./chunk-SR7TGHHG.js";
8
8
  import {
9
+ DEFAULT_RUN_HISTORY_LIMIT,
9
10
  IntelligenceService,
10
11
  MIN_TREND_POINTS,
11
12
  agentMemory,
@@ -16,11 +17,22 @@ import {
16
17
  backlinkSummaries,
17
18
  bingCoverageSnapshots,
18
19
  bingUrlInspections,
20
+ buildAiSourceOrigin,
19
21
  buildBrandTokens,
22
+ buildCitationScorecard,
23
+ buildCompetitorLandscape,
24
+ buildCompetitorPressureScore,
20
25
  buildContentGapRows,
21
26
  buildContentSourceRows,
22
27
  buildContentTargetRows,
28
+ buildGapQueryScore,
23
29
  buildInventory,
30
+ buildMentionLandscape,
31
+ buildMovementSummary,
32
+ buildOverviewCompetitors,
33
+ buildProviderScores,
34
+ buildRunHistory,
35
+ buildVisibilityScore,
24
36
  categorizeQueryByIntent,
25
37
  ccReleaseSyncs,
26
38
  competitors,
@@ -49,7 +61,7 @@ import {
49
61
  runs,
50
62
  schedules,
51
63
  usageCounters
52
- } from "./chunk-NCWCPBOT.js";
64
+ } from "./chunk-7YSI4GFA.js";
53
65
  import {
54
66
  AGENT_MEMORY_VALUE_MAX_BYTES,
55
67
  AGENT_PROVIDER_IDS,
@@ -115,7 +127,7 @@ import {
115
127
  visibilityStateFromAnswerMentioned,
116
128
  windowCutoff,
117
129
  wordpressEnvSchema
118
- } from "./chunk-O5JZQUPX.js";
130
+ } from "./chunk-T2I6AO7D.js";
119
131
 
120
132
  // src/telemetry.ts
121
133
  import crypto from "crypto";
@@ -1946,7 +1958,7 @@ async function historyRoutes(app) {
1946
1958
  for (const snap of allSnapshots) {
1947
1959
  const key = `${snap.runId}:${snap.queryId}`;
1948
1960
  const existing = deduped.get(key);
1949
- if (!existing || !existing.answerMentioned && snap.answerMentioned || existing.answerMentioned === snap.answerMentioned && snap.citationState === "cited") {
1961
+ if (!existing || !existing.answerMentioned && snap.answerMentioned || existing.answerMentioned === snap.answerMentioned && snap.citationState === CitationStates.cited) {
1950
1962
  deduped.set(key, snap);
1951
1963
  }
1952
1964
  }
@@ -1968,16 +1980,16 @@ async function historyRoutes(app) {
1968
1980
  function computeTransitions(snaps) {
1969
1981
  return snaps.map((snap, idx) => {
1970
1982
  const run = projectRuns.find((r) => r.id === snap.runId);
1971
- let transition = snap.citationState === "cited" ? "cited" : "not-cited";
1983
+ let transition = snap.citationState === CitationStates.cited ? "cited" : "not-cited";
1972
1984
  let visibilityTransition = snap.answerMentioned ? "visible" : "not-visible";
1973
1985
  if (idx === 0) {
1974
1986
  transition = "new";
1975
1987
  visibilityTransition = "new";
1976
1988
  } else {
1977
1989
  const prev = snaps[idx - 1];
1978
- if (prev.citationState === "not-cited" && snap.citationState === "cited") {
1990
+ if (prev.citationState === CitationStates["not-cited"] && snap.citationState === CitationStates.cited) {
1979
1991
  transition = "emerging";
1980
- } else if (prev.citationState === "cited" && snap.citationState === "not-cited") {
1992
+ } else if (prev.citationState === CitationStates.cited && snap.citationState === CitationStates["not-cited"]) {
1981
1993
  transition = "lost";
1982
1994
  }
1983
1995
  if (!prev.answerMentioned && snap.answerMentioned) {
@@ -2051,7 +2063,7 @@ async function historyRoutes(app) {
2051
2063
  resolvedVisibilityState: resolveSnapshotVisibilityState(s, project)
2052
2064
  };
2053
2065
  const existing = map1.get(s.queryId);
2054
- if (!existing || !existing.resolvedAnswerMentioned && resolved.resolvedAnswerMentioned || existing.resolvedAnswerMentioned === resolved.resolvedAnswerMentioned && resolved.citationState === "cited") {
2066
+ if (!existing || !existing.resolvedAnswerMentioned && resolved.resolvedAnswerMentioned || existing.resolvedAnswerMentioned === resolved.resolvedAnswerMentioned && resolved.citationState === CitationStates.cited) {
2055
2067
  map1.set(s.queryId, resolved);
2056
2068
  }
2057
2069
  }
@@ -2063,7 +2075,7 @@ async function historyRoutes(app) {
2063
2075
  resolvedVisibilityState: resolveSnapshotVisibilityState(s, project)
2064
2076
  };
2065
2077
  const existing = map2.get(s.queryId);
2066
- if (!existing || !existing.resolvedAnswerMentioned && resolved.resolvedAnswerMentioned || existing.resolvedAnswerMentioned === resolved.resolvedAnswerMentioned && resolved.citationState === "cited") {
2078
+ if (!existing || !existing.resolvedAnswerMentioned && resolved.resolvedAnswerMentioned || existing.resolvedAnswerMentioned === resolved.resolvedAnswerMentioned && resolved.citationState === CitationStates.cited) {
2067
2079
  map2.set(s.queryId, resolved);
2068
2080
  }
2069
2081
  }
@@ -2177,7 +2189,7 @@ async function analyticsRoutes(app) {
2177
2189
  consistencyMap.set(s.queryId, entry);
2178
2190
  }
2179
2191
  entry.totalRuns.add(s.runId);
2180
- if (s.citationState === "cited") entry.citedRuns.add(s.runId);
2192
+ if (s.citationState === CitationStates.cited) entry.citedRuns.add(s.runId);
2181
2193
  if (resolveSnapshotAnswerMentioned(s, project)) entry.mentionedRuns.add(s.runId);
2182
2194
  }
2183
2195
  }
@@ -2209,7 +2221,7 @@ async function analyticsRoutes(app) {
2209
2221
  const notMentioned = [];
2210
2222
  for (const [queryId, qSnapshots] of byQuery) {
2211
2223
  const query = qSnapshots[0]?.query ?? "";
2212
- const citedProviders = qSnapshots.filter((s) => s.citationState === "cited").map((s) => s.provider);
2224
+ const citedProviders = qSnapshots.filter((s) => s.citationState === CitationStates.cited).map((s) => s.provider);
2213
2225
  const mentionedProviders = qSnapshots.filter((s) => s.resolvedMentioned).map((s) => s.provider);
2214
2226
  const competitorsCiting = /* @__PURE__ */ new Set();
2215
2227
  for (const s of qSnapshots) {
@@ -2338,7 +2350,7 @@ function bucketSizeForSpan(spanDays) {
2338
2350
  }
2339
2351
  function computeProviderMetric(snapshots) {
2340
2352
  const total = snapshots.length;
2341
- const cited = snapshots.filter((s) => s.citationState === "cited").length;
2353
+ const cited = snapshots.filter((s) => s.citationState === CitationStates.cited).length;
2342
2354
  const mentionedCount = snapshots.filter((s) => s.resolvedMentioned).length;
2343
2355
  return {
2344
2356
  citationRate: total > 0 ? Math.round(cited / total * 1e4) / 1e4 : 0,
@@ -2967,7 +2979,7 @@ function renderCitationMatrix(scorecard) {
2967
2979
  if (!cell) {
2968
2980
  return '<td><span class="cell-pending">\u2014</span></td>';
2969
2981
  }
2970
- if (cell.citationState === "cited") {
2982
+ if (cell.citationState === CitationStates.cited) {
2971
2983
  return '<td><span class="cell-cited">Cited</span></td>';
2972
2984
  }
2973
2985
  return '<td><span class="cell-not-cited">Not cited</span></td>';
@@ -3846,7 +3858,6 @@ function extractPath(url) {
3846
3858
  var TOP_QUERIES_LIMIT = 20;
3847
3859
  var TOP_LANDING_PAGES_LIMIT = 20;
3848
3860
  var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
3849
- var TOP_SOURCE_DOMAINS_LIMIT = 20;
3850
3861
  var TOP_CAMPAIGN_LIMIT = 10;
3851
3862
  var INSIGHT_LOOKBACK_RUNS = 5;
3852
3863
  function safeNum(value) {
@@ -3857,14 +3868,6 @@ function safeNum(value) {
3857
3868
  }
3858
3869
  return 0;
3859
3870
  }
3860
- function citedDomainBelongsToProject(citedDomain, projectDomains) {
3861
- const candidate = normalizeProjectDomain(citedDomain);
3862
- for (const domain of projectDomains) {
3863
- const normalized = normalizeProjectDomain(domain);
3864
- if (candidate === normalized || candidate.endsWith(`.${normalized}`)) return true;
3865
- }
3866
- return false;
3867
- }
3868
3871
  function categorizeQuery(query, projectDisplayName, canonicalDomain) {
3869
3872
  return categorizeQueryByIntent(query, buildBrandTokens(canonicalDomain, projectDisplayName));
3870
3873
  }
@@ -3891,186 +3894,6 @@ function loadQueryLookup(db, projectId) {
3891
3894
  for (const row of rows) byId.set(row.id, row.query);
3892
3895
  return { byId };
3893
3896
  }
3894
- function buildCitationScorecard(snapshots, queryLookup) {
3895
- if (snapshots.length === 0) {
3896
- return { queries: [], providers: [], matrix: [], providerRates: [] };
3897
- }
3898
- const querySet = /* @__PURE__ */ new Set();
3899
- const providerSet = /* @__PURE__ */ new Set();
3900
- for (const snap of snapshots) {
3901
- const q = queryLookup.byId.get(snap.queryId);
3902
- if (!q) continue;
3903
- querySet.add(q);
3904
- providerSet.add(snap.provider);
3905
- }
3906
- const queryList = [...querySet].sort();
3907
- const providerList = [...providerSet].sort();
3908
- const matrix = queryList.map(
3909
- () => providerList.map(() => null)
3910
- );
3911
- const providerCounts = /* @__PURE__ */ new Map();
3912
- for (const snap of snapshots) {
3913
- const q = queryLookup.byId.get(snap.queryId);
3914
- if (!q) continue;
3915
- const qi = queryList.indexOf(q);
3916
- const pi = providerList.indexOf(snap.provider);
3917
- if (qi < 0 || pi < 0) continue;
3918
- matrix[qi][pi] = {
3919
- citationState: snap.citationState === "cited" ? "cited" : "not-cited",
3920
- answerMentioned: snap.answerMentioned ?? null,
3921
- model: snap.model
3922
- };
3923
- const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
3924
- counts.total++;
3925
- if (snap.citationState === "cited") counts.cited++;
3926
- providerCounts.set(snap.provider, counts);
3927
- }
3928
- const providerRates = providerList.map((provider) => {
3929
- const counts = providerCounts.get(provider) ?? { cited: 0, total: 0 };
3930
- const citationRate = counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0;
3931
- return {
3932
- provider,
3933
- citedCount: counts.cited,
3934
- totalCount: counts.total,
3935
- citationRate
3936
- };
3937
- });
3938
- return { queries: queryList, providers: providerList, matrix, providerRates };
3939
- }
3940
- function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
3941
- let projectCitationCount = 0;
3942
- const competitorMap = /* @__PURE__ */ new Map();
3943
- for (const c of competitorDomains) {
3944
- competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
3945
- }
3946
- for (const snap of snapshots) {
3947
- const q = queryLookup.byId.get(snap.queryId);
3948
- const allDomains = [...snap.citedDomains, ...snap.competitorOverlap];
3949
- if (allDomains.some((d) => citedDomainBelongsToProject(d, projectDomains))) {
3950
- projectCitationCount++;
3951
- }
3952
- for (const competitor of competitorDomains) {
3953
- if (allDomains.some((d) => citedDomainBelongsToProject(d, [competitor]))) {
3954
- const entry = competitorMap.get(competitor);
3955
- entry.count++;
3956
- if (q) entry.queries.add(q);
3957
- }
3958
- const competitorNorm = normalizeDomain(competitor);
3959
- for (const gs of snap.groundingSources) {
3960
- const host = normalizeDomain(extractHostFromUri(gs.uri));
3961
- if (!host) continue;
3962
- if (host === competitorNorm || host.endsWith(`.${competitorNorm}`)) {
3963
- const entry = competitorMap.get(competitor);
3964
- const pageQueries = entry.pages.get(gs.uri) ?? /* @__PURE__ */ new Set();
3965
- if (q) pageQueries.add(q);
3966
- entry.pages.set(gs.uri, pageQueries);
3967
- }
3968
- }
3969
- }
3970
- }
3971
- const totalCitedSlots = projectCitationCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
3972
- const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
3973
- const total = snapshots.length;
3974
- const ratio = total > 0 ? data.count / total : 0;
3975
- let pressureLabel = "None";
3976
- if (data.count > 0) {
3977
- if (ratio >= 0.5) pressureLabel = "High";
3978
- else if (ratio >= 0.2) pressureLabel = "Moderate";
3979
- else pressureLabel = "Low";
3980
- }
3981
- const sharePct = totalCitedSlots > 0 ? Math.round(data.count / totalCitedSlots * 100) : 0;
3982
- const theirCitedPages = [...data.pages.entries()].map(([url, qs]) => ({ url, citedFor: [...qs].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
3983
- return {
3984
- domain,
3985
- citationCount: data.count,
3986
- totalCount: total,
3987
- pressureLabel,
3988
- citedQueries: [...data.queries].sort(),
3989
- sharePct,
3990
- theirCitedPages
3991
- };
3992
- });
3993
- competitorRows.sort((a, b) => b.citationCount - a.citationCount);
3994
- return { projectCitationCount, competitors: competitorRows };
3995
- }
3996
- function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName, projectDomains, queryLookup) {
3997
- let projectMentionCount = 0;
3998
- let totalAnswerSnapshots = 0;
3999
- const competitorMap = /* @__PURE__ */ new Map();
4000
- for (const c of competitorDomains) {
4001
- competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set() });
4002
- }
4003
- for (const snap of snapshots) {
4004
- const text = snap.answerText;
4005
- if (!text) continue;
4006
- totalAnswerSnapshots++;
4007
- const q = queryLookup.byId.get(snap.queryId);
4008
- const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
4009
- text,
4010
- projectDisplayName,
4011
- projectDomains
4012
- );
4013
- if (projectMentioned) projectMentionCount++;
4014
- for (const competitor of competitorDomains) {
4015
- const brand = brandLabelFromDomain(competitor);
4016
- const mentioned = determineAnswerMentioned(text, brand, [competitor]);
4017
- if (mentioned) {
4018
- const entry = competitorMap.get(competitor);
4019
- entry.count++;
4020
- if (q) entry.queries.add(q);
4021
- }
4022
- }
4023
- }
4024
- const totalMentionedSlots = projectMentionCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
4025
- const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
4026
- const ratio = totalAnswerSnapshots > 0 ? data.count / totalAnswerSnapshots : 0;
4027
- let pressureLabel = "None";
4028
- if (data.count > 0) {
4029
- if (ratio >= 0.5) pressureLabel = "High";
4030
- else if (ratio >= 0.2) pressureLabel = "Moderate";
4031
- else pressureLabel = "Low";
4032
- }
4033
- const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : 0;
4034
- return {
4035
- domain,
4036
- mentionCount: data.count,
4037
- totalCount: totalAnswerSnapshots,
4038
- pressureLabel,
4039
- mentionedQueries: [...data.queries].sort(),
4040
- sharePct
4041
- };
4042
- });
4043
- competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
4044
- return { projectMentionCount, totalAnswerSnapshots, competitors: competitorRows };
4045
- }
4046
- function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains) {
4047
- const categoryCounts = /* @__PURE__ */ new Map();
4048
- const domainCounts = /* @__PURE__ */ new Map();
4049
- let totalCitations = 0;
4050
- for (const snap of snapshots) {
4051
- for (const raw of snap.citedDomains) {
4052
- if (citedDomainBelongsToProject(raw, projectDomains)) continue;
4053
- const { category, label, domain } = categorizeSource(raw);
4054
- const cat = categoryCounts.get(category) ?? { label, count: 0 };
4055
- cat.count++;
4056
- categoryCounts.set(category, cat);
4057
- domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
4058
- totalCitations++;
4059
- }
4060
- }
4061
- const categories = [...categoryCounts.entries()].map(([category, { label, count }]) => ({
4062
- category,
4063
- label,
4064
- count,
4065
- sharePct: totalCitations > 0 ? Math.round(count / totalCitations * 100) : 0
4066
- })).sort((a, b) => b.count - a.count);
4067
- const topDomains = [...domainCounts.entries()].map(([domain, count]) => ({
4068
- domain,
4069
- count,
4070
- isCompetitor: citedDomainBelongsToProject(domain, competitorDomains)
4071
- })).sort((a, b) => b.count - a.count).slice(0, TOP_SOURCE_DOMAINS_LIMIT);
4072
- return { categories, topDomains };
4073
- }
4074
3897
  function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedQueries) {
4075
3898
  const rows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
4076
3899
  if (rows.length === 0) return null;
@@ -4319,10 +4142,10 @@ function buildCitationsTrend(db, projectId, queryLookup) {
4319
4142
  for (const snap of snaps) {
4320
4143
  if (!queryLookup.byId.has(snap.queryId)) continue;
4321
4144
  considered++;
4322
- if (snap.citationState === "cited") cited++;
4145
+ if (snap.citationState === CitationStates.cited) cited++;
4323
4146
  const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
4324
4147
  counts.total++;
4325
- if (snap.citationState === "cited") counts.cited++;
4148
+ if (snap.citationState === CitationStates.cited) counts.cited++;
4326
4149
  providerCounts.set(snap.provider, counts);
4327
4150
  }
4328
4151
  if (considered === 0) continue;
@@ -4523,7 +4346,7 @@ function buildProjectReport(db, projectName) {
4523
4346
  for (const snap of latestSnapshots) {
4524
4347
  if (!queryLookup.byId.has(snap.queryId)) continue;
4525
4348
  latestConsidered++;
4526
- if (snap.citationState === "cited") latestCited++;
4349
+ if (snap.citationState === CitationStates.cited) latestCited++;
4527
4350
  }
4528
4351
  const citationRate = latestConsidered > 0 ? Math.round(latestCited / latestConsidered * 100) : 0;
4529
4352
  const trendBaseline = isTrendBaseline(citationsTrend);
@@ -4785,24 +4608,68 @@ function normalizeDomain2(domain) {
4785
4608
  }
4786
4609
 
4787
4610
  // ../api-routes/src/composites.ts
4788
- import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3 } from "drizzle-orm";
4611
+ import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3, inArray as inArray6 } from "drizzle-orm";
4789
4612
  var TOP_INSIGHT_LIMIT = 5;
4790
4613
  var SEARCH_HIT_HARD_LIMIT = 50;
4791
4614
  var SEARCH_SNIPPET_RADIUS = 80;
4792
4615
  async function compositeRoutes(app) {
4793
4616
  app.get("/projects/:name/overview", async (request, reply) => {
4794
4617
  const project = resolveProject(app.db, request.params.name);
4795
- const totalRunsRow = app.db.select({ count: sql3`count(*)` }).from(runs).where(eq15(runs.projectId, project.id)).get();
4796
- const totalRuns = totalRunsRow?.count ?? 0;
4797
- const recentRuns = app.db.select().from(runs).where(eq15(runs.projectId, project.id)).orderBy(desc7(runs.createdAt)).limit(2).all();
4798
- const [latestRunRow, previousRunRow] = recentRuns;
4618
+ const filterLocation = (request.query.location ?? "").trim() || null;
4619
+ const sinceIso = parseSinceFilter(request.query.since);
4620
+ const allRunsRaw = app.db.select().from(runs).where(eq15(runs.projectId, project.id)).orderBy(desc7(runs.createdAt)).all();
4621
+ const allRuns = allRunsRaw.filter((r) => runMatchesFilters(r, filterLocation, sinceIso));
4622
+ const totalRuns = allRuns.length;
4623
+ const visibilityRuns = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]);
4624
+ const completedVisRuns = visibilityRuns.filter(
4625
+ (r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
4626
+ );
4627
+ const latestVisibilityRun = completedVisRuns[0] ?? null;
4628
+ const previousVisibilityRun = completedVisRuns[1] ?? null;
4629
+ const latestRunRow = allRuns[0] ?? null;
4799
4630
  const latestRun = latestRunRow ? { totalRuns, run: summarizeRun(latestRunRow) } : { totalRuns: 0, run: null };
4800
4631
  const healthRow = app.db.select().from(healthSnapshots).where(eq15(healthSnapshots.projectId, project.id)).orderBy(desc7(healthSnapshots.createdAt)).limit(1).get();
4801
4632
  const health = healthRow ? mapHealthRow2(healthRow) : null;
4802
4633
  const insightRows = app.db.select().from(insights).where(eq15(insights.projectId, project.id)).orderBy(desc7(insights.createdAt)).all();
4803
4634
  const topInsights = insightRows.filter((row) => !row.dismissed).slice(0, TOP_INSIGHT_LIMIT).map(mapInsightRow2);
4804
- const { queryCounts, providers } = summarizeLatestRun(app, latestRunRow ?? null);
4805
- const transitions = summarizeTransitions(app, latestRunRow ?? null, previousRunRow ?? null);
4635
+ const sparklineRunIds = visibilityRuns.slice(0, DEFAULT_RUN_HISTORY_LIMIT).map((r) => r.id);
4636
+ const snapshotRunIds = new Set(sparklineRunIds);
4637
+ if (latestVisibilityRun) snapshotRunIds.add(latestVisibilityRun.id);
4638
+ if (previousVisibilityRun) snapshotRunIds.add(previousVisibilityRun.id);
4639
+ const snapshotsByRun = loadSnapshotsByRunIds(app, [...snapshotRunIds]);
4640
+ const latestSnapshots = latestVisibilityRun ? snapshotsByRun.get(latestVisibilityRun.id) ?? [] : [];
4641
+ const previousSnapshots = previousVisibilityRun ? snapshotsByRun.get(previousVisibilityRun.id) ?? [] : [];
4642
+ const { queryCounts, providers } = summarizeFromSnapshots(latestSnapshots);
4643
+ const transitions = summarizeTransitionsFromSnapshots(
4644
+ latestSnapshots,
4645
+ previousSnapshots,
4646
+ previousVisibilityRun?.createdAt ?? null
4647
+ );
4648
+ const competitorRows = app.db.select().from(competitors).where(eq15(competitors.projectId, project.id)).all();
4649
+ const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq15(queries.projectId, project.id)).all();
4650
+ const queryLookup = { byId: new Map(projectQueries.map((q) => [q.id, q.query])) };
4651
+ const configuredApiProviders = parseJsonColumn(project.providers, []).filter((p) => !p.startsWith("cdp:"));
4652
+ const scores = {
4653
+ visibility: buildVisibilityScore(latestSnapshots, { configuredApiProviders }),
4654
+ gapQueries: buildGapQueryScore(latestSnapshots),
4655
+ indexCoverage: buildIndexCoverageScore(app, project.id),
4656
+ competitorPressure: buildCompetitorPressureScore(
4657
+ latestSnapshots,
4658
+ competitorRows.map((c) => c.domain),
4659
+ competitorRows.length
4660
+ ),
4661
+ runStatus: buildRunStatusScore(allRuns)
4662
+ };
4663
+ const movementSummary = buildMovementSummary(latestSnapshots, previousSnapshots);
4664
+ const providerScores = buildProviderScores(latestSnapshots);
4665
+ const overviewCompetitors = buildOverviewCompetitors(
4666
+ latestSnapshots,
4667
+ competitorRows.map((c) => ({ id: c.id, domain: c.domain })),
4668
+ queryLookup
4669
+ );
4670
+ const attentionItems = buildAttentionItems(insightRows, allRuns);
4671
+ const sparklineRuns = visibilityRuns.slice(0, DEFAULT_RUN_HISTORY_LIMIT).map((r) => ({ id: r.id, createdAt: r.createdAt, status: r.status }));
4672
+ const runHistory = buildRunHistory(sparklineRuns, snapshotsByRun);
4806
4673
  const result = {
4807
4674
  project: formatProject2(project),
4808
4675
  latestRun,
@@ -4810,7 +4677,15 @@ async function compositeRoutes(app) {
4810
4677
  topInsights,
4811
4678
  queryCounts,
4812
4679
  providers,
4813
- transitions
4680
+ transitions,
4681
+ scores,
4682
+ movementSummary,
4683
+ competitors: overviewCompetitors,
4684
+ providerScores,
4685
+ attentionItems,
4686
+ runHistory,
4687
+ dateRangeLabel: "All time",
4688
+ contextLabel: `${project.country} / ${project.language.toUpperCase()}`
4814
4689
  };
4815
4690
  return reply.send(result);
4816
4691
  });
@@ -4876,6 +4751,21 @@ async function compositeRoutes(app) {
4876
4751
  return reply.send(response);
4877
4752
  });
4878
4753
  }
4754
+ function parseSinceFilter(raw) {
4755
+ if (!raw) return null;
4756
+ const trimmed = raw.trim();
4757
+ if (!trimmed) return null;
4758
+ const parsed = Date.parse(trimmed);
4759
+ if (Number.isNaN(parsed)) {
4760
+ throw validationError('"since" must be an ISO 8601 datetime');
4761
+ }
4762
+ return new Date(parsed).toISOString();
4763
+ }
4764
+ function runMatchesFilters(run, location, sinceIso) {
4765
+ if (location !== null && (run.location ?? "") !== location) return false;
4766
+ if (sinceIso !== null && run.createdAt < sinceIso) return false;
4767
+ return true;
4768
+ }
4879
4769
  function clampSearchLimit(raw) {
4880
4770
  if (!raw) return 25;
4881
4771
  const parsed = Number.parseInt(raw, 10);
@@ -4901,29 +4791,49 @@ function summarizeRun(run) {
4901
4791
  createdAt: run.createdAt
4902
4792
  };
4903
4793
  }
4904
- function summarizeLatestRun(app, run) {
4794
+ function loadSnapshotsByRunIds(app, runIds) {
4795
+ const result = /* @__PURE__ */ new Map();
4796
+ if (runIds.length === 0) return result;
4797
+ const rows = app.db.select({
4798
+ runId: querySnapshots.runId,
4799
+ queryId: querySnapshots.queryId,
4800
+ provider: querySnapshots.provider,
4801
+ model: querySnapshots.model,
4802
+ citationState: querySnapshots.citationState,
4803
+ competitorOverlap: querySnapshots.competitorOverlap,
4804
+ citedDomains: querySnapshots.citedDomains
4805
+ }).from(querySnapshots).where(inArray6(querySnapshots.runId, [...runIds])).all();
4806
+ for (const row of rows) {
4807
+ const list = result.get(row.runId) ?? [];
4808
+ list.push({
4809
+ queryId: row.queryId,
4810
+ provider: row.provider,
4811
+ model: row.model,
4812
+ citationState: row.citationState,
4813
+ competitorOverlap: parseJsonColumn(row.competitorOverlap, []),
4814
+ citedDomains: parseJsonColumn(row.citedDomains, [])
4815
+ });
4816
+ result.set(row.runId, list);
4817
+ }
4818
+ return result;
4819
+ }
4820
+ function summarizeFromSnapshots(snapshots) {
4905
4821
  const empty = {
4906
4822
  queryCounts: { totalQueries: 0, citedQueries: 0, notCitedQueries: 0, citedRate: 0 },
4907
4823
  providers: []
4908
4824
  };
4909
- if (!run) return empty;
4910
- const rows = app.db.select({
4911
- queryId: querySnapshots.queryId,
4912
- provider: querySnapshots.provider,
4913
- citationState: querySnapshots.citationState
4914
- }).from(querySnapshots).where(eq15(querySnapshots.runId, run.id)).all();
4915
- if (rows.length === 0) return empty;
4825
+ if (snapshots.length === 0) return empty;
4916
4826
  const perQuery = /* @__PURE__ */ new Map();
4917
4827
  const perProvider = /* @__PURE__ */ new Map();
4918
- for (const row of rows) {
4919
- const cited = row.citationState === "cited";
4920
- if (!perQuery.has(row.queryId) || cited) {
4921
- perQuery.set(row.queryId, cited);
4828
+ for (const snap of snapshots) {
4829
+ const cited = snap.citationState === CitationStates.cited;
4830
+ if (!perQuery.has(snap.queryId) || cited) {
4831
+ perQuery.set(snap.queryId, cited);
4922
4832
  }
4923
- const bucket = perProvider.get(row.provider) ?? { cited: 0, total: 0 };
4833
+ const bucket = perProvider.get(snap.provider) ?? { cited: 0, total: 0 };
4924
4834
  bucket.total += 1;
4925
4835
  if (cited) bucket.cited += 1;
4926
- perProvider.set(row.provider, bucket);
4836
+ perProvider.set(snap.provider, bucket);
4927
4837
  }
4928
4838
  const totalQueries = perQuery.size;
4929
4839
  let citedQueries = 0;
@@ -4943,23 +4853,20 @@ function summarizeLatestRun(app, run) {
4943
4853
  providers
4944
4854
  };
4945
4855
  }
4946
- function summarizeTransitions(app, latest, previous) {
4947
- const empty = { since: null, gained: 0, lost: 0, emerging: 0 };
4948
- if (!latest || !previous) return empty;
4949
- const fetchCited = (runId) => {
4950
- const rows = app.db.select({
4951
- queryId: querySnapshots.queryId,
4952
- citationState: querySnapshots.citationState
4953
- }).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
4954
- const map = /* @__PURE__ */ new Map();
4955
- for (const row of rows) {
4956
- const cited = row.citationState === "cited";
4957
- if (!map.has(row.queryId) || cited) map.set(row.queryId, cited);
4856
+ function summarizeTransitionsFromSnapshots(latest, previous, since) {
4857
+ if (!since || previous.length === 0) {
4858
+ return { since: null, gained: 0, lost: 0, emerging: 0 };
4859
+ }
4860
+ const buildMap = (snaps) => {
4861
+ const m = /* @__PURE__ */ new Map();
4862
+ for (const s of snaps) {
4863
+ const cited = s.citationState === CitationStates.cited;
4864
+ if (!m.has(s.queryId) || cited) m.set(s.queryId, cited);
4958
4865
  }
4959
- return map;
4866
+ return m;
4960
4867
  };
4961
- const latestMap = fetchCited(latest.id);
4962
- const previousMap = fetchCited(previous.id);
4868
+ const latestMap = buildMap(latest);
4869
+ const previousMap = buildMap(previous);
4963
4870
  let gained = 0;
4964
4871
  let lost = 0;
4965
4872
  let emerging = 0;
@@ -4972,7 +4879,142 @@ function summarizeTransitions(app, latest, previous) {
4972
4879
  if (latestCited && !previousCited) gained += 1;
4973
4880
  else if (!latestCited && previousCited) lost += 1;
4974
4881
  }
4975
- return { since: previous.createdAt, gained, lost, emerging };
4882
+ return { since, gained, lost, emerging };
4883
+ }
4884
+ function buildIndexCoverageScore(app, projectId) {
4885
+ const tooltip = "Percentage of inspected URLs currently indexed. Google Search Console is preferred when available, otherwise Bing Webmaster Tools is used.";
4886
+ const empty = {
4887
+ label: "Index Coverage",
4888
+ value: "No data",
4889
+ delta: "Connect GSC or Bing",
4890
+ tone: "neutral",
4891
+ description: "Connect Google Search Console or Bing Webmaster Tools and inspect your sitemap to populate coverage.",
4892
+ tooltip,
4893
+ trend: []
4894
+ };
4895
+ const gscRow = app.db.select().from(gscCoverageSnapshots).where(eq15(gscCoverageSnapshots.projectId, projectId)).orderBy(desc7(gscCoverageSnapshots.date)).limit(1).get();
4896
+ const bingRow = app.db.select().from(bingCoverageSnapshots).where(eq15(bingCoverageSnapshots.projectId, projectId)).orderBy(desc7(bingCoverageSnapshots.date)).limit(1).get();
4897
+ const chosen = pickIndexCoverageRow(gscRow, bingRow);
4898
+ if (!chosen) return empty;
4899
+ const total = chosen.indexed + chosen.notIndexed;
4900
+ if (total === 0) return empty;
4901
+ const deindexed = chosen.provider === "Google" ? countGoogleDeindexedUrls(app, projectId) : 0;
4902
+ const percentage = chosen.indexed / total * 100;
4903
+ const tone = deindexed > 0 ? "negative" : percentage >= 90 ? "positive" : percentage >= 70 ? "caution" : "negative";
4904
+ const notIndexedLabel = chosen.notIndexed === 1 ? "URL is" : "URLs are";
4905
+ const deindexedLabel = deindexed === 1 ? "URL" : "URLs";
4906
+ return {
4907
+ label: "Index Coverage",
4908
+ value: `${Math.round(percentage)}`,
4909
+ delta: `${chosen.provider} \xB7 ${chosen.indexed} of ${total} indexed`,
4910
+ tone,
4911
+ description: deindexed > 0 ? `${deindexed} deindexed ${deindexedLabel} detected in the latest Google Search Console inspection.` : `${chosen.notIndexed} ${notIndexedLabel} not indexed in ${chosen.provider === "Google" ? "Google Search Console" : "Bing Webmaster Tools"}.`,
4912
+ tooltip,
4913
+ trend: [],
4914
+ progress: Math.round(percentage)
4915
+ };
4916
+ }
4917
+ function countGoogleDeindexedUrls(app, projectId) {
4918
+ const rows = app.db.select({
4919
+ url: gscUrlInspections.url,
4920
+ indexingState: gscUrlInspections.indexingState,
4921
+ inspectedAt: gscUrlInspections.inspectedAt
4922
+ }).from(gscUrlInspections).where(eq15(gscUrlInspections.projectId, projectId)).orderBy(desc7(gscUrlInspections.inspectedAt)).all();
4923
+ if (rows.length === 0) return 0;
4924
+ const canonicalUrl = (url) => url.replace(/^http:\/\//, "https://");
4925
+ const historyByUrl = /* @__PURE__ */ new Map();
4926
+ for (const row of rows) {
4927
+ const key = canonicalUrl(row.url);
4928
+ const list = historyByUrl.get(key);
4929
+ if (list) list.push(row);
4930
+ else historyByUrl.set(key, [row]);
4931
+ }
4932
+ let deindexed = 0;
4933
+ for (const history of historyByUrl.values()) {
4934
+ if (history.length < 2) continue;
4935
+ const latest = history[0];
4936
+ const previous = history[1];
4937
+ if (previous.indexingState === "INDEXING_ALLOWED" && latest.indexingState !== "INDEXING_ALLOWED") {
4938
+ deindexed++;
4939
+ }
4940
+ }
4941
+ return deindexed;
4942
+ }
4943
+ function pickIndexCoverageRow(gsc, bing) {
4944
+ if (gsc && gsc.indexed + gsc.notIndexed > 0) {
4945
+ return { provider: "Google", indexed: gsc.indexed, notIndexed: gsc.notIndexed };
4946
+ }
4947
+ if (bing && bing.indexed + bing.notIndexed > 0) {
4948
+ return { provider: "Bing", indexed: bing.indexed, notIndexed: bing.notIndexed };
4949
+ }
4950
+ if (gsc) return { provider: "Google", indexed: gsc.indexed, notIndexed: gsc.notIndexed };
4951
+ if (bing) return { provider: "Bing", indexed: bing.indexed, notIndexed: bing.notIndexed };
4952
+ return null;
4953
+ }
4954
+ function buildRunStatusScore(allRuns) {
4955
+ const tooltip = "Current execution state of visibility sweeps. Shows the status of the most recent run and total run count.";
4956
+ if (allRuns.length === 0) {
4957
+ return {
4958
+ label: "Run Status",
4959
+ value: "None",
4960
+ delta: "No runs yet",
4961
+ tone: "neutral",
4962
+ description: "Trigger a visibility sweep to start tracking.",
4963
+ tooltip,
4964
+ trend: []
4965
+ };
4966
+ }
4967
+ const latestVisibility = allRuns.find((r) => r.kind === RunKinds["answer-visibility"]);
4968
+ const latest = latestVisibility ?? allRuns[0];
4969
+ const value = latest.status === RunStatuses.completed ? "Healthy" : latest.status === RunStatuses.running ? "Running" : latest.status === RunStatuses.queued ? "Queued" : latest.status === RunStatuses.partial ? "Partial" : "Failed";
4970
+ const tone = latest.status === RunStatuses.completed ? "positive" : latest.status === RunStatuses.failed ? "negative" : latest.status === RunStatuses.partial ? "caution" : "neutral";
4971
+ const visibilityRunCount = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]).length;
4972
+ const syncRunCount = allRuns.length - visibilityRunCount;
4973
+ const delta = syncRunCount > 0 ? `${visibilityRunCount} visibility \xB7 ${syncRunCount} sync` : `${visibilityRunCount} visibility run${visibilityRunCount === 1 ? "" : "s"}`;
4974
+ return {
4975
+ label: "Run Status",
4976
+ value,
4977
+ delta,
4978
+ tone,
4979
+ description: `Latest run ${value.toLowerCase()}. ${allRuns.length} total run${allRuns.length === 1 ? "" : "s"}.`,
4980
+ tooltip,
4981
+ trend: []
4982
+ };
4983
+ }
4984
+ var ATTENTION_INSIGHT_LIMIT = 5;
4985
+ function buildAttentionItems(insightRows, allRuns) {
4986
+ const items = [];
4987
+ for (const row of insightRows) {
4988
+ if (row.dismissed) continue;
4989
+ if (row.severity !== "critical" && row.severity !== "high") continue;
4990
+ if (items.length >= ATTENTION_INSIGHT_LIMIT) break;
4991
+ items.push({
4992
+ id: `insight_${row.id}`,
4993
+ tone: row.severity === "critical" ? "negative" : "caution",
4994
+ title: row.title,
4995
+ detail: row.query ? `On query: ${row.query}` : "",
4996
+ actionLabel: row.severity === "critical" ? "Critical" : "High",
4997
+ href: `#insight-${row.id}`
4998
+ });
4999
+ }
5000
+ const sortedRuns = [...allRuns].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
5001
+ const latestVisRun = sortedRuns.find((r) => r.kind === RunKinds["answer-visibility"]);
5002
+ const latestSyncRun = sortedRuns.find((r) => r.kind !== RunKinds["answer-visibility"]);
5003
+ if (latestVisRun && latestSyncRun) {
5004
+ const visibilityAge = new Date(latestSyncRun.createdAt).getTime() - new Date(latestVisRun.createdAt).getTime();
5005
+ const ONE_DAY = 24 * 60 * 60 * 1e3;
5006
+ if (visibilityAge > ONE_DAY) {
5007
+ items.push({
5008
+ id: "stale_visibility",
5009
+ tone: "caution",
5010
+ title: "Stale visibility data",
5011
+ detail: `Last visibility sweep was ${latestVisRun.createdAt}; integration syncs have run since.`,
5012
+ actionLabel: "Stale",
5013
+ href: "#runs"
5014
+ });
5015
+ }
5016
+ }
5017
+ return items;
4976
5018
  }
4977
5019
  function mapInsightRow2(r) {
4978
5020
  return {
@@ -10814,8 +10856,8 @@ async function cdpRoutes(app, opts) {
10814
10856
  let total = 0;
10815
10857
  const queryResults = [...byQuery.values()].map(({ query, api, browser }) => {
10816
10858
  total++;
10817
- const apiCited = api?.citationState === "cited";
10818
- const browserCited = browser?.citationState === "cited";
10859
+ const apiCited = api?.citationState === CitationStates.cited;
10860
+ const browserCited = browser?.citationState === CitationStates.cited;
10819
10861
  let agreement;
10820
10862
  if (!api && !browser) {
10821
10863
  agreement = "no-data";
@@ -17299,7 +17341,7 @@ import crypto19 from "crypto";
17299
17341
  import fs7 from "fs";
17300
17342
  import path9 from "path";
17301
17343
  import os4 from "os";
17302
- import { and as and12, eq as eq23, inArray as inArray6, sql as sql7 } from "drizzle-orm";
17344
+ import { and as and12, eq as eq23, inArray as inArray7, sql as sql7 } from "drizzle-orm";
17303
17345
 
17304
17346
  // src/citation-utils.ts
17305
17347
  function domainMatches(domain, canonicalDomain) {
@@ -17556,7 +17598,7 @@ var JobRunner = class {
17556
17598
  this.registry = registry;
17557
17599
  }
17558
17600
  recoverStaleRuns() {
17559
- const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray6(runs.status, ["running", "queued"])).all();
17601
+ const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray7(runs.status, ["running", "queued"])).all();
17560
17602
  if (stale.length === 0) return;
17561
17603
  const now = (/* @__PURE__ */ new Date()).toISOString();
17562
17604
  for (const run of stale) {