@yourbright/emdash-analytics-plugin 0.1.2 → 0.1.4

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 +369 -95
  2. package/dist/index.js +192 -37
  3. package/package.json +1 -1
package/dist/admin.js CHANGED
@@ -19,7 +19,7 @@ var ADMIN_ROUTES = {
19
19
  };
20
20
 
21
21
  // src/admin.tsx
22
- import { jsx, jsxs } from "react/jsx-runtime";
22
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
23
23
  var API_BASE = `/_emdash/api/plugins/${PLUGIN_ID}`;
24
24
  var EMPTY_CONFIG = {
25
25
  siteOrigin: "",
@@ -138,6 +138,19 @@ function Section({
138
138
  children
139
139
  ] });
140
140
  }
141
+ function InlineHeader({
142
+ title,
143
+ description,
144
+ actions
145
+ }) {
146
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 border-b border-border pb-4 md:flex-row md:items-end md:justify-between", children: [
147
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
148
+ /* @__PURE__ */ jsx("h2", { className: "text-xl font-semibold tracking-tight", children: title }),
149
+ description ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: description }) : null
150
+ ] }),
151
+ actions ? /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: actions }) : null
152
+ ] });
153
+ }
141
154
  function StatCard({
142
155
  label,
143
156
  value,
@@ -149,6 +162,45 @@ function StatCard({
149
162
  note ? /* @__PURE__ */ jsx("div", { className: "mt-1 text-xs text-muted-foreground", children: note }) : null
150
163
  ] });
151
164
  }
165
+ function sectionFromLocation() {
166
+ if (typeof window === "undefined") return "dashboard";
167
+ const url = new URL(window.location.href);
168
+ const section = url.searchParams.get("section");
169
+ return section === "pages" || section === "settings" ? section : "dashboard";
170
+ }
171
+ function updateSectionInUrl(section) {
172
+ if (typeof window === "undefined") return;
173
+ const url = new URL(window.location.href);
174
+ if (section === "dashboard") {
175
+ url.searchParams.delete("section");
176
+ } else {
177
+ url.searchParams.set("section", section);
178
+ }
179
+ window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
180
+ }
181
+ function AnalyticsTabs({
182
+ section,
183
+ onChange
184
+ }) {
185
+ const tabs = [
186
+ { key: "dashboard", label: "Dashboard" },
187
+ { key: "pages", label: "Pages" },
188
+ { key: "settings", label: "Settings" }
189
+ ];
190
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: tabs.map((tab) => {
191
+ const active = tab.key === section;
192
+ return /* @__PURE__ */ jsx(
193
+ "button",
194
+ {
195
+ type: "button",
196
+ onClick: () => onChange(tab.key),
197
+ className: `rounded-full border px-3 py-2 text-sm font-medium transition ${active ? "border-foreground bg-foreground text-background" : "border-border bg-background text-foreground hover:bg-accent"}`,
198
+ children: tab.label
199
+ },
200
+ tab.key
201
+ );
202
+ }) });
203
+ }
152
204
  function ErrorBanner({ message }) {
153
205
  if (!message) return null;
154
206
  return /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700", children: message });
@@ -273,7 +325,138 @@ function MetricTable({
273
325
  ] }, item.urlPath)) })
274
326
  ] }) });
275
327
  }
