Skip to content

Add Gen UI Analytics Dashboard listing - #698

Open
boxel-submission-bot wants to merge 4 commits into
mainfrom
e4df05-gen-ui-analytics-dashboard
Open

Add Gen UI Analytics Dashboard listing#698
boxel-submission-bot wants to merge 4 commits into
mainfrom
e4df05-gen-ui-analytics-dashboard

Conversation

@boxel-submission-bot

Copy link
Copy Markdown
Collaborator

Summary

Getting from a question to a chart usually means exporting data, cleaning it, and fighting a chart library — by the time the graph exists, the meeting is over. Gen UI Analytics Dashboard skips all of that: ask a question and watch the answer build itself as a chart on your wall. Drop a CSV or a screenshot onto the canvas and it becomes a dataset with a suggested chart; ask a live-web question and a search-grounded model fetches current figures with cited sources. Every chart is its own card — long-press to drag it anywhere, resize it from any edge Figma-style, undo any layout change with ⌘Z, and reuse it on other dashboards.

Open the demo to see it shine: the Tesla 2025 Analytics wall mixes a KPI tile, a revenue donut, regional bars, and a two-company delivery trend line, all built from web data. Click a tile's data button to inspect the dataset behind it, each stamped with a provenance badge — web-sourced, AI-recalled, or extracted from file. Then drop the bundled sales CSV onto the fresh dashboard and watch a chart appear in seconds, tap the sun-and-moon toggle to flip the whole wall between its night and light looks, or ask the AI assistant (the Gen UI Analyst skill ships with the listing) to build you a new view.


@boxel-submission-bot
boxel-submission-bot requested a review from a team August 6, 2026 04:29
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Staging Submissions Preview

This PR's content is pushed to the staging submissions realm: https://realms-staging.stack.cards/submissions/

Changed listings:

Changed folders:

  • e4df05-gen-ui-analytics-dashboard/

Updated at 2026-08-12 17:01:33 UTC for commit f27483c. Shared realm: only this PR's changed files are pushed; files touched by multiple PRs reflect whichever pushed last, and deleted files are not removed.

@lucaslyl
lucaslyl requested a review from a team August 11, 2026 12:26

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated code review (xhigh recall). 8 findings posted inline on the new .gts logic files. Highest-impact: unlinkCard can remove the wrong tile when a broken link is present (filtered-vs-unfiltered index), bucketDate timezone drift misbuckets date-only values, and category sorting reorders "Qn YYYY" time labels non-chronologically. The rest cover a permanently-cached ECharts load failure, a suggestChart measure-selection miss for year,value CSVs, a stale hardcoded source path, and two small cleanups. Data/JSON example files and CSS were not flagged.


Generated by Claude Code

}

