@happyvertical/smrt-reports 0.57.5 → 0.57.7

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 (74) hide show
  1. package/AGENTS.md +10 -0
  2. package/dist/chunks/runtime-time-D7JZ1e4J.js +1052 -0
  3. package/dist/chunks/runtime-time-D7JZ1e4J.js.map +1 -0
  4. package/dist/index.d.ts +7 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +58 -591
  7. package/dist/index.js.map +1 -1
  8. package/dist/manifest.json +28 -4
  9. package/dist/playground.js +1 -1
  10. package/dist/playground.js.map +1 -1
  11. package/dist/recipes.d.ts +21 -0
  12. package/dist/recipes.d.ts.map +1 -1
  13. package/dist/runtime-compiler.d.ts +7 -40
  14. package/dist/runtime-compiler.d.ts.map +1 -1
  15. package/dist/runtime-dimensions.d.ts +43 -0
  16. package/dist/runtime-dimensions.d.ts.map +1 -0
  17. package/dist/runtime-memory.d.ts +81 -0
  18. package/dist/runtime-memory.d.ts.map +1 -0
  19. package/dist/runtime-plan.d.ts +121 -0
  20. package/dist/runtime-plan.d.ts.map +1 -0
  21. package/dist/runtime-report.d.ts.map +1 -1
  22. package/dist/runtime-server.d.ts.map +1 -1
  23. package/dist/runtime-spec-hash.d.ts +11 -0
  24. package/dist/runtime-spec-hash.d.ts.map +1 -0
  25. package/dist/runtime-spec.d.ts +16 -4
  26. package/dist/runtime-spec.d.ts.map +1 -1
  27. package/dist/runtime-time.d.ts +41 -0
  28. package/dist/runtime-time.d.ts.map +1 -0
  29. package/dist/smrt-knowledge.json +37 -12
  30. package/dist/spec.d.ts +25 -0
  31. package/dist/spec.d.ts.map +1 -0
  32. package/dist/spec.js +2 -0
  33. package/dist/state.js +1 -1
  34. package/dist/state.js.map +1 -1
  35. package/dist/svelte/ReportConfirmation.svelte +97 -24
  36. package/dist/svelte/ReportConfirmation.svelte.d.ts +20 -0
  37. package/dist/svelte/ReportConfirmation.svelte.d.ts.map +1 -1
  38. package/dist/svelte/ReportDetail.svelte +263 -12
  39. package/dist/svelte/ReportDetail.svelte.d.ts +10 -1
  40. package/dist/svelte/ReportDetail.svelte.d.ts.map +1 -1
  41. package/dist/svelte/ReportPicker.svelte +47 -14
  42. package/dist/svelte/ReportPicker.svelte.d.ts +3 -0
  43. package/dist/svelte/ReportPicker.svelte.d.ts.map +1 -1
  44. package/dist/svelte/ReportResult.svelte +216 -35
  45. package/dist/svelte/ReportResult.svelte.d.ts +8 -2
  46. package/dist/svelte/ReportResult.svelte.d.ts.map +1 -1
  47. package/dist/svelte/RuntimeReportWidgetOptions.svelte +9 -1
  48. package/dist/svelte/RuntimeReportWidgetOptions.svelte.d.ts.map +1 -1
  49. package/dist/svelte/SavedReportCard.svelte +242 -0
  50. package/dist/svelte/SavedReportCard.svelte.d.ts +30 -0
  51. package/dist/svelte/SavedReportCard.svelte.d.ts.map +1 -0
  52. package/dist/svelte/SavedReports.svelte +206 -33
  53. package/dist/svelte/SavedReports.svelte.d.ts +13 -0
  54. package/dist/svelte/SavedReports.svelte.d.ts.map +1 -1
  55. package/dist/svelte/SavedReportsWidget.svelte +59 -0
  56. package/dist/svelte/SavedReportsWidget.svelte.d.ts +19 -0
  57. package/dist/svelte/SavedReportsWidget.svelte.d.ts.map +1 -0
  58. package/dist/svelte/client.d.ts.map +1 -1
  59. package/dist/svelte/client.js +3 -0
  60. package/dist/svelte/describe.d.ts +94 -0
  61. package/dist/svelte/describe.d.ts.map +1 -0
  62. package/dist/svelte/describe.js +249 -0
  63. package/dist/svelte/index.d.ts +4 -1
  64. package/dist/svelte/index.d.ts.map +1 -1
  65. package/dist/svelte/index.js +4 -1
  66. package/dist/svelte/messages.d.ts +48 -0
  67. package/dist/svelte/messages.d.ts.map +1 -1
  68. package/dist/svelte/messages.js +59 -11
  69. package/dist/svelte/playground.d.ts +2 -2
  70. package/dist/svelte/playground.d.ts.map +1 -1
  71. package/dist/svelte/playground.js +1 -1
  72. package/dist/svelte/types.d.ts +2 -0
  73. package/dist/svelte/types.d.ts.map +1 -1
  74. package/package.json +13 -9
@@ -1,11 +1,17 @@
1
1
  <script lang="ts">
2
2
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
3
3
  import { Button } from '@happyvertical/smrt-ui/ui';
4
+ import { describeReport, type ReportFieldInfo } from './describe.js';
4
5
  import { M } from './messages.js';