276
- function OverviewPage() {
328
+ function KpiDeltaCard({ metric }) {
329
+ return /* @__PURE__ */ jsx(
330
+ StatCard,
331
+ {
332
+ label: metric.label,
333
+ value: formatInteger(metric.current),
334
+ note: `vs prev ${formatSignedInteger(metric.delta)} (${formatInteger(metric.previous)})`
335
+ }
336
+ );
337
+ }
338
+ function TrendPanel({
339
+ title,
340
+ subtitle,
341
+ metrics,
342
+ trend
343
+ }) {
344
+ 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)) }) });
345
+ }
346
+ function TrendMetricCard({
347
+ metric,
348
+ trend
349
+ }) {
350
+ const data = trend.map((row) => ({
351
+ date: row.date,
352
+ value: metric.key === "gscClicks" ? row.gscClicks : metric.key === "gscImpressions" ? row.gscImpressions : metric.key === "gaViews" ? row.gaViews : metric.key === "gaUsers" ? row.gaUsers : row.gaSessions
353
+ }));
354
+ return /* @__PURE__ */ jsxs("div", { className: "rounded-xl border border-border bg-background p-4", children: [
355
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-3", children: [
356
+ /* @__PURE__ */ jsxs("div", { children: [
357
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-medium", children: metric.label }),
358
+ /* @__PURE__ */ jsx("div", { className: "mt-1 text-2xl font-semibold", children: formatInteger(metric.current) })
359
+ ] }),
360
+ /* @__PURE__ */ jsx("div", { className: `text-sm font-medium ${metric.delta >= 0 ? "text-emerald-700" : "text-red-700"}`, children: formatSignedInteger(metric.delta) })
361
+ ] }),
362
+ /* @__PURE__ */ jsx("div", { className: "mt-3", children: /* @__PURE__ */ jsx(Sparkline, { data }) }),
363
+ /* @__PURE__ */ jsxs("div", { className: "mt-2 text-xs text-muted-foreground", children: [
364
+ "Previous 28d: ",
365
+ formatInteger(metric.previous)
366
+ ] })
367
+ ] });
368
+ }
369
+ function Sparkline({
370
+ data
371
+ }) {
372
+ if (data.length === 0) {
373
+ return /* @__PURE__ */ jsx("div", { className: "h-28 rounded-lg border border-dashed border-border bg-card" });
374
+ }
375
+ const width = 320;
376
+ const height = 112;
377
+ const padding = 10;
378
+ const values = data.map((item) => item.value);
379
+ const max = Math.max(...values, 1);
380
+ const min = Math.min(...values, 0);
381
+ const range = Math.max(max - min, 1);
382
+ const points = data.map((item, index) => {
383
+ const x = padding + index / Math.max(data.length - 1, 1) * (width - padding * 2);
384
+ const y = height - padding - (item.value - min) / range * (height - padding * 2);
385
+ return `${x},${y}`;
386
+ });
387
+ return /* @__PURE__ */ jsx("svg", { viewBox: `0 0 ${width} ${height}`, className: "h-28 w-full overflow-visible", children: /* @__PURE__ */ jsx(
388
+ "path",
389
+ {
390
+ d: `M ${points.join(" L ")}`,
391
+ fill: "none",
392
+ stroke: "var(--color-kumo-brand)",
393
+ strokeWidth: "2.5",
394
+ strokeLinecap: "round",
395
+ strokeLinejoin: "round"
396
+ }
397
+ ) });
398
+ }
399
+ function BreakdownTable({
400
+ rows,
401
+ emptyMessage
402
+ }) {
403
+ if (rows.length === 0) {
404
+ return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: emptyMessage });
405
+ }
406
+ return /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "min-w-full text-sm", children: [
407
+ /* @__PURE__ */ jsx("thead", { className: "text-left text-xs uppercase tracking-[0.16em] text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
408
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Group" }),
409
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Pages" }),
410
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GSC Clicks" }),
411
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GA Views" }),
412
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GA Sessions" })
413
+ ] }) }),
414
+ /* @__PURE__ */ jsx("tbody", { children: rows.map((row) => /* @__PURE__ */ jsxs("tr", { className: "border-t border-border/80", children: [
415
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4 font-medium", children: row.label }),
416
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatInteger(row.trackedPages) }),
417
+ /* @__PURE__ */ jsxs("td", { className: "py-3 pr-4", children: [
418
+ formatInteger(row.current.gscClicks),
419
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: formatSignedInteger(row.delta.gscClicks) })
420
+ ] }),
421
+ /* @__PURE__ */ jsxs("td", { className: "py-3 pr-4", children: [
422
+ formatInteger(row.current.gaViews),
423
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: formatSignedInteger(row.delta.gaViews) })
424
+ ] }),
425
+ /* @__PURE__ */ jsxs("td", { className: "py-3 pr-4", children: [
426
+ formatInteger(row.current.gaSessions),
427
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: formatSignedInteger(row.delta.gaSessions) })
428
+ ] })
429
+ ] }, row.key)) })
430
+ ] }) });
431
+ }
432
+ function MoversTable({
433
+ items,
434
+ emptyMessage
435
+ }) {
436
+ if (items.length === 0) {
437
+ return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: emptyMessage });
438
+ }
439
+ return /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "min-w-full text-sm", children: [
440
+ /* @__PURE__ */ jsx("thead", { className: "text-left text-xs uppercase tracking-[0.16em] text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
441
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Page" }),
442
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Type" }),
443
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GA Views \u0394" }),
444
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "GSC Clicks \u0394" }),
445
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Current Views" })
446
+ ] }) }),
447
+ /* @__PURE__ */ jsx("tbody", { children: items.map((item) => /* @__PURE__ */ jsxs("tr", { className: "border-t border-border/80", children: [
448
+ /* @__PURE__ */ jsxs("td", { className: "py-3 pr-4", children: [
449
+ /* @__PURE__ */ jsx("div", { className: "font-medium", children: item.title }),
450
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: item.urlPath })
451
+ ] }),
452
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: pageKindLabel(item.pageKind) }),
453
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatSignedInteger(item.gaViewsDelta) }),
454
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatSignedInteger(item.gscClicksDelta) }),
455
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatInteger(item.gaViews28d) })
456
+ ] }, item.urlPath)) })
457
+ ] }) });
458
+ }
459
+ function OverviewPage({ embedded = false }) {
277
460
  const [status, setStatus] = React.useState(null);
278
461
  const [overview, setOverview] = React.useState(null);
279
462
  const [loading, setLoading] = React.useState(true);
@@ -303,44 +486,79 @@ function OverviewPage() {
303
486
  }, [load]);
