@agent-native/core-corpus 0.209.2 → 0.209.3-nightly-20261016183441

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 (62) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  3. package/corpus/templates/analytics/actions/update-dashboard.ts +9 -0
  4. package/corpus/templates/analytics/app/components/auth/SqlQuerySnapshotSessionBridge.tsx +25 -0
  5. package/corpus/templates/analytics/app/components/dashboard/PanelFreshnessClock.tsx +140 -0
  6. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +104 -48
  7. package/corpus/templates/analytics/app/components/dashboard/panel-freshness.ts +171 -0
  8. package/corpus/templates/analytics/app/global.css +59 -0
  9. package/corpus/templates/analytics/app/i18n/en-US.ts +8 -0
  10. package/corpus/templates/analytics/app/lib/cost-tracker.ts +122 -16
  11. package/corpus/templates/analytics/app/lib/query-metrics.ts +50 -13
  12. package/corpus/templates/analytics/app/lib/sql-query-cache.ts +697 -0
  13. package/corpus/templates/analytics/app/lib/sql-query-freshness.ts +85 -0
  14. package/corpus/templates/analytics/app/lib/sql-query-snapshot-session.ts +145 -0
  15. package/corpus/templates/analytics/app/lib/sql-query.ts +366 -36
  16. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +73 -18
  17. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/filter-vars.ts +45 -12
  18. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +5 -0
  19. package/corpus/templates/analytics/app/root.tsx +2 -0
  20. package/corpus/templates/analytics/changelog/2026-10-14-dashboard-charts-now-reappear-instantly-after-a-reload-from-.md +6 -0
  21. package/corpus/templates/analytics/changelog/2026-10-14-dashboard-panels-show-fetch-time-in-a-clock-and-label-only-st.md +6 -0
  22. package/corpus/templates/analytics/changelog/2026-10-15-dashboard-query-costs-and-refresh-confirmations-reflect-actu.md +6 -0
  23. package/corpus/templates/analytics/changelog/2026-10-15-dashboard-results-disappear-when-access-is-withdrawn-and-exp.md +6 -0
  24. package/corpus/templates/analytics/changelog/2026-10-16-dashboard-freshness-stays-in-the-title-row-with-compact-upda.md +6 -0
  25. package/corpus/templates/analytics/changelog/2026-10-16-dashboard-update-status-remains-visible-for-sources-without-.md +6 -0
  26. package/corpus/templates/analytics/server/lib/bigquery.ts +35 -4
  27. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +2 -3
  28. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +9 -6
  29. package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +2 -2
  30. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +4 -2
  31. package/corpus/templates/analytics/shared/sql-query-contract.ts +49 -0
  32. package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  33. package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  34. package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  35. package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  36. package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  37. package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  38. package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  39. package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  40. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +24 -0
  41. package/corpus/templates/design/app/components/design/types.ts +1 -0
  42. package/corpus/templates/design/app/lib/design-import.ts +35 -0
  43. package/corpus/templates/design/app/pages/design-editor/clone-and-pen-edit.ts +11 -0
  44. package/corpus/templates/design/app/pages/design-editor/command-types.ts +2 -0
  45. package/corpus/templates/design/app/pages/design-editor/commands/copy-selection.ts +3 -0
  46. package/corpus/templates/design/app/pages/design-editor/commands/get-selected-layer-snapshots.ts +19 -0
  47. package/corpus/templates/design/app/pages/design-editor/commands/paste-selection.ts +118 -20
  48. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-layer-actions.ts +15 -3
  49. package/corpus/templates/design/app/pages/design-editor/relative-position-offset.ts +109 -0
  50. package/corpus/templates/design/changelog/2026-10-15-dragged-layers-use-the-final-release-position.md +6 -0
  51. package/corpus/templates/design/changelog/2026-10-15-repeated-pastes-preserve-relative-layer-offsets.md +6 -0
  52. package/corpus/templates/design/changelog/2026-10-15-text-wrapping-hug-widths.md +6 -0
  53. package/corpus/templates/design/shared/code-layer.ts +2 -1
  54. package/corpus/templates/design/shared/relative-position-containing-block.ts +40 -0
  55. package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  56. package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  57. package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  58. package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  59. package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
  60. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +137 -118
  61. package/corpus/templates/slides/changelog/2026-10-16-search-and-preview-reference-decks-before-choosing-one.md +6 -0
  62. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -32,4 +32,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core-corpus/corpu