5
6
  import ReportResult from './ReportResult.svelte';
6
7
  import { reportError, validReportProposal } from './result.js';
7
8
  import type { RuntimeReportProposal, SavedRuntimeReport } from './types.js';
8
9
 
10
+ /** How a confirmation was answered; it then renders as a read-only line. */
11
+ export type ReportConfirmationOutcome =
12
+ | { status: 'saved'; report: Pick<SavedRuntimeReport, 'id' | 'title'> }
13
+ | { status: 'declined' };
14
+
9
15
  export interface Props {
10
16
  proposal: RuntimeReportProposal;
11
17
  /** Posts the exact displayed spec and hash to the authenticated confirm route. */
@@ -13,6 +19,18 @@ export interface Props {
13
19
  reportHref?: (id: string) => string;
14
20
  ondecline?: () => void;
15
21
  onsaved?: (report: SavedRuntimeReport) => void;
22
+ /**
23
+ * An earlier answer (from the transcript): the card shows its outcome line
24
+ * and offers no buttons, so a reloaded or re-rendered card cannot approve
25
+ * twice.
26
+ */
27
+ outcome?: ReportConfirmationOutcome | null;
28
+ /**
29
+ * The source's field labels and formats (its descriptor's `fields`), so
30
+ * groupings and filters read "Issue date", "Total amount" and money, not
31
+ * model field names. Omitted, names are humanized.
32
+ */
33
+ fields?: readonly ReportFieldInfo[];
16
34
  locale?: string;
17
35
  currency?: string;
18
36
  }
@@ -22,12 +40,14 @@ let {
22
40
  reportHref = (id) => `/reports/${encodeURIComponent(id)}`,
23
41
  ondecline,
24
42
  onsaved,
43
+ outcome = null,
44
+ fields = [],
25
45
  locale = 'en',
26
46
  currency = 'USD',
27
47
  }: Props = $props();
28
48
  const { t } = useI18n();
29
49
  let status = $state<'idle' | 'saving' | 'declined' | 'saved'>('idle');
30
- let saved = $state<SavedRuntimeReport | null>(null);
50
+ let saved = $state<Pick<SavedRuntimeReport, 'id' | 'title'> | null>(null);
31
51
  let error = $state<ReturnType<typeof reportError> | null>(null);
32
52
  let epoch = 0;
33
53
  $effect(() => {
@@ -42,10 +62,32 @@ $effect(() => {
42
62
  };
43
63
  });
44
64
  const valid = $derived(validReportProposal(proposal));
45
- const label = (field: string) => field;
65
+ const answered = $derived<ReportConfirmationOutcome | null>(
66
+ outcome ??
67
+ (status === 'saved' && saved
68
+ ? { status: 'saved', report: saved }
69
+ : status === 'declined'
70
+ ? { status: 'declined' }
71
+ : null),
72
+ );
73
+ const description = $derived(
74
+ valid
75
+ ? describeReport(proposal.spec, {
76
+ t,
77
+ locale,
78
+ currency,
79
+ source: {
80
+ id: proposal.spec.source,
81
+ label: proposal.sourceLabel,
82
+ fields,
83
+ },
84
+ columns: proposal.preview.columns,
85
+ })
86
+ : null,
87
+ );
46
88
  async function approve(event: MouseEvent) {
47
89
  // Approval is an immediate local human action, never a model-callable tool.
48
- if (!event.isTrusted || status !== 'idle' || !valid) return;
90
+ if (!event.isTrusted || status !== 'idle' || !valid || answered) return;
49
91
  const current = epoch;
50
92
  const specHash = proposal.specHash;
51
93
  status = 'saving';
@@ -64,36 +106,67 @@ async function approve(event: MouseEvent) {
64
106
  }
65
107
  }
66
108
  function decline() {
67
- if (status !== 'idle') return;
109
+ if (status !== 'idle' || answered) return;
68
110
  status = 'declined';
69
111
  ondecline?.();
70
112
  }
71
113
  </script>
72
114
 
73
115
  <section data-smrt-component="ReportConfirmation" aria-label={t(M['reports.confirm_title'])}>
74
- <h2>{valid ? proposal.spec.title : t(M['reports.confirm_title'])}</h2>
116
+ <h3 class="report-confirmation-title">{valid ? proposal.spec.title : t(M['reports.confirm_title'])}</h3>
75
117
  {#if !valid}
76
118
  <p role="alert">{t(M['reports.invalid'])}</p>
77
- {:else if saved}
78
- <p role="status">{t(M['reports.saved'])}</p>
79
- <a href={reportHref(saved.id)}>{saved.title}</a>
80
- {:else if status === 'declined'}
81
- <p role="status">{t(M['reports.declined'])}</p>
82
119
  {:else}
83
- {#if proposal.spec.description}<p>{proposal.spec.description}</p>{/if}
84
- <dl>
85
- <dt>{t(M['reports.source'])}</dt><dd>{proposal.sourceLabel}</dd>
86
- <dt>{t(M['reports.groupings'])}</dt>
87
- <dd>{#each proposal.spec.dimensions as d (d.as)}<p>{label(d.field)}{#if d.bucket} · {t(`reports.bucket.${d.bucket}`)}{/if}</p>{:else}{t(M['reports.none'])}{/each}</dd>
88
- <dt>{t(M['reports.measures'])}</dt>
89
- <dd>{#each proposal.spec.measures as m (m.as)}<p>{t(`reports.aggregate.${m.fn}`, { field: m.field ? label(m.field) : t(M['reports.rows']) })}</p>{/each}</dd>
90
- <dt>{t(M['reports.filters'])}</dt>
91
- <dd>{#each proposal.spec.filters as f, i (i)}<p>{label(f.field)} {t(`reports.operator.${f.op}`)} {Array.isArray(f.value) ? f.value.join(', ') : String(f.value ?? '')}</p>{:else}{t(M['reports.none'])}{/each}</dd>
92
- {#if proposal.spec.having.length}<dt>{t(M['reports.having'])}</dt><dd>{#each proposal.spec.having as h, i (i)}<p>{h.measure} {t(`reports.operator.${h.op}`)} {h.value}</p>{/each}</dd>{/if}
93
- </dl>
120
+ {#if proposal.spec.description}<p class="report-confirmation-description">{proposal.spec.description}</p>{/if}
121
+ {#if description}
122
+ <dl class="report-confirmation-facts">
123
+ <div><dt>{t(M['reports.source'])}</dt><dd>{description.source}</dd></div>
124
+ <div><dt>{t(M['reports.groupings'])}</dt><dd>{description.groupings || t(M['reports.none'])}</dd></div>
125
+ {#if description.period}
126
+ <div><dt>{t(M['reports.period'])}</dt><dd>{description.period.range}</dd></div>
127
+ {/if}
128
+ <div><dt>{t(M['reports.measures'])}</dt><dd>{description.measures.join(', ')}</dd></div>
129
+ {#if description.filters.length}
130
+ <div><dt>{t(M['reports.filters'])}</dt><dd>{description.filters.join('; ')}</dd></div>
131
+ {/if}
132
+ {#if description.having.length}
133
+ <div><dt>{t(M['reports.having'])}</dt><dd>{description.having.join('; ')}</dd></div>
134
+ {/if}
135
+ </dl>
136
+ {/if}
94
137
  <ReportResult result={proposal.preview} {locale} {currency} />
95
- {#if error}<p role="alert">{t(M[error])}</p>{/if}
96
- <Button disabled={status === 'saving'} onclick={approve}>{t(M[status === 'saving' ? 'reports.saving' : 'reports.approve'])}</Button>
97
- <Button disabled={status === 'saving'} onclick={decline}>{t(M['reports.decline'])}</Button>
138
+ {#if answered?.status === 'saved'}
139
+ <p class="report-confirmation-outcome" role="status">{t(M['reports.saved'])} · <a href={reportHref(answered.report.id)}>{t(M['reports.open'])}</a></p>
140
+ {:else if answered?.status === 'declined'}
141
+ <p class="report-confirmation-outcome" role="status">{t(M['reports.declined'])}</p>
142
+ {:else}
143
+ {#if error}<p role="alert">{t(M[error])}</p>{/if}
144
+ <div class="report-confirmation-actions">
145
+ <Button variant="secondary" disabled={status === 'saving'} onclick={decline}>{t(M['reports.decline'])}</Button>
146
+ <Button disabled={status === 'saving'} aria-busy={status === 'saving'} onclick={approve}>{t(M[status === 'saving' ? 'reports.saving' : 'reports.approve'])}</Button>
147
+ </div>
148
+ {/if}
98
149
  {/if}
99
150
  </section>
151
+
152
+ <style>
153
+ @layer smrt.reset, smrt.structure, smrt.theme;
154
+ @layer smrt.theme {
155
+ [data-smrt-component='ReportConfirmation'] { display: grid; gap: var(--smrt-spacing-3); inline-size: 100%; min-inline-size: 0; box-sizing: border-box; padding: var(--smrt-spacing-3); border: var(--smrt-hairline); border-radius: var(--smrt-radius-large); background: var(--smrt-color-surface-container); color: var(--smrt-color-on-surface); container-type: inline-size; }
156
+ .report-confirmation-title { margin: 0; font: var(--smrt-typography-title-medium-font); overflow-wrap: anywhere; }
157
+ .report-confirmation-description { margin: 0; color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-body-small-size); }
158
+ .report-confirmation-facts { display: grid; gap: var(--smrt-spacing-1); margin: 0; font-size: var(--smrt-typography-body-small-size); }
159
+ .report-confirmation-facts > div { display: grid; grid-template-columns: 5.5rem minmax(0, 1fr); gap: var(--smrt-spacing-2); align-items: baseline; }
160
+ .report-confirmation-facts dt { color: var(--smrt-color-on-surface-variant); }
161
+ .report-confirmation-facts dd { margin: 0; overflow-wrap: anywhere; }
162
+ .report-confirmation-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--smrt-spacing-2); }
163
+ /* A narrow dock: labels over values, and the actions share the row. */
164
+ @container (max-width: 12rem) {
165
+ .report-confirmation-facts > div { grid-template-columns: minmax(0, 1fr); gap: 0; }
166
+ }
167
+ @container (max-width: 22rem) {
168
+ .report-confirmation-actions > :global(*) { flex: 1 1 0; }
169
+ }
170
+ .report-confirmation-outcome { margin: 0; color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-body-small-size); }
171
+ }
172
+ </style>
@@ -1,4 +1,12 @@
1
+ import { type ReportFieldInfo } from './describe.js';
1
2
  import type { RuntimeReportProposal, SavedRuntimeReport } from './types.js';
3
+ /** How a confirmation was answered; it then renders as a read-only line. */
4
+ export type ReportConfirmationOutcome = {
5
+ status: 'saved';
6
+ report: Pick<SavedRuntimeReport, 'id' | 'title'>;
7
+ } | {
8
+ status: 'declined';
9
+ };
2
10
  export interface Props {
3
11
  proposal: RuntimeReportProposal;
4
12
  /** Posts the exact displayed spec and hash to the authenticated confirm route. */
@@ -6,6 +14,18 @@ export interface Props {
6
14
  reportHref?: (id: string) => string;
7
15
  ondecline?: () => void;
8
16
  onsaved?: (report: SavedRuntimeReport) => void;
17
+ /**
18
+ * An earlier answer (from the transcript): the card shows its outcome line
19
+ * and offers no buttons, so a reloaded or re-rendered card cannot approve
20
+ * twice.
21
+ */
22
+ outcome?: ReportConfirmationOutcome | null;
23
+ /**
24
+ * The source's field labels and formats (its descriptor's `fields`), so
25
+ * groupings and filters read "Issue date", "Total amount" and money, not
26
+ * model field names. Omitted, names are humanized.
27
+ */
28
+ fields?: readonly ReportFieldInfo[];
9
29
  locale?: string;
10
30
  currency?: string;
11
31
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ReportConfirmation.svelte.d.ts","sourceRoot":"","sources":["../../src/svelte/ReportConfirmation.svelte.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAG5E,MAAM,WAAW,KAAK;IACpB,QAAQ,EAAE,qBAAqB,CAAC;IAChC,kFAAkF;IAClF,OAAO,EAAE,CAAC,QAAQ,EAAE,qBAAqB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC1E,UAAU,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA6FD,QAAA,MAAM,kBAAkB,2CAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
1
+ {"version":3,"file":"ReportConfirmation.svelte.d.ts","sourceRoot":"","sources":["../../src/svelte/ReportConfirmation.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAAkB,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAIrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAG5E,4EAA4E;AAC5E,MAAM,MAAM,yBAAyB,GACjC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,IAAI,CAAC,kBAAkB,EAAE,IAAI,GAAG,OAAO,CAAC,CAAA;CAAE,GACrE;IAAE,MAAM,EAAE,UAAU,CAAA;CAAE,CAAC;AAE3B,MAAM,WAAW,KAAK;IACpB,QAAQ,EAAE,qBAAqB,CAAC;IAChC,kFAAkF;IAClF,OAAO,EAAE,CAAC,QAAQ,EAAE,qBAAqB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC1E,UAAU,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAC/C;;;;OAIG;IACH,OAAO,CAAC,EAAE,yBAAyB,GAAG,IAAI,CAAC;IAC3C;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAkID,QAAA,MAAM,kBAAkB,2CAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
@@ -1,9 +1,26 @@
1
1
  <script lang="ts">
2
+ /**
3
+ * A saved report's page: its title and description, what it covers (source,
4
+ * groupings, the period with its dates, measures, filters, who saved it and
5
+ * when it last ran), then its result run afresh: headline figures and a chart
6
+ * or table. The primary action follows the report's state: Edit (asks the
7
+ * page's assistant), Try again after a failed run, Restore when archived.
8
+ * Export CSV sits beside it; Re-run and Archive are in the "more" menu.
9
+ */
10
+ import { requestAssistant } from '@happyvertical/smrt-ui/chat';
11
+ import { ConfirmDialog } from '@happyvertical/smrt-ui/feedback';
2
12
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
3
- import { Button } from '@happyvertical/smrt-ui/ui';
13
+ import { Badge, Button, Menu } from '@happyvertical/smrt-ui/ui';
14
+ import { untrack } from 'svelte';
15
+ import {
16
+ describeReport,
17
+ type ReportSourceInfo,
18
+ relativeTime,
19
+ reportCsv,
20
+ } from './describe.js';
4
21
  import { M } from './messages.js';
5
22
  import ReportResult from './ReportResult.svelte';
6
- import { reportError, validReportResult } from './result.js';
23
+ import { reportCell, reportError, validReportResult } from './result.js';
7
24
  import type {
8
25
  RuntimeReportResult,
9
26
  RuntimeReportsClient,
@@ -13,22 +30,47 @@ import type {
13
30
  export interface Props {
14
31
  id: string;
15
32
  client?: RuntimeReportsClient;
33
+ /** The report sources by label and fields (`reports.runtime.sources`). */
34
+ sources?: readonly ReportSourceInfo[];
35
+ /** Host-owned identity projection ("You"); others stay unnamed. */
36
+ authorLabel?: (userId: string) => string | undefined;
37
+ /** Called with the report once it loads (a host titles its page with it). */
38
+ onreport?: (report: SavedRuntimeReport) => void;
39
+ /** Replace Edit asking the page's assistant. */
40
+ onedit?: (report: SavedRuntimeReport) => void;
16
41
  locale?: string;
17
42
  currency?: string;
18
43
  }
19
- let { id, client, locale = 'en', currency = 'USD' }: Props = $props();
44
+ let {
45
+ id,
46
+ client,
47
+ sources = [],
48
+ authorLabel = () => undefined,
49
+ onreport,
50
+ onedit,
51
+ locale = 'en',
52
+ currency = 'USD',
53
+ }: Props = $props();
20
54
  const { t } = useI18n();
21
55
  let report = $state<SavedRuntimeReport | null>(null);
22
56
  let result = $state<RuntimeReportResult | null>(null);
23
57
  let error = $state<ReturnType<typeof reportError> | null>(null);
24
58
  let loading = $state(false);
25
59
  let revision = $state(0);
60
+ let view = $state<'chart' | 'table'>('chart');
61
+ let confirming = $state(false);
62
+ let archiving = $state(false);
63
+ let askNote = $state(false);
64
+ let root = $state<HTMLElement | null>(null);
65
+ let shownId: string | null = null;
26
66
  $effect(() => {
27
67
  const transport = client;
28
68
  const reportId = id;
29
69
  revision;
30
70
  let active = true;
31
- report = null;
71
+ // A re-run keeps the report shown; another report starts blank.
72
+ if (shownId !== reportId) report = null;
73
+ shownId = reportId;
32
74
  result = null;
33
75
  error = null;
34
76
  loading = !!transport;
@@ -38,6 +80,8 @@ $effect(() => {
38
80
  const saved = await transport.get(reportId);
39
81
  if (!active) return;
40
82
  report = saved;
83
+ untrack(() => onreport)?.(saved);
84
+ if (saved.status === 'archived') return;
41
85
  const value = await transport.run(reportId);
42
86
  if (!active) return;
43
87
  if (!validReportResult(value)) error = 'reports.invalid';
@@ -53,17 +97,224 @@ $effect(() => {
53
97
  active = false;
54
98
  };
55
99
  });
100
+
101
+ const source = $derived(
102
+ report ? sources.find((s) => s.id === report?.sourceId) : undefined,
103
+ );
104
+ const description = $derived(
105
+ report
106
+ ? describeReport(report.spec, {
107
+ t,
108
+ locale,
109
+ currency,
110
+ source,
111
+ columns: result?.columns,
112
+ })
113
+ : null,
114
+ );
115
+ const author = $derived(
116
+ report ? authorLabel(report.createdByUserId) : undefined,
117
+ );
118
+ const savedText = $derived.by(() => {
119
+ if (!report) return '';
120
+ const time = report.savedAt ? relativeTime(report.savedAt, locale) : '';
121
+ if (author && time) return t(M['reports.saved_by_at'], { author, time });
122
+ if (author) return t(M['reports.saved_by'], { author });
123
+ return time ? t(M['reports.saved_at'], { time }) : '';
124
+ });
125
+ const ranText = $derived(
126
+ result?.ranAt
127
+ ? t(M['reports.ran_at'], {
128
+ time: relativeTime(
129
+ result.ranAt,
130
+ locale,
131
+ undefined,
132
+ t(M['reports.just_now']),
133
+ ),
134
+ })
135
+ : '',
136
+ );
137
+ const archived = $derived(report?.status === 'archived');
138
+
139
+ function edit() {
140
+ if (!report) return;
141
+ if (onedit) {
142
+ onedit(report);
143
+ return;
144
+ }
145
+ askNote = !(
146
+ root &&
147
+ requestAssistant(root, {
148
+ prompt: t(M['reports.edit_prompt'], { title: report.title }),
149
+ })
150
+ );
151
+ }
152
+
153
+ function exportCsv() {
154
+ if (!result || !report || typeof document === 'undefined') return;
155
+ const csv = reportCsv(result, (value, column) =>
156
+ typeof value === 'boolean'
157
+ ? t(M[value ? 'reports.yes' : 'reports.no'])
158
+ : reportCell(value, column, locale, currency),
159
+ );
160
+ const url = URL.createObjectURL(
161
+ new Blob([csv], { type: 'text/csv;charset=utf-8' }),
162
+ );
163
+ const link = document.createElement('a');
164
+ link.href = url;
165
+ link.download = `${
166
+ report.title
167
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
168
+ .replace(/^-|-$/g, '')
169
+ .toLowerCase() || 'report'
170
+ }.csv`;
171
+ link.click();
172
+ setTimeout(() => URL.revokeObjectURL(url), 0);
173
+ }
174
+
175
+ async function setArchived(next: boolean) {
176
+ if (!client || !report || archiving) return;
177
+ archiving = true;
178
+ error = null;
179
+ try {
180
+ await client.archive(report.id, next);
181
+ revision++;
182
+ } catch (e) {
183
+ error = reportError(e);
184
+ } finally {
185
+ archiving = false;
186
+ confirming = false;
187
+ }
188
+ }
189
+
190
+ // The page runs the report afresh each time it opens, so Re-run is a
191
+ // secondary action beside Archive.
192
+ const menu = $derived([
193
+ ...(report && !archived && !error
194
+ ? [
195
+ {
196
+ id: 'rerun',
197
+ label: t(M['reports.rerun']),
198
+ disabled: loading,
199
+ onselect: () => {
200
+ revision++;
201
+ },
202
+ },
203
+ ]
204
+ : []),
205
+ ...(report?.canArchive && !archived
206
+ ? [
207
+ {
208
+ id: 'archive',
209
+ label: t(M['reports.archive']),
210
+ disabled: archiving,
211
+ onselect: () => {
212
+ confirming = true;
213
+ },
214
+ },
215
+ ]
216
+ : []),
217
+ ]);
56
218
  </script>
57
219
 
58
- <section data-smrt-component="ReportDetail" aria-label={t(M['reports.title'])}>
59
- <h1>{report?.title ?? t(M['reports.title'])}</h1>
220
+ <section
221
+ bind:this={root}
222
+ class="report-detail"
223
+ data-smrt-component="ReportDetail"
224
+ data-report-id={id}
225
+ aria-label={report?.title ?? t(M['reports.title'])}
226
+ >
227
+ <header class="report-detail__head">
228
+ <div class="report-detail__heading">
229
+ <div class="report-detail__title-row">
230
+ <h1 class="report-detail__title">{report?.title ?? t(M['reports.title'])}</h1>
231
+ {#if archived}<Badge>{t(M['reports.archived_badge'])}</Badge>{/if}
232
+ </div>
233
+ {#if description}<p class="report-detail__line">{description.line}</p>{/if}
234
+ {#if report?.description}<p class="report-detail__description">{report.description}</p>{/if}
235
+ </div>
236
+ {#if client && report}
237
+ <div class="report-detail__actions">
238
+ {#if archived}
239
+ {#if report.canArchive}<Button variant="primary" disabled={archiving} onclick={() => setArchived(false)}>{t(M['reports.restore'])}</Button>{/if}
240
+ {:else if error}
241
+ <Button variant="primary" disabled={loading} onclick={() => revision++}>{t(M['reports.retry'])}</Button>
242
+ {:else}
243
+ <Button variant="secondary" disabled={!result} onclick={exportCsv}>{t(M['reports.export_csv'])}</Button>
244
+ <Button variant="primary" onclick={edit}>{t(M['reports.edit'])}</Button>
245
+ {/if}
246
+ {#if menu.length}
247
+ <Menu variant="icon" placement="bottom-end" triggerLabel={t(M['reports.more'], { title: report.title })} items={menu}>
248
+ {#snippet trigger()}<span aria-hidden="true">⋯</span>{/snippet}
249
+ </Menu>
250
+ {/if}
251
+ </div>
252
+ {/if}
253
+ </header>
60
254
  {#if !client}
61
- <p>{t(M['reports.unavailable'])}</p>
255
+ <p class="report-detail__note">{t(M['reports.unavailable'])}</p>
62
256
  {:else}
63
- {#if report?.description}<p>{report.description}</p>{/if}
64
- <Button disabled={loading} onclick={() => revision++}>{t(M['reports.rerun'])}</Button>
65
- {#if loading}<p role="status">{t(M['reports.running'])}</p>{/if}
66
- {#if error}<p role="alert">{t(M[error])}</p>{/if}
67
- {#if result}<ReportResult {result} {locale} {currency} />{/if}
257
+ {#if description}
258
+ <dl class="report-detail__facts" aria-label={t(M['reports.details'])}>
259
+ <div><dt>{t(M['reports.source'])}</dt><dd>{description.source}</dd></div>
260
+ <div><dt>{t(M['reports.groupings'])}</dt><dd>{description.groupings || t(M['reports.none'])}</dd></div>
261
+ {#if description.period}
262
+ <div><dt>{t(M['reports.period'])}</dt><dd>{description.period.range}<span class="report-detail__aside">{' · '}{description.period.field}</span></dd></div>
263
+ {/if}
264
+ <div><dt>{t(M['reports.measures'])}</dt><dd>{description.measures.join(', ')}</dd></div>
265
+ {#if description.filters.length}
266
+ <div><dt>{t(M['reports.filters'])}</dt><dd>{description.filters.join('; ')}</dd></div>
267
+ {/if}
268
+ {#if description.having.length}
269
+ <div><dt>{t(M['reports.having'])}</dt><dd>{description.having.join('; ')}</dd></div>
270
+ {/if}
271
+ {#if savedText || ranText}
272
+ <div class="report-detail__meta"><dt class="report-detail__sr">{t(M['reports.author'])}</dt><dd>{[savedText, ranText].filter(Boolean).join(' · ')}</dd></div>
273
+ {/if}
274
+ </dl>
275
+ {/if}
276
+ {#if askNote}<p class="report-detail__note" role="status">{t(M['reports.ask_where'])}</p>{/if}
277
+ {#if loading && !result}<p class="report-detail__note" role="status">{t(M['reports.running'])}</p>{/if}
278
+ {#if error}<p class="report-detail__error" role="alert">{t(M[error])}</p>{/if}
279
+ {#if result && report}
280
+ <div class="report-detail__result">
281
+ <ReportResult {result} spec={report.spec} variant="full" bind:view {locale} {currency} />
282
+ </div>
283
+ {/if}
68
284
  {/if}
285
+ <ConfirmDialog
286
+ open={confirming}
287
+ title={t(M['reports.archive_confirm_title'], { title: report?.title ?? '' })}
288
+ message={t(M['reports.archive_confirm'])}
289
+ confirmLabel={t(M['reports.archive'])}
290
+ cancelLabel={t(M['reports.cancel'])}
291
+ loading={archiving}
292
+ onconfirm={() => void setArchived(true)}
293
+ oncancel={() => (confirming = false)}
294
+ />
69
295
  </section>
296
+
297
+ <style>
298
+ @layer smrt.reset, smrt.structure, smrt.theme;
299
+ @layer smrt.theme {
300
+ .report-detail { display: grid; gap: var(--smrt-spacing-5); min-inline-size: 0; }
301
+ .report-detail__head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: var(--smrt-spacing-3) var(--smrt-spacing-5); }
302
+ .report-detail__heading { display: grid; gap: var(--smrt-spacing-1); flex: 1 1 20rem; min-inline-size: 0; }
303
+ .report-detail__title-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--smrt-spacing-2); }
304
+ .report-detail__title { margin: 0; font: var(--smrt-typography-headline-medium-font); overflow-wrap: anywhere; }
305
+ .report-detail__line { margin: 0; color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-body-medium-size); }
306
+ .report-detail__description { margin: 0; max-inline-size: 65ch; font-size: var(--smrt-typography-body-medium-size); }
307
+ .report-detail__actions { display: flex; flex-wrap: nowrap; align-items: center; gap: var(--smrt-spacing-2); }
308
+ .report-detail__facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr)); gap: var(--smrt-spacing-3) var(--smrt-spacing-5); margin: 0; padding: var(--smrt-spacing-4); border: var(--smrt-hairline); border-radius: var(--smrt-radius-large); background: var(--smrt-color-surface-container-low); }
309
+ .report-detail__facts > div { display: grid; gap: var(--smrt-spacing-1); min-inline-size: 0; }
310
+ .report-detail__facts dt { color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-label-medium-size); }
311
+ .report-detail__facts dd { margin: 0; overflow-wrap: anywhere; font-size: var(--smrt-typography-body-medium-size); }
312
+ .report-detail__meta { grid-column: 1 / -1; padding-block-start: var(--smrt-spacing-3); border-block-start: var(--smrt-hairline); }
313
+ .report-detail__meta dd { color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-body-small-size); }
314
+ .report-detail__aside { color: var(--smrt-color-on-surface-variant); }
315
+ .report-detail__sr { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
316
+ .report-detail__note { margin: 0; color: var(--smrt-color-on-surface-variant); }
317
+ .report-detail__error { margin: 0; color: var(--smrt-color-error); }
318
+ .report-detail__result { padding: var(--smrt-spacing-4); border: var(--smrt-hairline); border-radius: var(--smrt-radius-large); background: var(--smrt-color-surface); }
319
+ }
320
+ </style>
@@ -1,7 +1,16 @@
1
- import type { RuntimeReportsClient } from './types.js';
1
+ import { type ReportSourceInfo } from './describe.js';
2
+ import type { RuntimeReportsClient, SavedRuntimeReport } from './types.js';
2
3
  export interface Props {
3
4
  id: string;
4
5
  client?: RuntimeReportsClient;
6
+ /** The report sources by label and fields (`reports.runtime.sources`). */
7
+ sources?: readonly ReportSourceInfo[];
8
+ /** Host-owned identity projection ("You"); others stay unnamed. */
9
+ authorLabel?: (userId: string) => string | undefined;
10
+ /** Called with the report once it loads (a host titles its page with it). */
11
+ onreport?: (report: SavedRuntimeReport) => void;
12
+ /** Replace Edit asking the page's assistant. */
13
+ onedit?: (report: SavedRuntimeReport) => void;
5
14
  locale?: string;
6
15
  currency?: string;
7
16
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ReportDetail.svelte.d.ts","sourceRoot":"","sources":["../../src/svelte/ReportDetail.svelte.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAEV,oBAAoB,EAErB,MAAM,YAAY,CAAC;AAGpB,MAAM,WAAW,KAAK;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA+DD,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"ReportDetail.svelte.d.ts","sourceRoot":"","sources":["../../src/svelte/ReportDetail.svelte.ts"],"names":[],"mappings":"AAgBA,OAAO,EAEL,KAAK,gBAAgB,EAGtB,MAAM,eAAe,CAAC;AAIvB,OAAO,KAAK,EAEV,oBAAoB,EACpB,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAGpB,MAAM,WAAW,KAAK;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,0EAA0E;IAC1E,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACtC,mEAAmE;IACnE,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IACrD,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAChD,gDAAgD;IAChD,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAC9C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAgQD,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
- import { Select } from '@happyvertical/smrt-ui/forms';
2
+ import { FormGroup, Select } from '@happyvertical/smrt-ui/forms';
3
3
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
4
+ import { describeReport, type ReportSourceInfo } from './describe.js';
4
5
  import { M } from './messages.js';
5
6
  import { reportError } from './result.js';
6
7
  import type { RuntimeReportsClient, SavedRuntimeReport } from './types.js';
@@ -10,6 +11,8 @@ export interface Props {
10
11
  reportId?: string;
11
12
  measure?: string;
12
13
  dimension?: string;
14
+ /** The report sources by label and fields, for measure and grouping names. */
15
+ sources?: readonly ReportSourceInfo[];
13
16
  onchange: (value: {
14
17
  reportId: string;
15
18
  measure: string;
@@ -21,6 +24,7 @@ let {
21
24
  reportId = '',
22
25
  measure = '',
23
26
  dimension = '',
27
+ sources = [],
24
28
  onchange,
25
29
  }: Props = $props();
26
30
  const { t } = useI18n();
@@ -28,6 +32,21 @@ let reports = $state<SavedRuntimeReport[]>([]);
28
32
  let loading = $state(false);
29
33
  let error = $state<ReturnType<typeof reportError> | null>(null);
30
34
  const selected = $derived(reports.find((r) => r.id === reportId));
35
+ const named = $derived.by(() => {
36
+ if (!selected) return { measures: [], dimensions: [] };
37
+ const source = sources.find((s) => s.id === selected.sourceId);
38
+ const describe = (spec: typeof selected.spec) =>
39
+ describeReport(spec, { t, locale: 'en', currency: 'USD', source });
40
+ const measures = describe(selected.spec).measures.map((label, i) => ({
41
+ value: selected.spec.measures[i]?.as ?? '',
42
+ label,
43
+ }));
44
+ const dimensions = selected.spec.dimensions.map((d) => ({
45
+ value: d.as,
46
+ label: describe({ ...selected.spec, dimensions: [d] }).groupings,
47
+ }));
48
+ return { measures, dimensions };
49
+ });
31
50
  $effect(() => {
32
51
  const transport = client;
33
52
  let active = true;
@@ -52,22 +71,36 @@ $effect(() => {
52
71
  });
53
72
  </script>
54
73
 
55
- <div data-smrt-component="ReportPicker">
74
+ <div class="report-picker" data-smrt-component="ReportPicker">
56
75
  {#if !client}<p>{t(M['reports.unavailable'])}</p>
57
76
  {:else}
58
77
  {#if loading}<p role="status">{t(M['reports.loading'])}</p>{/if}
59
78
  {#if error}<p role="alert">{t(M[error])}</p>{/if}
60
- <Select aria-label={t(M['reports.select_report'])} value={reportId} disabled={loading || !!error} onchange={event => onchange({ reportId: event.currentTarget.value, measure: '', dimension: '' })}>
61
- <option value="">{t(M['reports.choose_report'])}</option>
62
- {#each reports as report (report.id)}<option value={report.id}>{report.title}</option>{/each}
63
- </Select>
64
- <Select aria-label={t(M['reports.select_measure'])} value={measure} disabled={!selected} onchange={event => onchange({ reportId, measure: event.currentTarget.value, dimension })}>
65
- <option value="">{t(M['reports.choose_measure'])}</option>
66
- {#each selected?.spec.measures ?? [] as m (m.as)}<option value={m.as}>{m.as}</option>{/each}
67
- </Select>
68
- <Select aria-label={t(M['reports.select_dimension'])} value={dimension} disabled={!selected} onchange={event => onchange({ reportId, measure, dimension: event.currentTarget.value })}>
69
- <option value="">{t(M['reports.choose_dimension'])}</option>
70
- {#each selected?.spec.dimensions ?? [] as d (d.as)}<option value={d.as}>{d.as}</option>{/each}
71
- </Select>
79
+ <FormGroup label={t(M['reports.select_report'])}>
80
+ <Select value={reportId} disabled={loading || !!error} onchange={event => onchange({ reportId: event.currentTarget.value, measure: '', dimension: '' })}>
81
+ <option value="">{t(M['reports.choose_report'])}</option>
82
+ {#each reports as report (report.id)}<option value={report.id}>{report.title}</option>{/each}
83
+ </Select>
84
+ </FormGroup>
85
+ <FormGroup label={t(M['reports.select_measure'])}>
86
+ <Select value={measure} disabled={!selected} onchange={event => onchange({ reportId, measure: event.currentTarget.value, dimension })}>
87
+ <option value="">{t(M['reports.choose_measure'])}</option>
88
+ {#each named.measures as m (m.value)}<option value={m.value}>{m.label}</option>{/each}
89
+ </Select>
90
+ </FormGroup>
91
+ <FormGroup label={t(M['reports.select_dimension'])}>
92
+ <Select value={dimension} disabled={!selected} onchange={event => onchange({ reportId, measure, dimension: event.currentTarget.value })}>
93
+ <option value="">{t(M['reports.choose_dimension'])}</option>
94
+ {#each named.dimensions as d (d.value)}<option value={d.value}>{d.label}</option>{/each}
95
+ </Select>
96
+ </FormGroup>
72
97
  {/if}
73
98
  </div>
99
+
100
+ <style>
101
+ @layer smrt.reset, smrt.structure, smrt.theme;
102
+ @layer smrt.theme {
103
+ .report-picker { display: grid; gap: var(--smrt-spacing-3); }
104
+ .report-picker > p { margin: 0; color: var(--smrt-color-on-surface-variant); font-size: var(--smrt-typography-body-small-size); }
105
+ }
106
+ </style>