304
487
  const summary = overview?.summary ?? status?.summary ?? null;
305
488
  const freshness = overview?.freshness ?? status?.freshness ?? idleFreshness();
306
- return /* @__PURE__ */ jsxs(
489
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
490
+ /* @__PURE__ */ jsx(ErrorBanner, { message: error }),
491
+ !status?.config ? /* @__PURE__ */ jsx(Section, { title: "Not Configured", subtitle: "Save your Google connection settings first.", children: /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: "After saving the configuration, run a manual sync to populate this dashboard." }) }) : null,
492
+ /* @__PURE__ */ jsx(Section, { title: "Freshness", subtitle: "Track the latest sync and the effective source dates.", children: /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-4", children: [
493
+ /* @__PURE__ */ jsx(StatCard, { label: "Last Sync", value: formatDateTime(freshness.lastSyncedAt), note: statusLabel(freshness.lastStatus) }),
494
+ /* @__PURE__ */ jsx(StatCard, { label: "GSC Final Date", value: freshness.lastGscDate || "-" }),
495
+ /* @__PURE__ */ jsx(StatCard, { label: "GA Final Date", value: freshness.lastGaDate || "-" }),
496
+ /* @__PURE__ */ jsx(StatCard, { label: "Service Account", value: status?.config?.serviceAccountEmail || "-" })
497
+ ] }) }),
498
+ /* @__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)) }) }),
499
+ summary ? /* @__PURE__ */ jsxs("div", { className: "grid gap-6 xl:grid-cols-2", children: [
500
+ /* @__PURE__ */ jsx(
501
+ TrendPanel,
502
+ {
503
+ title: "Search Trend",
504
+ subtitle: "Daily search demand and click capture for the current 28-day window.",
505
+ metrics: (overview?.kpiDeltas ?? []).filter(
506
+ (metric) => metric.key === "gscClicks" || metric.key === "gscImpressions"
507
+ ),
508
+ trend: summary.trend
509
+ }
510
+ ),
511
+ /* @__PURE__ */ jsx(
512
+ TrendPanel,
513
+ {
514
+ title: "Traffic Trend",
515
+ subtitle: "Daily traffic movement from GA4 for the current 28-day window.",
516
+ metrics: (overview?.kpiDeltas ?? []).filter(
517
+ (metric) => metric.key === "gaViews" || metric.key === "gaSessions"
518
+ ),
519
+ trend: summary.trend
520
+ }
521
+ )
522
+ ] }) : null,
523
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-6 xl:grid-cols-2", children: [
524
+ /* @__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." }) }),
525
+ /* @__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." }) })
526
+ ] }),
527
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-6 xl:grid-cols-2", children: [
528
+ /* @__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." }) }),
529
+ /* @__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." }) })
530
+ ] }),
531
+ /* @__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: [
532
+ /* @__PURE__ */ jsx(WindowCard, { label: "GSC Current", value: summary?.window.gscCurrent }),
533
+ /* @__PURE__ */ jsx(WindowCard, { label: "GSC Previous", value: summary?.window.gscPrevious }),
534
+ /* @__PURE__ */ jsx(WindowCard, { label: "GA Current", value: summary?.window.gaCurrent }),
535
+ /* @__PURE__ */ jsx(WindowCard, { label: "GA Previous", value: summary?.window.gaPrevious })
536
+ ] }) })
537
+ ] });
538
+ if (embedded) {
539
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
540
+ /* @__PURE__ */ jsx(
541
+ InlineHeader,
542
+ {
543
+ title: "Dashboard",
544
+ description: "Monitor site health, compare the last 28 days to the previous window, and spot pages that changed fastest.",
545
+ actions: /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: () => void load(), disabled: loading, children: "Reload" })
546
+ }
547
+ ),
548
+ content
549
+ ] });
550
+ }
551
+ return /* @__PURE__ */ jsx(
307
552
  Shell,
308
553
  {
309
554
  title: "Content Insights",
310
- description: "Prioritize pages with the clearest opportunities using combined Search Console and GA4 data.",
555
+ description: "Monitor site health, compare the last 28 days to the previous window, and spot pages that changed fastest.",
311
556
  actions: /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: () => void load(), disabled: loading, children: "Reload" }),