@action unlinkCard(index: number) {
let removed = this.args.model?.cards?.[index];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong card removed when a broken link exists. The {{#each this.cards}} loop iterates the filtered list (get cards does .filter(Boolean)), so index is a filtered-array index. But unlinkCard reads this.args.model?.cards?.[index] from the unfiltered model.cards. When model.cards contains any null (an unresolved/broken linksToMany link — the very reason get cards filters), the indices diverge and the wrong tile is removed.

Example: model.cards = [null, A, B]this.cards = [A, B]. Clicking remove on B (filtered index 1) sets removed = model.cards[1] = A, then doRemove splices out A. B survives, A is deleted — silently the wrong chart.

Pass the card into requestRemove/unlinkCard (like the drag handlers already do) and locate it via indexOf, rather than indexing model.cards by a filtered index.


Generated by Claude Code

if (bucket === 'none' || value == null) {
return String(value ?? 'unknown');
}
let date = value instanceof Date ? value : new Date(String(value));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timezone drift buckets date-only values into the wrong period. new Date('2025-01-01') parses as UTC midnight, but getFullYear()/getMonth()/getDate() read local time. For any viewer west of UTC, 2025-01-01 becomes 2024-12-31 locally, so bucket: 'year' returns "2024", 'quarter' returns "2024 Q4", and 'month' returns "2024-12" — every period-boundary date lands one period early.

This hits the CSV path (suggestChart auto-selects xBucket: 'month' for date columns with >31 rows) and the web path whenever dates are ISO strings. Parse the Y/M/D from the string yourself (or construct with new Date(y, m, d) local) instead of relying on Date's UTC parse + local getters.


Generated by Claude Code

bytSeries.get(ser)!.push(measureOf(row));
}

let categories = [...table.keys()].sort();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Categories are sorted lexicographically, which misorders time labels. [...table.keys()].sort() sorts strings alphabetically. The extraction prompt explicitly tells the model to emit pre-aggregated x labels like "Q2 2025" with xBucket: 'none'. Across years these sort by the leading "Qn" token, not chronologically: ["Q4 2024", "Q1 2025"] sorts to ["Q1 2025", "Q4 2024"] — the line/bar renders time going backwards. Month names ("Jan","Feb",...) and unpadded numeric labels have the same problem.

Consider preserving first-seen order (insertion order of table) or sorting only when the bucket is a known sortable format, rather than always alphabetically.


Generated by Claude Code

let echartsLoaded: Promise<void> | undefined;

function loadECharts(): Promise<void> {
if (!echartsLoaded) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A single transient CDN failure permanently disables all charts for the session. echartsLoaded caches the promise, but on a fetch/parse failure it caches a rejected promise that is never cleared. Every later loadECharts() returns that same rejected promise, so no chart ever retries the load — even after connectivity returns. The await loadECharts() in modify also rejects unhandled.

Reset the cache on failure so a later render can retry, e.g. wrap the IIFE so echartsLoaded = undefined in a .catch before rethrowing.


Generated by Claude Code

// donut, categories → bar, single numeric row → kpi
export function suggestChart(parsed: ParsedCsv, name: string): SuggestedChart {
let { columns, rows } = parsed;
let numeric = columns.find((c) => c.type === 'number');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestChart picks the wrong column as the measure for year,value CSVs. A year column like 2020/2021 matches NUMBER_RE before DATE_RE, so its type is inferred as number, not date. numeric = columns.find(c => c.type === 'number') then returns year (the first numeric column). With no date and no string column, the final fallback builds x: columns[0].name (year) and y: { field: numeric.name = year, aggregate: 'sum' } — a bar of sum(year) by year, i.e. each bar equals the year value. The intended measure (the second numeric column) is never used. Consider preferring the last numeric column, or excluding the chosen x column when picking the measure.


Generated by Claude Code

.replace(/[^a-z0-9.]+/g, '-')
.replace(/^-+|-+$/g, '') || 'dropped-data';
let res = await new WriteBinaryFileCommand(commandContext).execute({
path: `8d24c1-gen-ui-analytics/sources/${slug}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded source path doesn't match this listing's directory. This writes dropped provenance files to 8d24c1-gen-ui-analytics/sources/, but the listing lives in e4df05-gen-ui-analytics-dashboard/ (different prefix and different base name). Unlike the skill lookup on line 960, which correctly resolves relative to import.meta.url so it survives an install, this absolute realm path is a stale leftover: dropped originals land in an unrelated 8d24c1-gen-ui-analytics/sources/ folder that has no relationship to the dashboard, and the resulting sourceFileUrl "View source" link points there. (The write is best-effort/try-caught, so it fails silently.) Derive the path from the module location or the dashboard's own directory.


Generated by Claude Code

realm,
});

(this.args.model as any).cards = [...this.cards, chart];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: appending via the filtered this.cards silently drops any broken links from model.cards. this.cards is (model.cards ?? []).filter(Boolean), so model.cards = [...this.cards, chart] rewrites the field without the null placeholders it had before. Adding a chart thus permanently deletes any unresolved links on the wall as a side effect. Prefer appending to the raw model.cards array.


Generated by Claude Code

/^\d{4}-\d{2}(-\d{2})?([T ].*)?$|^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}$/;

function toNumber(value: string): number {
return Number(value.replace(/[$,%]/g, '').replace(/,/g, ''));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: the second .replace(/,/g, '') is dead — the first .replace(/[$,%]/g, '') already strips every comma (, is in the character class). One replace suffices.


Generated by Claude Code

@lucaslyl

Copy link
Copy Markdown
Contributor

Thanks for the thorough catch-up @habdelra — all eight are addressed in 653ed52. The timezone and sort bugs were the interesting pair: unbucketed axes now keep source order instead of sorting alphabetically, which also un-scrambled the hero and CSV-import charts (both datasets were authored in descending value order all along), so those two screenshots are rebuilt. suggestChart now picks the measure from the numeric columns that aren't the dimension, the ECharts promise cache clears on failure, unlinkCard locates the card by identity rather than a filtered index (undo's restore position had the same bug), appends go to the raw model.cards, and the provenance path comes off import.meta.url. Added regression tests for the local-time bucketing, both category orderings, and the year/measure selection.

@habdelra

Copy link
Copy Markdown
Contributor

theres still a lot of lint issues

boxel-submission-bot and others added 4 commits August 13, 2026 00:59
The chart, dataset, and dashboard modules import chart-render,
chart-spec, and parse-csv; include those leaves so the modules
type-check, plus the bundled demo CSV the listing summary points
users at and the live tests for the exported pure functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cross-realm links resolve via @cardstack/catalog/… in any deployment;
the absolutized app.boxel.ai URLs only worked against production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness fixes from review:

- bucketDate: parse date-only strings as local Y/M/D. `new Date('2025-01-01')`
  is UTC midnight while getFullYear()/getMonth() read local time, so west of
  UTC every period-boundary date bucketed one period early.
- aggregate: only sort categories when the axis is bucketed. bucketDate emits
  sortable labels ("2024", "2024 Q2", "2024-03"), but xBucket:'none' labels are
  pre-aggregated ("Q4 2024", "Q1 2025") and sorted alphabetically ran time
  backwards. Unbucketed axes now keep source order.
- suggestChart: pick the measure from the numeric columns that are not the
  dimension. A `year` column types as number (it matches NUMBER_RE before
  DATE_RE), so `year,value` charted sum(year) by year and never used the measure.
- chart-render: clear the cached ECharts promise on failure. A single transient
  CDN error cached a rejected promise and permanently disabled every chart for
  the session; also check response.ok and stop the load rejecting unhandled.
- unlinkCard: locate the card by identity, not by a filtered index. The template
  iterates the null-filtered list while the splice indexed the raw model.cards,
  so an unresolved link made the wrong tile get removed. Undo's restore position
  had the same bug.
- mintDatasetAndChart: append to the raw model.cards so unresolved links are not
  dropped as a side effect of adding a chart.
- Derive the dropped-file provenance path from import.meta.url instead of a
  stale hardcoded listing directory.
- Drop a redundant comma-stripping replace in toNumber.

Adds regression tests for the local-time bucketing, both category orderings,
and the year/measure column selection.

Rebuilds the hero and CSV-import screenshots: both datasets are authored in
descending value order, which the alphabetical sort had been scrambling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@richardhjtan
richardhjtan force-pushed the e4df05-gen-ui-analytics-dashboard branch from 653ed52 to f27483c Compare August 12, 2026 16:59
@richardhjtan
richardhjtan requested review from a team and habdelra August 19, 2026 07:15

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review of the Gen UI Analytics Dashboard listing (11 inline findings).

Highest-impact ones are all in the render/ingest path and are reproducible from the artifacts in this PR itself:

  • color: 'inherit' / fontFamily: 'inherit' are not valid ECharts (canvas) values — the donut labels and legends are unreadable in both night and light mode, visible in the committed screenshots.
  • grid.left: 48 without containLabel clips 7-digit y-axis labels (,800,000 in the same screenshots).
  • 'donut' falls into tooltip.trigger: 'axis', so donut slices have no tooltip.
  • I ran the shipped CSV parser over the bundled sources/sales-data-sample.csv: ORDERDATE ("2/24/2003 0:00") fails DATE_RE so it types as string, and the measure heuristic picks ORDERNUMBER — the flagship "drop the CSV" demo mints a 205-category bar chart of summed order numbers instead of anything about SALES.

The rest are smaller: a minted chart can be born invalid when the model omits y.field/x, drag/resize don't handle pointercancel, undo-after-remove loses the tile rect, the Dataset fitted card always says "3 columns", an empty kpi dataset renders a big "0", and the global ⌘Z listener fires from outside the dashboard.

Nothing here blocks the architecture — the card/spec/validate split and the CDN loader (matching the existing map-render.gts precedent) look good.


Generated by Claude Code

type: 'pie',
radius: spec.chartKind === 'donut' ? ['45%', '72%'] : '72%',
data,
label: { color: 'inherit' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

color: 'inherit' is not a valid ECharts color. ECharts renders to a canvas and zrender assigns this straight to ctx.fillStyle; an invalid color string is silently ignored, so the text is painted with whatever fill was last set rather than the theme foreground. The committed screenshots show it both ways on the shipped "Tesla 2025 Revenue Mix" donut: in Screenshots/01-hero-tesla-wall-*.png the slice labels/legend are dark on the near-black wall, and in Screenshots/05-light-mode-*.png the same labels are near-white on white — unreadable in both themes.

Same applies to the legends on lines 257 and 289, and to textStyle: { fontFamily: 'inherit' } on line 246 (canvas gets 12px inherit and falls back to the default family, not the page font). These need real values — e.g. thread a ink/muted color in alongside palette.


Generated by Claude Code

let type = spec.chartKind === 'line' ? 'line' : 'bar';
return {
...base,
grid: { left: 48, right: 16, top: 32, bottom: 48 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left: 48 with ECharts' default containLabel: false clips y-axis labels wider than 48px — and this demo's own numbers are in the millions (1,800,000 is ~60px at the default 12px font). It's visible in the committed screenshots: the "Tesla Deliveries by Model" axis reads ,100,000 / ,800,000 / ,500,000 and "Tesla vs BYD Delivery Trend" reads ,500,000 / ,000,000.

Suggested change
grid: { left: 48, right: 16, top: 32, bottom: 48 },
grid: { left: 48, right: 16, top: 32, bottom: 48, containLabel: true },

Generated by Claude Code

let base: Record<string, any> = {
color: palette,
animationDuration: 400,
tooltip: { trigger: spec.chartKind === 'pie' ? 'item' : 'axis' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only 'pie' is special-cased, so a 'donut' spec gets trigger: 'axis' — but the donut branch below emits series: [{ type: 'pie' }] with no xAxis/yAxis at all. An axis trigger needs a cartesian axis/axisPointer, so hovering a slice of the shipped "Tesla 2025 Revenue Mix" donut shows no tooltip, while the same chart saved as pie does.

Suggested change
tooltip: { trigger: spec.chartKind === 'pie' ? 'item' : 'axis' },
tooltip: {
trigger:
spec.chartKind === 'pie' || spec.chartKind === 'donut'
? 'item'
: 'axis',
},

Generated by Claude Code

// taking the first numeric outright would chart sum(year) by year and never
// touch the real measure.
let measureFor = (dimension?: string) =>
columns.find((c) => c.type === 'number' && c.name !== dimension);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measure is "first numeric column that isn't the dimension" with no guard against identifier columns, so a real export charts an ID. I ran this pipeline over the CSV bundled in this PR (sources/sales-data-sample.csv — the file the PR description tells reviewers to drop on the fresh dashboard) and got:

columns: ORDERNUMBER(number), QUANTITYORDERED(number), PRICEEACH(number), ORDERLINENUMBER(number), SALES(number), ORDERDATE(string), …
suggested: {"chartKind":"bar","x":"ORDERDATE","xBucket":"none","y":{"field":"ORDERNUMBER","aggregate":"sum"}}
→ 205 categories, series[0] = [40428, 20242, 40536, …]

So the headline "drop the CSV and watch a chart appear" flow mints a 205-bar chart of summed order numbers; SALES is never considered. Worth de-prioritising columns whose name looks like an id (/(^|_)(id|number|code|no)$/i) or whose values are near-unique integers, and/or preferring the last numeric column.


Generated by Claude Code


const NUMBER_RE = /^-?\$?[\d,]+(\.\d+)?%?$/;
const DATE_RE =
/^\d{4}-\d{2}(-\d{2})?([T ].*)?$|^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}$/;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ISO branch allows a trailing time (([T ].*)?) but the M/D/YYYY branch is anchored with no time allowance — and that's the format spreadsheets export. The CSV bundled in this PR has ORDERDATE values like 2/24/2003 0:00, which fail this regex, so the column is inferred as string, columns.find(c => c.type === 'date') finds nothing, and suggestChart skips the date→line rule entirely: you get a bar chart keyed on 205 raw date strings with xBucket: 'none', in source order rather than chronological.

Allowing the same optional time suffix on the slash branch (e.g. ^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}([T ].*)?$) fixes both the type inference and the bucketing.


Generated by Claude Code

this.persistLayout();
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No pointercancel handler here (or in startResize, line 667). The move gesture is a 300ms long-press — i.e. a touch gesture — and .tile sets no touch-action, so the browser can take the pointer over for scrolling and fire pointercancel with no pointerup following. When that happens onMove/onUp stay bound to window and movingIndex/resizingIndex are never cleared: the tile keeps its grabbing outline and the user's next touch anywhere on the page fires pointermove, teleporting the tile to the cursor before the next pointerup persists that position.

Registering onUp for pointercancel as well (and/or setPointerCapture on the tile) closes it.


Generated by Claude Code

return;
}
let key = this.layoutKey(removed, index);
let entry = this.liveLayout?.[key];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads liveLayout only, but persistLayout() always ends with this.liveLayout = undefined, so outside of an in-flight drag entry is undefined and the if (entry) this.restoreEntry(...) in the undo closure is dead code.

Concrete loss: remove a tile, then drag any other tile — persistLayout bakes only the surviving cards, dropping the removed card's key from layoutJson — then ⌘Z twice. The card is re-linked but neither liveLayout nor storedLayout has its rect any more, so entryFor falls back to the 2-per-row default and the restored tile lands on top of another one.

Suggested change
let entry = this.liveLayout?.[key];
let entry = this.liveLayout?.[key] ?? this.storedLayout[key];

Generated by Claude Code

return parseRows(this.args.model?.rowsJson);
}
get columns(): DatasetColumn[] {
return parseColumns(this.args.model?.columnsJson).slice(0, 3);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This getter is sliced to 3 for the chip strip but also feeds the metadata line ({{this.columns.length}} columns, line 559), so the fitted card can never report more than "3 columns" — a Dataset built from the bundled 25-column sales CSV says 3 columns while the isolated view correctly says 25 (line 104). Keep the full list and slice at the call site, e.g.

get allColumns() { return parseColumns(this.args.model?.columnsJson); }
get columns() { return this.allColumns.slice(0, 3); }

and use this.allColumns.length in .r-meta.


Generated by Claude Code

if (spec?.chartKind !== 'kpi') {
return undefined;
}
let { total } = aggregate(this.rows, spec);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No empty-rows guard here, so an empty (or unresolved / unparseable) dataset gives total: 0kpiValue === '0', which is a truthy string in the template. The {{else if this.kpiValue}} branch then renders a 3rem 0 as if zero were the real answer, instead of falling through to "This chart's dataset has no rows yet." The fitted variant already guards this (line 374), so the two formats disagree.

Suggested change
let { total } = aggregate(this.rows, spec);
let { total } = aggregate(this.rows, spec);
if (!this.rows.length) {
return undefined;
}

Generated by Claude Code

shortcuts = modifier(() => {
let onKey = (e: KeyboardEvent) => {
let el = document.activeElement as HTMLElement | null;
if (el && /^(INPUT|TEXTAREA)$/.test(el.tagName)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the listener is on window and the guard only covers INPUT/TEXTAREA, so ⌘Z pressed while focus is in a contenteditable — or in any other card in the companion stack — is preventDefault()ed and undoes this dashboard's last tile move instead of whatever the user was actually editing. Two dashboards open side by side both undo on the same keypress. Checking el.isContentEditable and that document.activeElement is inside this dashboard's element would scope it.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants