@yourbright/emdash-analytics-plugin 0.1.2 → 0.1.3

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.
Files changed (3) hide show
  1. package/dist/admin.js +168 -11
  2. package/dist/index.js +190 -31
  3. package/package.json +1 -1
package/dist/admin.js CHANGED
@@ -273,6 +273,137 @@ function MetricTable({
273
273
  ] }, item.urlPath)) })
274
274
  ] }) });
275
275
  }
276
+ function KpiDeltaCard({ metric }) {
277
+ return /* @__PURE__ */ jsx(
278
+ StatCard,
279
+ {
280
+ label: metric.label,
281
+ value: formatInteger(metric.current),
282
+ note: `vs prev ${formatSignedInteger(metric.delta)} (${formatInteger(metric.previous)})`
283
+ }
284
+ );
285
+ }
286
+ function TrendPanel({
287
+ title,
288
+ subtitle,
289
+ metrics,
290
+ trend
291
+ }) {
292
+ return /* @__PURE__ */ jsx(Section, { title, subtitle, children: /* @__PURE__ */ jsx("div", { className: "grid gap-4 lg:grid-cols-2", children: metrics.map((metric) => /* @__PURE__ */ jsx(TrendMetricCard, { metric, trend }, metric.key)) }) });
293
+ }
294
+ function TrendMetricCard({
295
+ metric,
296
+ trend
297
+ }) {
298
+ const data = trend.map((row) => ({
299
+ date: row.date,
300
+ value: metric.key === "gscClicks" ? row.gscClicks : metric.key === "gscImpressions" ? row.gscImpressions : metric.key === "gaViews" ? row.gaViews : metric.key === "gaUsers" ? row.gaUsers : row.gaSessions
301
+ }));
302
+ return /* @__PURE__ */ jsxs("div", { className: "rounded-xl border border-border bg-background p-4", children: [
303
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-3", children: [
304
+ /* @__PURE__ */ jsxs("div", { children: [
305
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-medium", children: metric.label }),
306
+ /* @__PURE__ */ jsx("div", { className: "mt-1 text-2xl font-semibold", children: formatInteger(metric.current) })
307
+ ] }),
308
+ /* @__PURE__ */ jsx("div", { className: `text-sm font-medium ${metric.delta >= 0 ? "text-emerald-700" : "text-red-700"}`, children: formatSignedInteger(metric.delta) })
309
+ ] }),
310
+ /* @__PURE__ */ jsx("div", { className: "mt-3", children: /* @__PURE__ */ jsx(Sparkline, { data }) }),
311
+ /* @__PURE__ */ jsxs("div", { className: "mt-2 text-xs text-muted-foreground", children: [
312
+ "Previous 28d: ",
313
+ formatInteger(metric.previous)
314
+ ] })
315
+ ] });
316
+ }
317
+ function Sparkline({
318
+ data
319
+ }) {
320
+ if (data.length === 0) {
321
+ return /* @__PURE__ */ jsx("div", { className: "h-28 rounded-lg border border-dashed border-border bg-card" });
322
+ }
323
+ const width = 320;
324
+ const height = 112;
325
+ const padding = 10;
326
+ const values = data.map((item) => item.value);
327
+ const max = Math.max(...values, 1);
328
+ const min = Math.min(...values, 0);
329
+ const range = Math.max(max - min, 1);
330
+ const points = data.map((item, index) => {
331
+ const x = padding + index / Math.max(data.length - 1, 1) * (width - padding * 2);
332
+ const y = height - padding - (item.value - min) / range * (height - padding * 2);
333
+ return `${x},${y}`;
334
+ });
335
+ return /* @__PURE__ */ jsx("svg", { viewBox: `0 0 ${width} ${height}`, className: "h-28 w-full overflow-visible", children: /* @__PURE__ */ jsx(
336
+ "path",
337
+ {
338
+ d: `M ${points.join(" L ")}`,
339
+ fill: "none",
340
+ stroke: "var(--color-kumo-brand)",
341
+ strokeWidth: "2.5",
342
+ strokeLinecap: "round",
343
+ strokeLinejoin: "round"
344
+ }
345
+ ) });
346
+ }
347
+ function BreakdownTable({
348
+ rows,
349
+ emptyMessage
350
+ }) {
351
+ if (rows.length === 0) {
352
+ return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: emptyMessage });
353
+ }
354
+ return /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "min-w-full text-sm", children: [
355
+ /* @__PURE__ */ jsx("thead", { className: "text-left text-xs uppercase tracking-[0.16em] text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
356
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Group" }),
357
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Pages" }),
358
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GSC Clicks" }),
359
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GA Views" }),
360
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GA Sessions" })
361
+ ] }) }),
362
+ /* @__PURE__ */ jsx("tbody", { children: rows.map((row) => /* @__PURE__ */ jsxs("tr", { className: "border-t border-border/80", children: [
363
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4 font-medium", children: row.label }),
364
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatInteger(row.trackedPages) }),
365
+ /* @__PURE__ */ jsxs("td", { className: "py-3 pr-4", children: [
366
+ formatInteger(row.current.gscClicks),
367
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: formatSignedInteger(row.delta.gscClicks) })
368
+ ] }),
369
+ /* @__PURE__ */ jsxs("td", { className: "py-3 pr-4", children: [
370
+ formatInteger(row.current.gaViews),
371
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: formatSignedInteger(row.delta.gaViews) })
372
+ ] }),
373
+ /* @__PURE__ */ jsxs("td", { className: "py-3 pr-4", children: [
374
+ formatInteger(row.current.gaSessions),
375
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: formatSignedInteger(row.delta.gaSessions) })
376
+ ] })
377
+ ] }, row.key)) })
378
+ ] }) });
379
+ }
380
+ function MoversTable({
381
+ items,
382
+ emptyMessage
383
+ }) {
384
+ if (items.length === 0) {
385
+ return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: emptyMessage });
386
+ }
387
+ return /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "min-w-full text-sm", children: [
388
+ /* @__PURE__ */ jsx("thead", { className: "text-left text-xs uppercase tracking-[0.16em] text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
389
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Page" }),
390
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Type" }),
391
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GA Views \u0394" }),
392
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GSC Clicks \u0394" }),
393
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Current Views" })
394
+ ] }) }),
395
+ /* @__PURE__ */ jsx("tbody", { children: items.map((item) => /* @__PURE__ */ jsxs("tr", { className: "border-t border-border/80", children: [
396
+ /* @__PURE__ */ jsxs("td", { className: "py-3 pr-4", children: [
397
+ /* @__PURE__ */ jsx("div", { className: "font-medium", children: item.title }),
398
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: item.urlPath })
399
+ ] }),
400
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: pageKindLabel(item.pageKind) }),
401
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatSignedInteger(item.gaViewsDelta) }),
402
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatSignedInteger(item.gscClicksDelta) }),
403
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatInteger(item.gaViews28d) })
404
+ ] }, item.urlPath)) })
405
+ ] }) });
406
+ }
276
407
  function OverviewPage() {
277
408
  const [status, setStatus] = React.useState(null);
278
409
  const [overview, setOverview] = React.useState(null);
@@ -307,7 +438,7 @@ function OverviewPage() {
307
438
  Shell,
308
439
  {
309
440
  title: "Content Insights",
310
- description: "Prioritize pages with the clearest opportunities using combined Search Console and GA4 data.",
441
+ description: "Monitor site health, compare the last 28 days to the previous window, and spot pages that changed fastest.",
311
442
  actions: /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: () => void load(), disabled: loading, children: "Reload" }),
312
443
  children: [
313
444
  /* @__PURE__ */ jsx(ErrorBanner, { message: error }),
@@ -318,17 +449,38 @@ function OverviewPage() {
318
449
  /* @__PURE__ */ jsx(StatCard, { label: "GA Final Date", value: freshness.lastGaDate || "-" }),
319
450
  /* @__PURE__ */ jsx(StatCard, { label: "Service Account", value: status?.config?.serviceAccountEmail || "-" })
320
451
  ] }) }),