312
- children: [
313
- /* @__PURE__ */ jsx(ErrorBanner, { message: error }),
314
- !status?.config ? /* @__PURE__ */ jsx(Section, { title: "Not Configured", subtitle: "Save your Google connection settings first.", children: /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: "After saving the configuration, run a manual sync to populate this dashboard." }) }) : null,
315
- /* @__PURE__ */ jsx(Section, { title: "Freshness", subtitle: "Track the latest sync and the effective source dates.", children: /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-4", children: [
316
- /* @__PURE__ */ jsx(StatCard, { label: "Last Sync", value: formatDateTime(freshness.lastSyncedAt), note: statusLabel(freshness.lastStatus) }),
317
- /* @__PURE__ */ jsx(StatCard, { label: "GSC Final Date", value: freshness.lastGscDate || "-" }),
318
- /* @__PURE__ */ jsx(StatCard, { label: "GA Final Date", value: freshness.lastGaDate || "-" }),
319
- /* @__PURE__ */ jsx(StatCard, { label: "Service Account", value: status?.config?.serviceAccountEmail || "-" })
320
- ] }) }),
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
- ] }) }),
329
- /* @__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." }) })
332
- ] }),
333
- /* @__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
- /* @__PURE__ */ jsx(WindowCard, { label: "GSC Current", value: summary?.window.gscCurrent }),
335
- /* @__PURE__ */ jsx(WindowCard, { label: "GSC Previous", value: summary?.window.gscPrevious }),
336
- /* @__PURE__ */ jsx(WindowCard, { label: "GA Current", value: summary?.window.gaCurrent }),
337
- /* @__PURE__ */ jsx(WindowCard, { label: "GA Previous", value: summary?.window.gaPrevious })
338
- ] }) })
339
- ]
557
+ children: content
340
558
  }
341
559
  );
342
560
  }
343
- function PagesPage() {
561
+ function PagesPage({ embedded = false }) {
344
562
  const [managed, setManaged] = React.useState("all");
345
563
  const [pageKind, setPageKind] = React.useState("all");
346
564
  const [hasOpportunity, setHasOpportunity] = React.useState(false);
@@ -396,7 +614,7 @@ function PagesPage() {
396
614
  cancelled = true;
397
615
  };
398
616
  }, [selected]);
399
- return /* @__PURE__ */ jsxs(Shell, { title: "Pages", description: "Explore all public pages and filter down to the content that needs attention.", children: [
617
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
400
618
  /* @__PURE__ */ jsx(ErrorBanner, { message: error }),
401
619
  /* @__PURE__ */ jsx(Section, { title: "Filters", children: /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-4", children: [
402
620
  /* @__PURE__ */ jsx(Field, { label: "Scope", children: /* @__PURE__ */ jsx(
@@ -485,8 +703,21 @@ function PagesPage() {
485
703
  ] }) })
486
704
  ] })
487
705
  ] });