32
32
 
33
33
  ## Generated Counts
34
34
 
35
- - template files: 12525
35
+ - template files: 12592
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @agent-native/core deploy build
2
+ import { markDefaultPluginProvided } from "@agent-native/core/server/edge";
3
+
4
+ const pluginStems = ["agent-chat","analytics-alert-jobs","analytics-rollup-backfill-jobs","auth","collab","config","core-routes","creative-context","dashboard-report-jobs","db","feature-flags","labs","onboarding","otel","session-replay-retention-jobs","transactional-emails","uptime-monitor-jobs"];
5
+
6
+ export default function markBuildDiscoveredPlugins(nitroApp) {
7
+ for (const stem of pluginStems) {
8
+ markDefaultPluginProvided(nitroApp, stem);
9
+ }
10
+ }
@@ -373,6 +373,15 @@ function collectDashboardConfigIssues(
373
373
  }
374
374
  }
375
375
  }
376
+ // isDashboardFilter rejects a non-boolean customRange, so a save that passes here would drop the whole filter set on read.
377
+ if (
378
+ f.customRange !== undefined &&
379
+ (typeof f.customRange !== "boolean" || f.type !== "select")
380
+ ) {
381
+ return dashboardIssue(
382
+ `config.filters[${i}].customRange must be a boolean, and only select filters take it`,
383
+ );
384
+ }
376
385
  if (f.presets !== undefined) {
377
386
  // isDashboardFilter rejects a malformed preset, so a save that passes here would drop the whole filter set on read.
378
387
  if (f.type !== "multi-select" || !Array.isArray(f.presets)) {
@@ -0,0 +1,25 @@
1
+ import { useSession } from "@agent-native/core/client/hooks";
2
+ import { useEffect } from "react";
3
+
4
+ import { dashboardCacheScope } from "@/lib/prefetch-keys";
5
+ import { syncSqlQuerySnapshotsToSession } from "@/lib/sql-query-snapshot-session";
6
+
7
+ /**
8
+ * Keeps saved SQL results tied to the signed-in account. Mount it outside
9
+ * `RequireSession`: that gate unmounts everything under it as soon as a
10
+ * sign-out begins, so a cleanup effect inside it never sees the new status.
11
+ * Unmounting this component clears nothing, which is what lets a reload or an
12
+ * ordinary navigation keep the results it is about to restore.
13
+ */
14
+ export function SqlQuerySnapshotSessionBridge() {
15
+ const { session, status } = useSession();
16
+ const scope = session ? dashboardCacheScope(session) : null;
17
+
18
+ useEffect(() => {
19
+ syncSqlQuerySnapshotsToSession({ status, session });
20
+ // `scope` stands for the account: the session object changes identity on
21
+ // every re-read without changing who is signed in.
22
+ }, [status, scope]);
23
+
24
+ return null;
25
+ }
@@ -0,0 +1,140 @@
1
+ import { useFormatters, useT } from "@agent-native/core/client/i18n";
2
+ import { IconCheck, IconClock } from "@tabler/icons-react";
3
+ import { useEffect, useState } from "react";
4
+
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipTrigger,
9
+ } from "@/components/ui/tooltip";
10
+ import { cn } from "@/lib/utils";
11
+
12
+ import {
13
+ describeFreshnessText,
14
+ relativeFetchAge,
15
+ type PanelFreshness,
16
+ } from "./panel-freshness";
17
+
18
+ /**
19
+ * When the panel's data was fetched and whether it came from a cache, behind a
20
+ * clock in the panel's hover actions. The tooltip opens on hover and focus; a
21
+ * tap opens it too, because a tooltip alone never opens on touch.
22
+ */
23
+ export function PanelFreshnessClock({
24
+ freshness,
25
+ className,
26
+ showAge = false,
27
+ }: {
28
+ freshness: PanelFreshness;
29
+ className?: string;
30
+ showAge?: boolean;
31
+ }) {
32
+ const t = useT();
33
+ const formatters = useFormatters();
34
+ const [open, setOpen] = useState(false);
35
+ const [now, setNow] = useState(Date.now);
36
+ useEffect(() => {
37
+ if (!showAge || !freshness.fetchedAt) return;
38
+ const timer = setTimeout(
39
+ () => setNow(Date.now()),
40
+ 60_000 - (Date.now() % 60_000),
41
+ );
42
+ return () => clearTimeout(timer);
43
+ }, [showAge, freshness.fetchedAt, now]);
44
+ const status = freshness.status;
45
+ const updating =
46
+ status === "updating" || status === "previous-filters-updating";
47
+ const statusText =
48
+ status === "previous-filters-updating"
49
+ ? t("sqlDashboard.updatingPreviousFilters")
50
+ : status === "updating"
51
+ ? t("sqlDashboard.updating")
52
+ : status === "previous-filters"
53
+ ? t("sqlDashboard.previousFilters")
54
+ : status === "refreshed"
55
+ ? t("sqlDashboard.refreshed")
56
+ : undefined;
57
+ const label = [
58
+ statusText,
59
+ describeFreshnessText(t, formatters, freshness, "full"),
60
+ ]
61
+ .filter(Boolean)
62
+ .join(" · ");
63
+ const age =
64
+ showAge && freshness.fetchedAt
65
+ ? relativeFetchAge(
66
+ formatters,
67
+ freshness.fetchedAt,
68
+ Math.max(now, Date.now()),
69
+ )
70
+ : undefined;
71
+ const visibleText =
72
+ statusText ??
73
+ (age
74
+ ? t("sqlDashboard.updatedAgo", { age })
75
+ : status === "stale"
76
+ ? describeFreshnessText(t, formatters, freshness, "compact")
77
+ : undefined);
78
+ return (
79
+ <Tooltip open={open} onOpenChange={setOpen}>
80
+ <TooltipTrigger asChild>
81
+ <button
82
+ type="button"
83
+ className={cn(
84
+ "panel-freshness-clock inline-flex min-w-0 max-w-full items-center justify-end gap-1.5 whitespace-nowrap rounded text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
85
+ !status && "panel-freshness-routine",
86
+ className,
87
+ )}
88
+ aria-label={label}
89
+ data-dashboard-report-freshness={freshness.cache}
90
+ data-dashboard-report-updating={
91
+ updating
92
+ ? status === "previous-filters-updating"
93
+ ? "previous-filters"
94
+ : "refresh"
95
+ : undefined
96
+ }
97
+ data-dashboard-report-paused={
98
+ status === "previous-filters" ? "previous-filters" : undefined
99
+ }
100
+ data-dashboard-report-refreshed={
101
+ status === "refreshed" ? "" : undefined
102
+ }
103
+ data-dashboard-report-stale={
104
+ status === "stale" ? freshness.cache : undefined
105
+ }
106
+ onClick={(event) => {
107
+ event.preventDefault();
108
+ setOpen(true);
109
+ }}
110
+ >
111
+ <span
112
+ className="panel-freshness-text min-w-0 truncate"
113
+ aria-hidden="true"
114
+ >
115
+ {status === "stale" && !showAge ? (
116
+ <time dateTime={freshness.fetchedAt}>{visibleText}</time>
117
+ ) : (
118
+ visibleText
119
+ )}
120
+ </span>
121
+ <span className="sr-only" role="status">
122
+ {statusText}
123
+ </span>
124
+ {status === "refreshed" ? (
125
+ <IconCheck aria-hidden="true" className="size-3.5 shrink-0" />
126
+ ) : (
127
+ <IconClock
128
+ aria-hidden="true"
129
+ className={cn(
130
+ "size-3.5 shrink-0",
131
+ updating && "animate-spin motion-reduce:animate-none",
132
+ )}
133
+ />
134
+ )}
135
+ </button>
136
+ </TooltipTrigger>
137
+ <TooltipContent>{label}</TooltipContent>
138
+ </Tooltip>
139
+ );
140
+ }
@@ -114,7 +114,6 @@ import {
114
114
  SelectTrigger,
115
115
  SelectValue,
116
116
  } from "@/components/ui/select";
117
- import { Spinner } from "@/components/ui/spinner";
118
117
  import {
119
118
  Tooltip as HoverTooltip,
120
119
  TooltipContent as HoverTooltipContent,
@@ -157,6 +156,12 @@ import {
157
156
  useEventMarkerAxisPadding,
158
157
  useEventMarkerGroups,
159
158
  } from "./event-markers";
159
+ import {
160
+ useIsStaleResult,
161
+ useRefreshConfirmation,
162
+ type PanelFreshness,
163
+ } from "./panel-freshness";
164
+ import { PanelFreshnessClock } from "./PanelFreshnessClock";
160
165
 
161
166
  export { limitChartRows };
162
167
 
@@ -1266,6 +1271,11 @@ interface SqlChartProps {
1266
1271
  refreshToken?: number;
1267
1272
  onRefreshRequested?: () => void;
1268
1273
  onExportCsvChange?: (handler: (() => void) | null) => void;
1274
+ /**
1275
+ * The freshness of the result on screen, for a host that shows it outside
1276
+ * the chart; null when no result is shown, and again when the chart unmounts.
1277
+ */
1278
+ onFreshnessChange?: (freshness: PanelFreshness | null) => void;
1269
1279
  onCopyTableChange?: (handler: (() => Promise<void>) | null) => void;
1270
1280
  /** Passing this makes a table's columns editable; omit it for read-only tables. */
1271
1281
  onSaveColumns?: (columns: TableColumnConfig[]) => void;
@@ -1292,6 +1302,7 @@ export function SqlChart({
1292
1302
  refreshToken = 0,
1293
1303
  onRefreshRequested,
1294
1304
  onExportCsvChange,
1305
+ onFreshnessChange,
1295
1306
  onCopyTableChange,
1296
1307
  onSaveColumns,
1297
1308
  columnWidths,
@@ -1333,6 +1344,8 @@ export function SqlChart({
1333
1344
  isFetching: queryIsFetching,
1334
1345
  isPlaceholderData: queryIsPlaceholderData,
1335
1346
  error: queryError,
1347
+ freshness: queryFreshness,
1348
+ completedManualRefreshToken,
1336
1349
  } = useSqlQuery(
1337
1350
  ["sql-chart", dashboardId || panel.id, sql, panel.source],
1338
1351
  sql,
@@ -1344,6 +1357,7 @@ export function SqlChart({
1344
1357
  retainPreviousScope,
1345
1358
  retainWhilePaused: retainWhileOffscreen,
1346
1359
  reuseCompletedOnEnable: retainWhileOffscreen,
1360
+ snapshots: !isSection && !isExtension && !resultOverride,
1347
1361
  },
1348
1362
  );
1349
1363
 
@@ -1409,6 +1423,59 @@ export function SqlChart({
1409
1423
  [chartType, rows, xKey],
1410
1424
  );
1411
1425
 
1426
+ const showsRetainedResult = retainWhileOffscreen && queryResult !== undefined;
1427
+ const showsSkeleton =
1428
+ isLoading ||
1429
+ isFetching ||
1430
+ (!loadData && showLoadingWhenDisabled && !showsRetainedResult);
1431
+ // A result handed in by the caller was not fetched by this panel's query.
1432
+ const shownFreshness = resultOverride ? undefined : queryFreshness;
1433
+ const isStale = useIsStaleResult(shownFreshness?.fetchedAt);
1434
+ const refreshConfirmed = useRefreshConfirmation({
1435
+ requestToken: refreshToken + localRefreshToken,
1436
+ scopeKey: JSON.stringify([
1437
+ dashboardId ?? null,
1438
+ panel.id,
1439
+ panel.source,
1440
+ sql,
1441
+ ]),
1442
+ completedToken: completedManualRefreshToken,
1443
+ });
1444
+ const canReportFreshness =
1445
+ !isSection && !isExtension && !resultOverride && !error && !showsSkeleton;
1446
+ const reportedCache = shownFreshness?.cache;
1447
+ const reportedFetchedAt = shownFreshness?.fetchedAt;
1448
+ const reportedStatus: PanelFreshness["status"] = isUpdating
1449
+ ? isShowingPreviousFilters
1450
+ ? "previous-filters-updating"
1451
+ : "updating"
1452
+ : isPausedOnPreviousFilters
1453
+ ? "previous-filters"
1454
+ : refreshConfirmed
1455
+ ? "refreshed"
1456
+ : isStale
1457
+ ? "stale"
1458
+ : undefined;
1459
+ useEffect(() => {
1460
+ if (!onFreshnessChange) return;
1461
+ onFreshnessChange(
1462
+ canReportFreshness && (reportedCache || reportedStatus)
1463
+ ? {
1464
+ fetchedAt: reportedFetchedAt,
1465
+ cache: reportedCache ?? "unknown",
1466
+ ...(reportedStatus ? { status: reportedStatus } : {}),
1467
+ }
1468
+ : null,
1469
+ );
1470
+ return () => onFreshnessChange(null);
1471
+ }, [
1472
+ onFreshnessChange,
1473
+ canReportFreshness,
1474
+ reportedCache,
1475
+ reportedFetchedAt,
1476
+ reportedStatus,
1477
+ ]);
1478
+
1412
1479
  if (isSection) {
1413
1480
  return (
1414
1481
  <div className="px-1 py-2">
@@ -1462,12 +1529,7 @@ export function SqlChart({
1462
1529
  : "min-h-[250px]";
1463
1530
  const placeholderPadY = isMetric ? "py-2" : "py-8";
1464
1531
 
1465
- const showsRetainedResult = retainWhileOffscreen && queryResult !== undefined;
1466
- if (
1467
- isLoading ||
1468
- isFetching ||
1469
- (!loadData && showLoadingWhenDisabled && !showsRetainedResult)
1470
- ) {
1532
+ if (showsSkeleton) {
1471
1533
  return <SqlChartLoadingSkeleton panel={panel} />;
1472
1534
  }
1473
1535
 
@@ -1496,33 +1558,6 @@ export function SqlChart({
1496
1558
  }
1497
1559
 
1498
1560
  const missingConfigKeys = plan.missingKeys;
1499
- const updatingNotice = isUpdating ? (
1500
- <div className="pointer-events-none relative h-0">
1501
- <span
1502
- role="status"
1503
- data-dashboard-report-updating={
1504
- isShowingPreviousFilters ? "previous-filters" : "refresh"
1505
- }
1506
- className="absolute right-0 top-0 z-10 inline-flex items-center gap-1.5 rounded-full border bg-background/90 px-2 py-0.5 text-xs text-muted-foreground shadow-sm"
1507
- >
1508
- <Spinner aria-hidden="true" className="size-3" />
1509
- {t(
1510
- isShowingPreviousFilters
1511
- ? "sqlDashboard.updatingPreviousFilters"
1512
- : "sqlDashboard.updating",
1513
- )}
1514
- </span>
1515
- </div>
1516
- ) : isPausedOnPreviousFilters ? (
1517
- <div className="pointer-events-none relative h-0">
1518
- <span
1519
- data-dashboard-report-paused="previous-filters"
1520
- className="absolute right-0 top-0 z-10 inline-flex items-center rounded-full border bg-background/90 px-2 py-0.5 text-xs text-muted-foreground shadow-sm"
1521
- >
1522
- {t("sqlDashboard.previousFilters")}
1523
- </span>
1524
- </div>
1525
- ) : null;
1526
1561
  const withConfigBanners = (node: ReactNode) => {
1527
1562
  if (refreshError) {
1528
1563
  return (
@@ -1567,12 +1602,38 @@ export function SqlChart({
1567
1602
  );
1568
1603
  };
1569
1604
 
1570
- const withConfigWarning = (node: ReactNode) => (
1571
- <>
1572
- {updatingNotice}
1573
- {withConfigBanners(node)}
1574
- </>
1605
+ const showStandaloneStatus =
1606
+ !onFreshnessChange &&
1607
+ (shownFreshness !== undefined || reportedStatus !== undefined);
1608
+ const withNotice = (node: ReactNode) => (
1609
+ <div
1610
+ className={
1611
+ showStandaloneStatus
1612
+ ? "grid h-full min-h-0 grid-cols-[minmax(0,1fr)_1.5rem] items-start gap-x-1"
1613
+ : "flex h-full min-h-0 flex-col"
1614
+ }
1615
+ >
1616
+ <div data-dashboard-report-chart-body className="h-full min-h-0 min-w-0">
1617
+ {node}
1618
+ </div>
1619
+ {showStandaloneStatus ? (
1620
+ <div data-dashboard-report-standalone-status className="min-w-0">
1621
+ <HoverTooltipProvider>
1622
+ <PanelFreshnessClock
1623
+ freshness={{
1624
+ cache: "unknown",
1625
+ ...shownFreshness,
1626
+ status: reportedStatus,
1627
+ }}
1628
+ className="h-6 w-6 p-1"
1629
+ />
1630
+ </HoverTooltipProvider>
1631
+ </div>
1632
+ ) : null}
1633
+ </div>
1575
1634
  );
1635
+ const withConfigWarning = (node: ReactNode) =>
1636
+ withNotice(withConfigBanners(node));
1576
1637
 
1577
1638
  const pointLimit = plan.pointLimit;
1578
1639
  const withPointLimit = (node: ReactNode) =>
@@ -1588,7 +1649,7 @@ export function SqlChart({
1588
1649
  if (plan.empty) {
1589
1650
  const noData = (
1590
1651
  <div
1591
- className={`flex flex-1 items-center justify-center ${placeholderPadY} ${placeholderMinH}`}
1652
+ className={`flex h-full flex-1 items-center justify-center ${placeholderPadY} ${placeholderMinH}`}
1592
1653
  >
1593
1654
  <p className="text-sm text-muted-foreground text-center">
1594
1655
  {t("common.noData")}
@@ -1598,14 +1659,9 @@ export function SqlChart({
1598
1659
  // A funnel or heatmap with rows that draws nothing keeps the warning and
1599
1660
  // refresh banner that say why; a result with no rows to plot is a bare
1600
1661
  // "No data".
1601
- return plan.rows.length === 0 ? (
1602
- <>
1603
- {updatingNotice}
1604
- {noData}
1605
- </>
1606
- ) : (
1607
- withConfigWarning(noData)
1608
- );
1662
+ return plan.rows.length === 0
1663
+ ? withNotice(noData)
1664
+ : withConfigWarning(noData);
1609
1665
  }
1610
1666
 
1611
1667
  if (chartType === "metric") {
@@ -0,0 +1,171 @@
1
+ import type { useFormatters, useT } from "@agent-native/core/client/i18n";
2
+ import { useEffect, useRef, useState } from "react";
3
+
4
+ import {
5
+ isStaleFetchTime,
6
+ msUntilStale,
7
+ type SqlQueryFreshness,
8
+ } from "@/lib/sql-query-freshness";
9
+
10
+ /** How long a panel's own successful refresh says so before the text goes. */
11
+ export const REFRESH_CONFIRMATION_MS = 3000;
12
+
13
+ // setTimeout stores its delay in 32 bits; a longer one fires immediately.
14
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1;
15
+
16
+ type Translate = ReturnType<typeof useT>;
17
+ type Formatters = Pick<ReturnType<typeof useFormatters>, "formatDate">;
18
+
19
+ export type PanelFreshness = SqlQueryFreshness & {
20
+ status?:
21
+ | "updating"
22
+ | "previous-filters-updating"
23
+ | "previous-filters"
24
+ | "refreshed"
25
+ | "stale";
26
+ };
27
+
28
+ export function relativeFetchAge(
29
+ formatters: Pick<ReturnType<typeof useFormatters>, "formatRelativeTime">,
30
+ fetchedAt: string,
31
+ now: number,
32
+ ): string | undefined {
33
+ const age = now - Date.parse(fetchedAt);
34
+ if (!Number.isFinite(age) || age < 0) return undefined;
35
+ const minutes = Math.floor(age / 60_000);
36
+ const [value, unit] =
37
+ minutes < 60
38
+ ? [minutes, "minute" as const]
39
+ : minutes < 1440
40
+ ? [Math.floor(minutes / 60), "hour" as const]
41
+ : [Math.floor(minutes / 1440), "day" as const];
42
+ return formatters.formatRelativeTime(-value, unit, {
43
+ style: "narrow",
44
+ numeric: "always",
45
+ });
46
+ }
47
+
48
+ /** The time alone for today's fetch, with the date once it is older. */
49
+ function fetchedTimeFormat(fetchedAt: string): Intl.DateTimeFormatOptions {
50
+ const fetched = new Date(fetchedAt);
51
+ const now = new Date();
52
+ const today =
53
+ fetched.getFullYear() === now.getFullYear() &&
54
+ fetched.getMonth() === now.getMonth() &&
55
+ fetched.getDate() === now.getDate();
56
+ return today
57
+ ? { hour: "numeric", minute: "2-digit" }
58
+ : { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" };
59
+ }
60
+
61
+ /**
62
+ * "Fetched 5:20 PM · Cached", or the full timestamp for the clock's tooltip.
63
+ * A missing fetch time or cache state says so instead of being left out.
64
+ */
65
+ export function describeFreshnessText(
66
+ t: Translate,
67
+ formatters: Formatters,
68
+ { fetchedAt, cache }: SqlQueryFreshness,
69
+ detail: "compact" | "full",
70
+ ): string {
71
+ const parts = [
72
+ fetchedAt
73
+ ? t("sqlDashboard.fetchedAt", {
74
+ time: formatters.formatDate(
75
+ fetchedAt,
76
+ detail === "full"
77
+ ? { dateStyle: "long", timeStyle: "medium" }
78
+ : fetchedTimeFormat(fetchedAt),
79
+ ),
80
+ })
81
+ : t("sqlDashboard.fetchTimeUnknown"),
82
+ ];
83
+ if (cache === "app-cache") parts.push(t("sqlDashboard.cacheApp"));
84
+ else if (cache === "provider-cache")
85
+ parts.push(t("sqlDashboard.cacheProvider"));
86
+ else if (cache === "restored") parts.push(t("sqlDashboard.cacheRestored"));
87
+ else if (cache === "unknown") parts.push(t("sqlDashboard.cacheUnknown"));
88
+ return parts.join(" · ");
89
+ }
90
+
91
+ /**
92
+ * Whether the shown result is older than the cutoff, read from the clock on
93
+ * every render. A result that is not yet that old schedules one timeout for
94
+ * the moment it will be, so an idle panel still turns stale without polling,
95
+ * a shared timer, or a request; the timeout re-renders only this panel, once.
96
+ */
97
+ export function useIsStaleResult(fetchedAt: string | undefined): boolean {
98
+ const [tick, setTick] = useState(0);
99
+ const stale = isStaleFetchTime(fetchedAt, Date.now());
100
+ useEffect(() => {
101
+ if (stale) return;
102
+ const delay = msUntilStale(fetchedAt, Date.now());
103
+ if (delay === undefined) return;
104
+ const timer = setTimeout(
105
+ () => setTick((count) => count + 1),
106
+ Math.min(delay, MAX_TIMEOUT_MS),
107
+ );
108
+ return () => clearTimeout(timer);
109
+ }, [fetchedAt, stale, tick]);
110
+ return stale;
111
+ }
112
+
113
+ interface RefreshConfirmationInput {
114
+ /** Rises each time the user asks this panel to refresh. */
115
+ requestToken: number;
116
+ /** Changes when the panel's query does, so its refresh can no longer land. */
117
+ scopeKey: string;
118
+ /** The request whose own forced fetch just succeeded, from `useSqlQuery`. */
119
+ completedToken: number | undefined;
120
+ }
121
+
122
+ /**
123
+ * True for `REFRESH_CONFIRMATION_MS` after the fetch of this panel's latest
124
+ * manual refresh request succeeds. Only a completion for exactly the current
125
+ * request counts: an automatic revalidation, a cancelled, failed or replaced
126
+ * request, or a token the panel mounted with never reports one.
127
+ */
128
+ export function useRefreshConfirmation(input: RefreshConfirmationInput) {
129
+ const { requestToken, scopeKey, completedToken } = input;
130
+ const [confirmedIdentity, setConfirmedIdentity] = useState<string>();
131
+ const processedIdentity = useRef<string | undefined>(undefined);
132
+ const context = useRef({ requestToken, scopeKey });
133
+ const completionIdentity =
134
+ completedToken === requestToken
135
+ ? JSON.stringify([requestToken, scopeKey, completedToken])
136
+ : undefined;
137
+
138
+ // A completion already present during a request or scope change belongs to
139
+ // the old context. Consume it so it cannot be adopted by the new panel state.
140
+ if (
141
+ context.current.requestToken !== requestToken ||
142
+ context.current.scopeKey !== scopeKey
143
+ ) {
144
+ if (completionIdentity) processedIdentity.current = completionIdentity;
145
+ context.current = { requestToken, scopeKey };
146
+ }
147
+
148
+ useEffect(() => {
149
+ if (!completionIdentity) {
150
+ setConfirmedIdentity(undefined);
151
+ return;
152
+ }
153
+ if (processedIdentity.current === completionIdentity) return;
154
+ processedIdentity.current = completionIdentity;
155
+ setConfirmedIdentity(completionIdentity);
156
+ }, [completionIdentity]);
157
+
158
+ const confirmed =
159
+ completionIdentity !== undefined &&
160
+ confirmedIdentity === completionIdentity;
161
+ useEffect(() => {
162
+ if (!confirmed) return;
163
+ const timer = setTimeout(
164
+ () => setConfirmedIdentity(undefined),
165
+ REFRESH_CONFIRMATION_MS,
166
+ );
167
+ return () => clearTimeout(timer);
168
+ }, [confirmed]);
169
+
170
+ return confirmed;
171
+ }
@@ -610,6 +610,65 @@
610
610
  opacity: 1;
611
611
  }
612
612
 
613
+ .dashboard-chart-card {
614
+ container-type: inline-size;
615
+ }
616
+
617
+ /* Status and hover controls keep their tracks when their contents change. */
618
+ .dashboard-panel-header {
619
+ display: grid;
620
+ grid-template-columns: minmax(0, 1fr) 1.5rem 5rem;
621
+ align-items: center;
622
+ column-gap: 0.5rem;
623
+ }
624
+
625
+ .dashboard-panel-header > * {
626
+ margin-block: 0;
627
+ }
628
+
629
+ .dashboard-panel-freshness {
630
+ display: flex;
631
+ align-items: center;
632
+ height: 1.5rem;
633
+ }
634
+
635
+ .dashboard-panel-freshness .panel-freshness-routine {
636
+ opacity: 0;
637
+ }
638
+
639
+ .dashboard-chart-card:hover .panel-freshness-routine,
640
+ .dashboard-chart-card:focus-within .panel-freshness-routine {
641
+ opacity: 1;
642
+ }
643
+
644
+ @media (hover: none) {
645
+ .dashboard-panel-freshness .panel-freshness-routine {
646
+ opacity: 1;
647
+ }
648
+ }
649
+
650
+ .panel-freshness-text {
651
+ display: none;
652
+ }
653
+
654
+ .dashboard-panel-actions {
655
+ height: 1.5rem;
656
+ }
657
+
658
+ @container (min-width: 30rem) {
659
+ .dashboard-panel-header {
660
+ grid-template-columns: minmax(0, 1fr) 9rem 5rem;
661
+ }
662
+
663
+ .dashboard-panel-freshness .panel-freshness-text {
664
+ display: block;
665
+ }
666
+
667
+ .dashboard-panel-freshness .panel-freshness-routine {
668
+ opacity: 1;
669
+ }
670
+ }
671
+
613
672
  .dashboard-chart-card[data-dragging="true"],
614
673
  .dashboard-section-card[data-dragging="true"] {
615
674
  opacity: 0.55;
@@ -943,6 +943,7 @@ export default {
943
943
  certificationFailed: "Couldn't certify dashboard: {{message}}",
944
944
  details: "Details",
945
945
  updated: "Updated {{date}}",
946
+ updatedAgo: "Updated {{age}}",
946
947
  public: "Public",
947
948
  sharedWithOrg: "Shared with org",
948
949
  private: "Private",
@@ -998,8 +999,15 @@ export default {
998
999
  refresh: "Refresh",
999
1000
  refreshing: "Refreshing...",
1000
1001
  updating: "Updating",
1002
+ refreshed: "Refreshed",
1001
1003
  updatingPreviousFilters: "Previous filters · Updating",
1002
1004
  previousFilters: "Previous filters",
1005
+ fetchedAt: "Fetched {{time}}",
1006
+ fetchTimeUnknown: "Fetch time unknown",
1007
+ cacheApp: "Cached",
1008
+ cacheProvider: "BigQuery cache",
1009
+ cacheRestored: "Saved copy",
1010
+ cacheUnknown: "Cache status unknown",
1003
1011
  downloadCsv: "Download CSV",
1004
1012
  copyTable: "Copy table",
1005
1013
  multiColumnSortHelp: "Click to sort. Shift-click to add another sort.",