321
- /* @__PURE__ */ jsx(Section, { title: "KPI Snapshot", subtitle: "Aggregated totals for the last 28 days across public pages.", children: /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-3 xl:grid-cols-6", children: [
322
- /* @__PURE__ */ jsx(StatCard, { label: "GSC Clicks", value: formatInteger(summary?.totals.gscClicks28d ?? 0) }),
323
- /* @__PURE__ */ jsx(StatCard, { label: "GSC Impressions", value: formatInteger(summary?.totals.gscImpressions28d ?? 0) }),
324
- /* @__PURE__ */ jsx(StatCard, { label: "GA Views", value: formatInteger(summary?.totals.gaViews28d ?? 0) }),
325
- /* @__PURE__ */ jsx(StatCard, { label: "GA Users", value: formatInteger(summary?.totals.gaUsers28d ?? 0) }),
326
- /* @__PURE__ */ jsx(StatCard, { label: "GA Sessions", value: formatInteger(summary?.totals.gaSessions28d ?? 0) }),
327
- /* @__PURE__ */ jsx(StatCard, { label: "Managed Opportunities", value: formatInteger(summary?.totals.managedOpportunities ?? 0) })
328
- ] }) }),
452
+ /* @__PURE__ */ jsx(Section, { title: "KPI Snapshot", subtitle: "Current 28 days versus the previous 28 days across all tracked public pages.", children: /* @__PURE__ */ jsx("div", { className: "grid gap-4 md:grid-cols-2 xl:grid-cols-5", children: (overview?.kpiDeltas ?? []).map((metric) => /* @__PURE__ */ jsx(KpiDeltaCard, { metric }, metric.key)) }) }),
453
+ summary ? /* @__PURE__ */ jsxs("div", { className: "grid gap-6 xl:grid-cols-2", children: [
454
+ /* @__PURE__ */ jsx(
455
+ TrendPanel,
456
+ {
457
+ title: "Search Trend",
458
+ subtitle: "Daily search demand and click capture for the current 28-day window.",
459
+ metrics: (overview?.kpiDeltas ?? []).filter(
460
+ (metric) => metric.key === "gscClicks" || metric.key === "gscImpressions"
461
+ ),
462
+ trend: summary.trend
463
+ }
464
+ ),
465
+ /* @__PURE__ */ jsx(
466
+ TrendPanel,
467
+ {
468
+ title: "Traffic Trend",
469
+ subtitle: "Daily traffic movement from GA4 for the current 28-day window.",
470
+ metrics: (overview?.kpiDeltas ?? []).filter(
471
+ (metric) => metric.key === "gaViews" || metric.key === "gaSessions"
472
+ ),
473
+ trend: summary.trend
474
+ }
475
+ )
476
+ ] }) : null,
329
477
  /* @__PURE__ */ jsxs("div", { className: "grid gap-6 xl:grid-cols-2", children: [
330
- /* @__PURE__ */ jsx(Section, { title: "Top Opportunities", subtitle: "Managed content only.", children: /* @__PURE__ */ jsx(MetricTable, { items: overview?.topOpportunities ?? [], emptyMessage: "No opportunities yet." }) }),
331
- /* @__PURE__ */ jsx(Section, { title: "Top Unmanaged Pages", subtitle: "Public pages outside EmDash-managed content.", children: /* @__PURE__ */ jsx(MetricTable, { items: overview?.topUnmanaged ?? [], emptyMessage: "No unmanaged page data yet." }) })
478
+ /* @__PURE__ */ jsx(Section, { title: "Page Mix", subtitle: "Compare page groups by current volume and change from the previous window.", children: /* @__PURE__ */ jsx(BreakdownTable, { rows: overview?.pageKindBreakdown ?? [], emptyMessage: "No tracked pages yet." }) }),
479
+ /* @__PURE__ */ jsx(Section, { title: "Managed Coverage", subtitle: "See whether growth is coming from EmDash-managed content or unmanaged pages.", children: /* @__PURE__ */ jsx(BreakdownTable, { rows: overview?.managedBreakdown ?? [], emptyMessage: "No tracked pages yet." }) })
480
+ ] }),
481
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-6 xl:grid-cols-2", children: [
482
+ /* @__PURE__ */ jsx(Section, { title: "Top Gainers", subtitle: "Pages with the strongest positive movement in the last 28 days.", children: /* @__PURE__ */ jsx(MoversTable, { items: overview?.topGainers ?? [], emptyMessage: "No gaining pages yet." }) }),
483
+ /* @__PURE__ */ jsx(Section, { title: "Top Decliners", subtitle: "Pages with the sharpest drop and the clearest candidates for investigation.", children: /* @__PURE__ */ jsx(MoversTable, { items: overview?.topDecliners ?? [], emptyMessage: "No declining pages yet." }) })
332
484
  ] }),