706
+ if (embedded) {
707
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
708
+ /* @__PURE__ */ jsx(
709
+ InlineHeader,
710
+ {
711
+ title: "Pages",
712
+ description: "Explore all public pages and filter down to the content that needs attention."
713
+ }
714
+ ),
715
+ content
716
+ ] });
717
+ }
718
+ return /* @__PURE__ */ jsx(Shell, { title: "Pages", description: "Explore all public pages and filter down to the content that needs attention.", children: content });
488
719
  }
489
- function SettingsPage() {
720
+ function SettingsPage({ embedded = false }) {
490
721
  const [draft, setDraft] = React.useState(EMPTY_CONFIG);
491
722
  const [storedConfig, setStoredConfig] = React.useState({
492
723
  siteOrigin: "",
@@ -640,70 +871,108 @@ function SettingsPage() {
640
871
  setBusy(null);
641
872
  }
642
873
  };
643
- return /* @__PURE__ */ jsxs(
874
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
875
+ /* @__PURE__ */ jsx(ErrorBanner, { message: error }),
876
+ /* @__PURE__ */ jsx(SuccessBanner, { message: success }),
877
+ /* @__PURE__ */ jsxs(Section, { title: "Google Connection", children: [
878
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [
879
+ /* @__PURE__ */ jsx(Field, { label: "Canonical Site Origin", hint: "Example: https://www.yourbright.co.jp", children: /* @__PURE__ */ jsx(Input, { value: draft.siteOrigin, onChange: (value) => setDraft((current) => ({ ...current, siteOrigin: value })) }) }),
880
+ /* @__PURE__ */ jsx(Field, { label: "GA4 Property ID", hint: "Enter the numeric property ID.", children: /* @__PURE__ */ jsx(Input, { value: draft.ga4PropertyId, onChange: (value) => setDraft((current) => ({ ...current, ga4PropertyId: value })) }) }),
881
+ /* @__PURE__ */ jsx("div", { className: "md:col-span-2", children: /* @__PURE__ */ jsx(Field, { label: "Search Console Property", hint: "Example: https://www.yourbright.co.jp/ or sc-domain:yourbright.co.jp", children: /* @__PURE__ */ jsx(Input, { value: draft.gscSiteUrl, onChange: (value) => setDraft((current) => ({ ...current, gscSiteUrl: value })) }) }) }),
882
+ /* @__PURE__ */ jsx("div", { className: "md:col-span-2", children: /* @__PURE__ */ jsx(
883
+ Field,
884
+ {
885
+ label: "Service Account JSON",
886
+ hint: hasStoredServiceAccount ? `Current: ${storedServiceAccountEmail || "configured"}. Leave blank to keep the current secret.` : "Required on the first save.",
887
+ children: /* @__PURE__ */ jsx(
888
+ TextArea,
889
+ {
890
+ value: draft.serviceAccountJson,
891
+ onChange: (value) => setDraft((current) => ({ ...current, serviceAccountJson: value })),
892
+ placeholder: '{"client_email":"...","private_key":"..."}',
893
+ rows: 12
894
+ }
895
+ )
896
+ }
897
+ ) })
898
+ ] }),
899
+ /* @__PURE__ */ jsxs("div", { className: "mt-6 flex flex-wrap gap-3 border-t border-border pt-4", children: [
900
+ /* @__PURE__ */ jsx(Button, { onClick: () => void save(), disabled: !!busy, children: busy === "save" ? "Saving..." : "Save Settings" }),
901
+ /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: () => void testConnection(), disabled: !!busy, children: busy === "test" ? "Testing..." : "Test Connection" }),
902
+ /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: () => void syncNow(), disabled: !!busy, children: busy === "sync" ? "Syncing..." : "Run Manual Sync" })
903
+ ] })
904
+ ] }),
905
+ /* @__PURE__ */ jsxs(Section, { title: "Agent API Keys", subtitle: "Use these with Authorization: AgentKey yb_ins_... or X-Emdash-Agent-Key. Raw keys are shown only once.", children: [
906
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]", children: [
907
+ /* @__PURE__ */ jsx(Field, { label: "New key label", children: /* @__PURE__ */ jsx(Input, { value: newKeyLabel, onChange: setNewKeyLabel, placeholder: "content-feedback-agent" }) }),
908
+ /* @__PURE__ */ jsx("div", { className: "flex items-end", children: /* @__PURE__ */ jsx(Button, { onClick: () => void createKey(), disabled: !!busy || !newKeyLabel.trim(), children: busy === "create-key" ? "Creating..." : "Create Key" }) })
909
+ ] }),
910
+ generatedKey ? /* @__PURE__ */ jsxs("div", { className: "mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4", children: [
911
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-medium text-amber-900", children: "Generated Key" }),
912
+ /* @__PURE__ */ jsx("div", { className: "mt-2 break-all font-mono text-sm text-amber-900", children: generatedKey })
913
+ ] }) : null,
914
+ /* @__PURE__ */ jsx("div", { className: "mt-4 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "min-w-full text-sm", children: [
915
+ /* @__PURE__ */ jsx("thead", { className: "text-left text-xs uppercase tracking-[0.16em] text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
916
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Prefix" }),
917
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Label" }),
918
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Created" }),
919
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Last Used" }),
920
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Status" }),
921
+ /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4" })
922
+ ] }) }),
923
+ /* @__PURE__ */ jsx("tbody", { children: keys.map((key) => /* @__PURE__ */ jsxs("tr", { className: "border-t border-border/80", children: [
924
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4 font-mono text-xs", children: key.prefix }),
925
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: key.label }),
926
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatDateTime(key.createdAt) }),
927
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatDateTime(key.lastUsedAt) }),
928
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: key.revokedAt ? "Revoked" : "Active" }),
929
+ /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: key.revokedAt ? null : /* @__PURE__ */ jsx(Button, { variant: "danger", onClick: () => void revokeKey(key.prefix), disabled: busy === key.prefix, children: "Revoke" }) })
930
+ ] }, key.prefix)) })
931
+ ] }) })
932
+ ] })
933
+ ] });
934
+ if (embedded) {
935
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
936
+ /* @__PURE__ */ jsx(
937
+ InlineHeader,
938
+ {
939
+ title: "Settings",
940
+ description: "Manage Google connection settings, manual sync, and agent API keys."
941
+ }
942
+ ),
943
+ content
944
+ ] });
945
+ }
946
+ return /* @__PURE__ */ jsx(
644
947
  Shell,
645
948
  {
646
949
  title: "Analytics",
647
950
  description: "Manage Google connection settings, manual sync, and agent API keys.",
951
+ children: content
952
+ }
953
+ );
954
+ }
955
+ function AnalyticsPage() {
956
+ const [section, setSection] = React.useState(sectionFromLocation);
957
+ React.useEffect(() => {
958
+ const handlePopstate = () => setSection(sectionFromLocation());
959
+ window.addEventListener("popstate", handlePopstate);
960
+ return () => window.removeEventListener("popstate", handlePopstate);
961
+ }, []);
962
+ const selectSection = (nextSection) => {
963
+ setSection(nextSection);
964
+ updateSectionInUrl(nextSection);
965
+ };
966
+ return /* @__PURE__ */ jsxs(
967
+ Shell,
968
+ {
969
+ title: "Analytics",
970
+ description: "Use one workspace for dashboard review, page drilldown, and connection management.",
971
+ actions: /* @__PURE__ */ jsx(AnalyticsTabs, { section, onChange: selectSection }),
648
972
  children: [
649
- /* @__PURE__ */ jsx(ErrorBanner, { message: error }),
650
- /* @__PURE__ */ jsx(SuccessBanner, { message: success }),
651
- /* @__PURE__ */ jsxs(Section, { title: "Google Connection", children: [
652
- /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [
653
- /* @__PURE__ */ jsx(Field, { label: "Canonical Site Origin", hint: "Example: https://www.yourbright.co.jp", children: /* @__PURE__ */ jsx(Input, { value: draft.siteOrigin, onChange: (value) => setDraft((current) => ({ ...current, siteOrigin: value })) }) }),
654
- /* @__PURE__ */ jsx(Field, { label: "GA4 Property ID", hint: "Enter the numeric property ID.", children: /* @__PURE__ */ jsx(Input, { value: draft.ga4PropertyId, onChange: (value) => setDraft((current) => ({ ...current, ga4PropertyId: value })) }) }),
655
- /* @__PURE__ */ jsx("div", { className: "md:col-span-2", children: /* @__PURE__ */ jsx(Field, { label: "Search Console Property", hint: "Example: https://www.yourbright.co.jp/ or sc-domain:yourbright.co.jp", children: /* @__PURE__ */ jsx(Input, { value: draft.gscSiteUrl, onChange: (value) => setDraft((current) => ({ ...current, gscSiteUrl: value })) }) }) }),
656
- /* @__PURE__ */ jsx("div", { className: "md:col-span-2", children: /* @__PURE__ */ jsx(
657
- Field,
658
- {
659
- label: "Service Account JSON",
660
- hint: hasStoredServiceAccount ? `Current: ${storedServiceAccountEmail || "configured"}. Leave blank to keep the current secret.` : "Required on the first save.",
661
- children: /* @__PURE__ */ jsx(
662
- TextArea,
663
- {
664
- value: draft.serviceAccountJson,
665
- onChange: (value) => setDraft((current) => ({ ...current, serviceAccountJson: value })),
666
- placeholder: '{"client_email":"...","private_key":"..."}',
667
- rows: 12
668
- }
669
- )
670
- }
671
- ) })
672
- ] }),
673
- /* @__PURE__ */ jsxs("div", { className: "mt-6 flex flex-wrap gap-3 border-t border-border pt-4", children: [
674
- /* @__PURE__ */ jsx(Button, { onClick: () => void save(), disabled: !!busy, children: busy === "save" ? "Saving..." : "Save Settings" }),
675
- /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: () => void testConnection(), disabled: !!busy, children: busy === "test" ? "Testing..." : "Test Connection" }),
676
- /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: () => void syncNow(), disabled: !!busy, children: busy === "sync" ? "Syncing..." : "Run Manual Sync" })
677
- ] })
678
- ] }),
679
- /* @__PURE__ */ jsxs(Section, { title: "Agent API Keys", subtitle: "Use these with Authorization: AgentKey yb_ins_... or X-Emdash-Agent-Key. Raw keys are shown only once.", children: [
680
- /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]", children: [
681
- /* @__PURE__ */ jsx(Field, { label: "New key label", children: /* @__PURE__ */ jsx(Input, { value: newKeyLabel, onChange: setNewKeyLabel, placeholder: "content-feedback-agent" }) }),
682
- /* @__PURE__ */ jsx("div", { className: "flex items-end", children: /* @__PURE__ */ jsx(Button, { onClick: () => void createKey(), disabled: !!busy || !newKeyLabel.trim(), children: busy === "create-key" ? "Creating..." : "Create Key" }) })
683
- ] }),
684
- generatedKey ? /* @__PURE__ */ jsxs("div", { className: "mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4", children: [
685
- /* @__PURE__ */ jsx("div", { className: "text-sm font-medium text-amber-900", children: "Generated Key" }),
686
- /* @__PURE__ */ jsx("div", { className: "mt-2 break-all font-mono text-sm text-amber-900", children: generatedKey })
687
- ] }) : null,
688
- /* @__PURE__ */ jsx("div", { className: "mt-4 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "min-w-full text-sm", children: [
689
- /* @__PURE__ */ jsx("thead", { className: "text-left text-xs uppercase tracking-[0.16em] text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
690
- /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Prefix" }),
691
- /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Label" }),
692
- /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Created" }),
693
- /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Last Used" }),
694
- /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4", children: "Status" }),
695
- /* @__PURE__ */ jsx("th", { className: "pb-3 pr-4" })
696
- ] }) }),
697
- /* @__PURE__ */ jsx("tbody", { children: keys.map((key) => /* @__PURE__ */ jsxs("tr", { className: "border-t border-border/80", children: [
698
- /* @__PURE__ */ jsx("td", { className: "py-3 pr-4 font-mono text-xs", children: key.prefix }),
699
- /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: key.label }),
700
- /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatDateTime(key.createdAt) }),
701
- /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: formatDateTime(key.lastUsedAt) }),
702
- /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: key.revokedAt ? "Revoked" : "Active" }),
703
- /* @__PURE__ */ jsx("td", { className: "py-3 pr-4", children: key.revokedAt ? null : /* @__PURE__ */ jsx(Button, { variant: "danger", onClick: () => void revokeKey(key.prefix), disabled: busy === key.prefix, children: "Revoke" }) })
704
- ] }, key.prefix)) })
705
- ] }) })
706
- ] })
973
+ section === "dashboard" ? /* @__PURE__ */ jsx(OverviewPage, { embedded: true }) : null,
974
+ section === "pages" ? /* @__PURE__ */ jsx(PagesPage, { embedded: true }) : null,
975
+ section === "settings" ? /* @__PURE__ */ jsx(SettingsPage, { embedded: true }) : null
707
976
  ]
708
977
  }
709
978
  );
@@ -789,6 +1058,11 @@ function WindowCard({
789
1058
  function formatInteger(value) {
790
1059
  return new Intl.NumberFormat("ja-JP").format(value ?? 0);
791
1060
  }
1061
+ function formatSignedInteger(value) {
1062
+ const numeric = value ?? 0;
1063
+ if (numeric === 0) return "0";
1064
+ return `${numeric > 0 ? "+" : ""}${formatInteger(numeric)}`;
1065
+ }
792
1066
  function formatPercent(value) {
793
1067
  return `${((value ?? 0) * 100).toFixed(1)}%`;
794
1068
  }
@@ -847,7 +1121,7 @@ function numberValue(value) {
847
1121
  return typeof value === "number" ? value : 0;
848
1122
  }
849
1123
  var pages = {
850
- "/": OverviewPage,
1124
+ "/": AnalyticsPage,
851
1125
  "/pages": PagesPage,
852
1126
  "/settings": SettingsPage
853
1127
  };
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({
@@ -1366,9 +1513,7 @@ function contentInsightsPlugin() {
1366
1513
  "www.googleapis.com"
1367
1514
  ],
1368
1515
  adminPages: [
1369
- { path: "/", label: "Overview", icon: "chart-bar" },
1370
- { path: "/pages", label: "Pages", icon: "list" },
1371
- { path: "/settings", label: "Analytics", icon: "gear" }
1516
+ { path: "/", label: "Analytics", icon: "chart-bar" }
1372
1517
  ],
1373
1518
  adminWidgets: [
1374
1519
  { id: "content-opportunities", title: "Content Opportunities", size: "full" }
@@ -1473,14 +1618,26 @@ function createPlugin() {
1473
1618
  if (!resolved.success) {
1474
1619
  throw new PluginRouteError2("BAD_REQUEST", resolved.message, 400);
1475
1620
  }
1476
- return testConnection(ctx, resolved.data);
1621
+ try {
1622
+ return testConnection(ctx, resolved.data);
1623
+ } catch (error) {
1624
+ const message = error instanceof Error ? error.message : "Connection test failed";
1625
+ console.error("[analytics-plugin] connection test failed", error);
1626
+ throw new PluginRouteError2("INTERNAL_ERROR", message, 500);
1627
+ }
1477
1628
  }
1478
1629
  },
1479
1630
  [ADMIN_ROUTES.SYNC_NOW]: {
1480
1631
  handler: async (ctx) => {
1481
- const base = await syncBase(ctx, "manual");
1482
- const enriched = await enrichManagedQueries(ctx);
1483
- return { ...base, ...enriched };
1632
+ try {
1633
+ const base = await syncBase(ctx, "manual");
1634
+ const enriched = await enrichManagedQueries(ctx);
1635
+ return { ...base, ...enriched };
1636
+ } catch (error) {
1637
+ const message = error instanceof Error ? error.message : "Manual sync failed";
1638
+ console.error("[analytics-plugin] manual sync failed", error);
1639
+ throw new PluginRouteError2("INTERNAL_ERROR", message, 500);
1640
+ }
1484
1641
  }
1485
1642
  },
1486
1643
  [ADMIN_ROUTES.AGENT_KEYS_LIST]: {
@@ -1537,9 +1694,7 @@ function createPlugin() {
1537
1694
  },
1538
1695
  admin: {
1539
1696
  pages: [
1540
- { path: "/", label: "Overview", icon: "chart-bar" },
1541
- { path: "/pages", label: "Pages", icon: "list" },
1542
- { path: "/settings", label: "Analytics", icon: "gear" }
1697
+ { path: "/", label: "Analytics", icon: "chart-bar" }
1543
1698
  ],
1544
1699
  widgets: [
1545
1700
  { id: "content-opportunities", title: "Content Opportunities", size: "full" }
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.4",
4
4
  "description": "Google Search Console and GA4 analytics plugin for EmDash",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",