333
485
  /* @__PURE__ */ jsx(Section, { title: "Reporting Windows", subtitle: "The agent API returns the same windows.", children: /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [
334
486
  /* @__PURE__ */ jsx(WindowCard, { label: "GSC Current", value: summary?.window.gscCurrent }),
@@ -789,6 +941,11 @@ function WindowCard({
789
941
  function formatInteger(value) {
790
942
  return new Intl.NumberFormat("ja-JP").format(value ?? 0);
791
943
  }
944
+ function formatSignedInteger(value) {
945
+ const numeric = value ?? 0;
946
+ if (numeric === 0) return "0";
947
+ return `${numeric > 0 ? "+" : ""}${formatInteger(numeric)}`;
948
+ }
792
949
  function formatPercent(value) {
793
950
  return `${((value ?? 0) * 100).toFixed(1)}%`;
794
951
  }
package/dist/index.js CHANGED
@@ -264,31 +264,36 @@ function buildContentUrl(siteOrigin, urlPath) {
264
264
  return new URL(urlPath, `${siteOrigin}/`).toString();
265
265
  }
266
266
  async function getManagedContentMap(siteOrigin) {
267
- const result = await getEmDashCollection("posts", {
268
- status: "published",
269
- limit: 1e3,
270
- orderBy: { updatedAt: "desc" }
271
- });
272
267
  const managed = /* @__PURE__ */ new Map();
273
- for (const entry of result.entries) {
274
- const id = typeof entry.id === "string" ? entry.id : "";
275
- if (!id) continue;
276
- const slug = typeof entry.slug === "string" ? entry.slug : null;
277
- const data = entry.data ?? {};
278
- const title = typeof data.title === "string" ? data.title : slug || id;
279
- const excerpt = typeof data.excerpt === "string" ? data.excerpt : void 0;
280
- const seoDescription = typeof data.seo_description === "string" ? data.seo_description : void 0;
281
- const urlPath = `/blog/${slug || id}/`;
282
- managed.set(urlPath, {
283
- collection: "posts",
284
- id,
285
- slug,
286
- urlPath,
287
- title,
288
- excerpt,
289
- seoDescription
268
+ let cursor;
269
+ do {
270
+ const result = await getEmDashCollection("posts", {
271
+ status: "published",
272
+ limit: 50,
273
+ cursor,
274
+ orderBy: { updatedAt: "desc" }
290
275
  });
291
- }
276
+ for (const entry of result.entries) {
277
+ const id = typeof entry.id === "string" ? entry.id : "";
278
+ if (!id) continue;
279
+ const slug = typeof entry.slug === "string" ? entry.slug : null;
280
+ const data = entry.data ?? {};
281
+ const title = typeof data.title === "string" ? data.title : slug || id;
282
+ const excerpt = typeof data.excerpt === "string" ? data.excerpt : void 0;
283
+ const seoDescription = typeof data.seo_description === "string" ? data.seo_description : void 0;
284
+ const urlPath = `/blog/${slug || id}/`;
285
+ managed.set(urlPath, {
286
+ collection: "posts",
287
+ id,
288
+ slug,
289
+ urlPath,
290
+ title,
291
+ excerpt,
292
+ seoDescription
293
+ });
294
+ }
295
+ cursor = result.nextCursor;
296
+ } while (cursor);
292
297
  void siteOrigin;
293
298
  return managed;
294
299
  }
@@ -997,7 +1002,7 @@ async function listPages(ctx, filters) {
997
1002
  };
998
1003
  }
999
1004
  async function getOverview(ctx) {
1000
- const [summary, freshness, topOpportunities, topUnmanaged] = await Promise.all([
1005
+ const [summary, freshness, topOpportunities, topUnmanaged, allPages] = await Promise.all([
1001
1006
  ctx.kv.get(SITE_SUMMARY_KEY),
1002
1007
  getFreshness(ctx),
1003
1008
  ctx.storage.pages.query({
@@ -1009,13 +1014,36 @@ async function getOverview(ctx) {
1009
1014
  where: { managed: false },
1010
1015
  orderBy: { gaViews28d: "desc" },
1011
1016
  limit: 5
1012
- })
1017
+ }),
1018
+ listAllPages(ctx)
1013
1019
  ]);
1020
+ return buildOverviewData(
1021
+ summary,
1022
+ freshness,
1023
+ allPages,
1024
+ topOpportunities.items.map((item) => item.data),
1025
+ topUnmanaged.items.map((item) => item.data)
1026
+ );
1027
+ }
1028
+ function buildOverviewData(summary, freshness, allPages, topOpportunities, topUnmanaged) {
1014
1029
  return {
1015
1030
  summary,
1016
1031
  freshness,
1017
- topOpportunities: topOpportunities.items.map((item) => item.data),
1018
- topUnmanaged: topUnmanaged.items.map((item) => item.data)
1032
+ kpiDeltas: buildKpiDeltas(allPages),
1033
+ pageKindBreakdown: buildBreakdown(
1034
+ allPages,
1035
+ (page) => page.pageKind,
1036
+ (key) => pageKindLabel(key)
1037
+ ),
1038
+ managedBreakdown: buildBreakdown(
1039
+ allPages,
1040
+ (page) => page.managed ? "managed" : "unmanaged",
1041
+ (key) => key === "managed" ? "Managed" : "Unmanaged"
1042
+ ),
1043
+ topGainers: buildMovers(allPages, "gainers"),
1044
+ topDecliners: buildMovers(allPages, "decliners"),
1045
+ topOpportunities,
1046
+ topUnmanaged
1019
1047
  };
1020
1048
  }
1021
1049
  async function getContentContext(ctx, collection, id, slug) {
@@ -1235,6 +1263,19 @@ async function getFreshness(ctx) {
1235
1263
  lastStatus: "idle"
1236
1264
  };
1237
1265
  }
1266
+ async function listAllPages(ctx) {
1267
+ const pages = [];
1268
+ let cursor;
1269
+ do {
1270
+ const batch = await ctx.storage.pages.query({
1271
+ limit: 500,
1272
+ cursor
1273
+ });
1274
+ cursor = batch.cursor;
1275
+ pages.push(...batch.items.map((item) => item.data));
1276
+ } while (cursor);
1277
+ return pages;
1278
+ }
1238
1279
  async function refreshSummaryFromStorage(ctx) {
1239
1280
  const windows = buildWindows();
1240
1281
  const allPages = [];
@@ -1327,6 +1368,112 @@ function mergeTrend(gscTrend, gaTrend) {
1327
1368
  }
1328
1369
  return Array.from(map.values()).sort((left, right) => left.date.localeCompare(right.date));
1329
1370
  }
1371
+ function buildKpiDeltas(pages) {
1372
+ const metrics = [
1373
+ {
1374
+ key: "gscClicks",
1375
+ label: "GSC Clicks",
1376
+ current: sumPages(pages, (page) => page.gscClicks28d),
1377
+ previous: sumPages(pages, (page) => page.gscClicksPrev28d)
1378
+ },
1379
+ {
1380
+ key: "gscImpressions",
1381
+ label: "GSC Impressions",
1382
+ current: sumPages(pages, (page) => page.gscImpressions28d),
1383
+ previous: sumPages(pages, (page) => page.gscImpressionsPrev28d)
1384
+ },
1385
+ {
1386
+ key: "gaViews",
1387
+ label: "GA Views",
1388
+ current: sumPages(pages, (page) => page.gaViews28d),
1389
+ previous: sumPages(pages, (page) => page.gaViewsPrev28d)
1390
+ },
1391
+ {
1392
+ key: "gaUsers",
1393
+ label: "GA Users",
1394
+ current: sumPages(pages, (page) => page.gaUsers28d),
1395
+ previous: sumPages(pages, (page) => page.gaUsersPrev28d)
1396
+ },
1397
+ {
1398
+ key: "gaSessions",
1399
+ label: "GA Sessions",
1400
+ current: sumPages(pages, (page) => page.gaSessions28d),
1401
+ previous: sumPages(pages, (page) => page.gaSessionsPrev28d)
1402
+ }
1403
+ ];
1404
+ return metrics.map((metric) => ({
1405
+ ...metric,
1406
+ delta: metric.current - metric.previous
1407
+ }));
1408
+ }
1409
+ function buildBreakdown(pages, getKey, getLabel) {
1410
+ const buckets = /* @__PURE__ */ new Map();
1411
+ for (const page of pages) {
1412
+ const key = getKey(page);
1413
+ const existing = buckets.get(key) ?? {
1414
+ key,
1415
+ label: getLabel(key),
1416
+ trackedPages: 0,
1417
+ current: { gscClicks: 0, gaViews: 0, gaSessions: 0 },
1418
+ previous: { gscClicks: 0, gaViews: 0, gaSessions: 0 },
1419
+ delta: { gscClicks: 0, gaViews: 0, gaSessions: 0 }
1420
+ };
1421
+ existing.trackedPages += 1;
1422
+ existing.current.gscClicks += page.gscClicks28d;
1423
+ existing.current.gaViews += page.gaViews28d;
1424
+ existing.current.gaSessions += page.gaSessions28d;
1425
+ existing.previous.gscClicks += page.gscClicksPrev28d;
1426
+ existing.previous.gaViews += page.gaViewsPrev28d;
1427
+ existing.previous.gaSessions += page.gaSessionsPrev28d;
1428
+ existing.delta.gscClicks = existing.current.gscClicks - existing.previous.gscClicks;
1429
+ existing.delta.gaViews = existing.current.gaViews - existing.previous.gaViews;
1430
+ existing.delta.gaSessions = existing.current.gaSessions - existing.previous.gaSessions;
1431
+ buckets.set(key, existing);
1432
+ }
1433
+ return Array.from(buckets.values()).sort((left, right) => right.current.gaViews - left.current.gaViews);
1434
+ }
1435
+ function buildMovers(pages, direction) {
1436
+ const rows = pages.map((page) => ({
1437
+ urlPath: page.urlPath,
1438
+ title: page.title,
1439
+ pageKind: page.pageKind,
1440
+ managed: page.managed,
1441
+ gscClicks28d: page.gscClicks28d,
1442
+ gaViews28d: page.gaViews28d,
1443
+ gscClicksDelta: page.gscClicks28d - page.gscClicksPrev28d,
1444
+ gaViewsDelta: page.gaViews28d - page.gaViewsPrev28d,
1445
+ opportunityScore: page.opportunityScore
1446
+ }));
1447
+ const filtered = rows.filter(
1448
+ (row) => direction === "gainers" ? row.gaViewsDelta > 0 || row.gscClicksDelta > 0 : row.gaViewsDelta < 0 || row.gscClicksDelta < 0
1449
+ );
1450
+ filtered.sort((left, right) => {
1451
+ if (direction === "gainers") {
1452
+ return right.gaViewsDelta - left.gaViewsDelta || right.gscClicksDelta - left.gscClicksDelta || right.gaViews28d - left.gaViews28d;
1453
+ }
1454
+ return left.gaViewsDelta - right.gaViewsDelta || left.gscClicksDelta - right.gscClicksDelta || right.gaViews28d - left.gaViews28d;
1455
+ });
1456
+ return filtered.slice(0, 5);
1457
+ }
1458
+ function sumPages(pages, getValue) {
1459
+ return pages.reduce((total, page) => total + getValue(page), 0);
1460
+ }
1461
+ function pageKindLabel(pageKind) {
1462
+ switch (pageKind) {
1463
+ case "blog_post":
1464
+ return "Blog Post";
1465
+ case "blog_archive":
1466
+ return "Blog Archive";
1467
+ case "tag":
1468
+ return "Tag";
1469
+ case "author":
1470
+ return "Author";
1471
+ case "landing":
1472
+ return "Landing";
1473
+ default:
1474
+ return "Other";
1475
+ }
1476
+ }
1330
1477
 
1331
1478
  // src/index.ts
1332
1479
  var configSaveSchema = z2.object({
@@ -1473,14 +1620,26 @@ function createPlugin() {
1473
1620
  if (!resolved.success) {
1474
1621
  throw new PluginRouteError2("BAD_REQUEST", resolved.message, 400);
1475
1622
  }
1476
- return testConnection(ctx, resolved.data);
1623
+ try {
1624
+ return testConnection(ctx, resolved.data);
1625
+ } catch (error) {
1626
+ const message = error instanceof Error ? error.message : "Connection test failed";
1627
+ console.error("[analytics-plugin] connection test failed", error);
1628
+ throw new PluginRouteError2("INTERNAL_ERROR", message, 500);
1629
+ }
1477
1630
  }
1478
1631
  },
1479
1632
  [ADMIN_ROUTES.SYNC_NOW]: {
1480
1633
  handler: async (ctx) => {
1481
- const base = await syncBase(ctx, "manual");
1482
- const enriched = await enrichManagedQueries(ctx);
1483
- return { ...base, ...enriched };
1634
+ try {
1635
+ const base = await syncBase(ctx, "manual");
1636
+ const enriched = await enrichManagedQueries(ctx);
1637
+ return { ...base, ...enriched };
1638
+ } catch (error) {
1639
+ const message = error instanceof Error ? error.message : "Manual sync failed";
1640
+ console.error("[analytics-plugin] manual sync failed", error);
1641
+ throw new PluginRouteError2("INTERNAL_ERROR", message, 500);
1642
+ }
1484
1643
  }
1485
1644
  },
1486
1645
  [ADMIN_ROUTES.AGENT_KEYS_LIST]: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yourbright/emdash-analytics-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Google Search Console and GA4 analytics plugin for EmDash",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",