diff --git a/AGENTS.md b/AGENTS.md index 12bda6e5..7d8ddf70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,7 +226,8 @@ How most packages work, so the bullets below don't repeat it: - `vp dlx skills add onmax/comark-ui` installs it from GitHub, the docs serve it under `/.well-known/skills/` through nuxt-agent-discovery, and comark-ui ships it. - `SKILL.md` installs the plugins, routes each block to its package's reference in `references/`, and holds what several packages share, such as the marks. - `test/workspace.test.ts` checks that every package has a reference linked from `SKILL.md` that ends with its Register section. A new package adds one, and a change to a package's syntax updates it. -- `skills/before-after-evidence`, `review-document`, `report`, `visual-explanation`, and `teach-lesson` are the skills for a whole document: what goes in it and in what order. +- `skills/before-after-evidence`, `review-document`, `report`, `data-report`, `visual-explanation`, and `teach-lesson` are the skills for a whole document: what goes in it and in what order. + - `data-report` also ships `scripts/build.mjs`, which wraps a report in a page with a top bar, a sidebar, and a theme toggle, and its `references/charts.md` is the chart gallery, built by the same script. - `vp dlx skills add onmax/comark-ui` lists them beside comark-ui and installs the ones picked. - Each installs alone, so it carries the syntax of the blocks it uses in `references/blocks.md` and links nothing outside its folder. Its `references/example.md` is a finished document. - Each has a page in `docs/pages/skills/` that leads with that example rendered. diff --git a/docs/examples.ts b/docs/examples.ts index 5d68f86a..3389f3ca 100644 --- a/docs/examples.ts +++ b/docs/examples.ts @@ -32,7 +32,7 @@ export const pageOrder = [ "eslint", "vite-doctor", ]; -export const recipeOrder = ["review", "pull-request", "share", "zoom", "carousel"]; +export const recipeOrder = ["review", "pull-request", "share", "data-report", "zoom", "carousel"]; // Third-party plugins the preset registers, by slug and npm name. They sit in the rail like a package, but have no // folder in packages/, so their pages sit in docs/pages//. export const thirdParty: Record = { mermaid: "comark-pele" }; diff --git a/docs/pages/recipes/data-report.md b/docs/pages/recipes/data-report.md new file mode 100644 index 00000000..b88d5493 --- /dev/null +++ b/docs/pages/recipes/data-report.md @@ -0,0 +1,165 @@ +--- +title: A data report +description: Headline figures, findings, and a section of charts per question, from one export, in one Markdown file. +keywords: analytics, dashboard, report, KPI, metrics, heatmap, data, BI +tagline: Turn a dataset into a page a reader decides from, with the figures first, then what stands out, then a chart per question. +install: pnpm add comark @comark/html comark-ui +--- + +::example{label="Data report"} +````md +::stats{tiles} +| Figure | Value | +| --- | --- | +| Questions | 418 | +| Threads | 297 | +| Customers | 8 | +| Answerable today | 78% | +| With action drafts | 87% | +| Median first reply | 1.3 h | +| With a screenshot | 37% | +:: + +## What stands out + +1. **78% of questions** can be answered today from the thread, the docs and code, and product data. Action drafts lift that to 87%. See [Sources](#sources). +2. **Data missing** is the largest type, 103 questions (25%). See [Question types](#question-types). +3. **Contoso** asks 93 questions, and 9 of them need someone from the team. See [Customers](#customers). + +## Sources + +Each step adds one source an answer can draw on, from what the thread already holds to what only the team knows. What stays below 100% at the last step needs a person. + +::chart{type="line" unit="%" title="Share of questions answerable, adding one source at a time" description="All questions, and the two customers who ask the most."} +| step | all | Contoso | Northwind | +| --- | --: | --: | --: | +| thread only | 20 | 20 | 16 | +| + docs and code | 55 | 49 | 55 | +| + product data | 78 | 78 | 76 | +| + action draft | 87 | 90 | 87 | +:: + +## Customers + +::chart{type="stacked" horizontal title="Questions per customer, by the last source a reply needs" description="The first three steps are answerable today; team only needs a person."} +| customer | thread only | docs and code | product data | action draft | team only | +| --- | --: | --: | --: | --: | --: | +| Contoso | 19 | 27 | 27 | 11 | 9 | +| Northwind | 12 | 30 | 16 | 8 | 10 | +| Tailspin | 15 | 17 | 10 | 1 | 9 | +| Fabrikam | 8 | 16 | 13 | 4 | 7 | +| Wingtip | 10 | 15 | 9 | 1 | 8 | +| Litware | 8 | 15 | 5 | 6 | 3 | +| Adventure Works | 7 | 13 | 9 | 3 | 5 | +| Proseware | 4 | 13 | 7 | 4 | 4 | +:: + +::chart{type="heatmap" title="Questions per customer and type" description="Darker cells hold more questions."} +| customer | Data missing | How it works | Forecast | Purchasing | Bug | Alerts | +| --- | --: | --: | --: | --: | --: | --: | +| Contoso | 20 | 20 | 18 | 13 | 10 | 7 | +| Northwind | 20 | 19 | 8 | 10 | 8 | 1 | +| Tailspin | 11 | 10 | 9 | 7 | 4 | 3 | +| Fabrikam | 13 | 10 | 7 | 8 | 4 | 3 | +| Wingtip | 10 | 13 | 5 | 4 | 1 | 3 | +| Litware | 10 | 6 | 1 | 3 | 6 | 8 | +| Adventure Works | 10 | 4 | 4 | 5 | 4 | 5 | +| Proseware | 9 | 6 | 5 | 3 | 3 | 4 | +:: + +## Question types + +::chart{horizontal title="Questions per type" description="Sorted by volume."} +| type | questions | +| --- | --: | +| Data missing | 103 | +| How it works | 88 | +| Forecast | 57 | +| Purchasing | 53 | +| Bug | 40 | +| Alerts | 34 | +| Feature request | 27 | +| Setup | 16 | +:: + +::chart{type="heatmap" better="higher" unit="%" title="Share answerable today, per type" description="Setup, feature requests, and bugs need the team most often."} +| type | answerable today | +| --- | --: | +| Data missing | 80 | +| How it works | 86 | +| Forecast | 77 | +| Purchasing | 75 | +| Bug | 65 | +| Alerts | 88 | +| Feature request | 63 | +| Setup | 62 | +:: + +## Over time + +::chart{type="stacked" title="Questions per month, by type" description="The four largest types, and the rest together."} +| month | Data missing | How it works | Forecast | Purchasing | other | +| --- | --: | --: | --: | --: | --: | +| 2026-05 | 9 | 13 | 4 | 6 | 11 | +| 2026-06 | 20 | 13 | 6 | 6 | 13 | +| 2026-07 | 17 | 6 | 11 | 10 | 13 | +| 2026-08 | 19 | 15 | 17 | 10 | 29 | +| 2026-09 | 18 | 15 | 7 | 9 | 25 | +| 2026-10 | 18 | 20 | 10 | 10 | 18 | +:: + +::chart{type="line" log unit="h" title="Time to first reply" description="Median and 90th percentile per month, on a log scale."} +| month | median | p90 | +| --- | --: | --: | +| 2026-05 | 2.04 | 15.7 | +| 2026-06 | 1.23 | 8.8 | +| 2026-07 | 1.04 | 6.0 | +| 2026-08 | 1.33 | 8.4 | +| 2026-09 | 1.01 | 4.8 | +| 2026-10 | 1.02 | 3.1 | +:: + +## Team + +::chart{type="heatmap" title="Replies per person and type" description="Ana answers most of every type."} +| person | Data missing | How it works | Forecast | Purchasing | Bug | Alerts | +| --- | --: | --: | --: | --: | --: | --: | +| Ana | 52 | 42 | 25 | 26 | 24 | 17 | +| Ben | 26 | 19 | 13 | 15 | 9 | 10 | +| Chen | 16 | 20 | 12 | 9 | 4 | 6 | +| Dara | 9 | 7 | 7 | 3 | 3 | 1 | +:: + +| Person | Replies | Median first reply | +| --- | --: | --: | +| Ana | 215 | 1.4 h | +| Ben | 101 | 1.1 h | +| Chen | 71 | 1.1 h | +| Dara | 31 | 1.2 h | + +## Method + +::details{summary="How the numbers were computed"} +The export holds one row per customer message. A question is a message that asks for something; thanks and scheduling are left out. Each question was classified by its type and by the last source a sendable reply needed, in the order the line chart adds them. First reply is the time from the customer's first message to the team's first reply, in calendar hours. Every number on this page comes from one script over the export, `analyze.py`, and nothing was typed by hand. +:: +```` +:: + +## The order + +A reader opens a data report to learn how much, where, when, and who, so the page keeps one shape: + +1. `::stats{tiles}` holds the headline figures in one strip, a name and a value each. +2. **What stands out** lists three to five findings, each with its number in bold and a link to the section whose chart shows it. +3. Each `##` section answers one question with one chart: [bars](/charts#type) for how many, stacked bars for how a total splits, a [heatmap](/charts#heatmap) for two groupings crossed or for scores, a line for a trend, and money with [`unit="$"`](/charts#unit). +4. A `::details` at the end says how every number was computed. + +Every chart has a `title` with what it measures and a `description` with what to see. The charts span the column, so a page of them reads like a dashboard. + +## Build it with an agent + +The [data-report skill](/skills/data-report) has an agent profile the data, compute every number in one script, and write this page. Its `build.mjs` wraps the page in one HTML file with a top bar, a sidebar with the outline and where the data comes from, and a light and dark toggle. + +## Check it + +Run [comark-linter](/lint) on the file, then open the rendered page and read every chart: each must say what its description says, with no label over another. diff --git a/docs/pages/skills/data-report.md b/docs/pages/skills/data-report.md new file mode 100644 index 00000000..96ad3297 --- /dev/null +++ b/docs/pages/skills/data-report.md @@ -0,0 +1,44 @@ +--- +title: Data report +description: A dataset turned into an analytics page, with headline figures, findings, and a section of charts per question. +icon: chart-column +keywords: analytics, dashboard, data, export, metrics, KPI, heatmap, chart, report, BI +tagline: Your agent profiles the data, computes every number in one script, and builds a page of charts with a top bar and a sidebar, in light and dark. +--- + +::skill{name="data-report"} +:: + +## What it does + +The skill turns an export, a database, logs, or eval results into one page with a fixed shape: + +- the **headline figures** in one strip +- **what stands out**, three to five findings, each linking to the chart that shows it +- **one section per question**, each with the chart that answers it: bars, stacked bars, lines, heatmaps, log scales, and money as `$22.2k` +- **how the numbers were made**, folded at the end + +Every number comes from one script the agent keeps, so the next run starts from the script rather than from the page. + +The skill's `build.mjs` wraps the report in a page: a top bar with a link per section, a sidebar with the outline and where the data comes from, and a light and dark toggle. The result is one HTML file that opens from disk or deploys as a static page. + +## When to use it + +Ask with the data at hand, such as "Build a report of this support export" or "Make a dashboard of these eval runs." To be sure, say "Use the data-report skill." + +Choose by what you have: + +- Data to explore and summarize: this skill. +- Options to compare, or a plan to approve: [Report](/skills/report). + +## It's working if + +- The strip at the top answers how much in four to seven figures. +- Each finding's number appears in a chart of the section it links to. +- Every chart has a title with its measure, a description that says what to see, and a unit when its numbers have one. +- A category keeps its color on every chart. +- The page ends with what a row is, what was left out, and the script that made the numbers. + +## The chart gallery + +The skill's `references/charts.md` maps each question shape to its chart, in the house style the reports follow. The same build script renders it, so it doubles as a gallery of every chart type in light and dark. diff --git a/docs/pages/skills/index.md b/docs/pages/skills/index.md index d6a1407f..408a2cb9 100644 --- a/docs/pages/skills/index.md +++ b/docs/pages/skills/index.md @@ -14,6 +14,7 @@ tagline: Each skill teaches your agent one kind of document, what goes in it and - A visible change needs proof on a pull request: [Before/after evidence](/skills/before-after-evidence). - A review is done, and someone has to decide what to do: [Review document](/skills/review-document). - Someone has to choose between options or approve a plan, from your research: [Report](/skills/report). +- Someone has a dataset and wants analytics, a dashboard, or a report of it: [Data report](/skills/data-report). - Someone asked how a system, a flow, or a change works: [Visual explanation](/skills/visual-explanation). - Someone needs to learn one concept and prove it: [Teach lesson](/skills/teach-lesson). - Your agent needs a block's syntax, not a whole document: the [comark-ui skill](/guide/agents). diff --git a/e2e/data-report.e2e.ts b/e2e/data-report.e2e.ts new file mode 100644 index 00000000..dcf6a714 --- /dev/null +++ b/e2e/data-report.e2e.ts @@ -0,0 +1,67 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, test } from "@playwright/test"; + +let folder: string; +let url: string; + +test.use({ viewport: { width: 1440, height: 900 } }); + +test.beforeAll(() => { + folder = mkdtempSync(join(tmpdir(), "comark-data-report-")); + const output = join(folder, "report.html"); + execFileSync( + process.execPath, + [ + fileURLToPath(new URL("../skills/data-report/scripts/build.mjs", import.meta.url)), + fileURLToPath(new URL("../skills/data-report/references/example.md", import.meta.url)), + output, + ], + { cwd: fileURLToPath(new URL("../packages/ui/", import.meta.url)) }, + ); + url = pathToFileURL(output).href; +}); + +test.afterAll(() => rmSync(folder, { recursive: true, force: true })); + +test("the generated report tracks its outline in both navigation areas", async ({ page }) => { + await page.goto(url); + const outline = page.locator(".dr-side ol a"); + await expect(outline).toHaveCount(7); + for (const link of await outline.all()) { + const target = await link.getAttribute("href"); + await expect(page.locator(`.dr-body .cl-heading > ${target}`)).toHaveCount(1); + } + await expect(page.locator(".dr-nav a[aria-current]")).toHaveAttribute("href", "#what-stands-out"); + await expect(page.locator(".dr-side ol a[aria-current]")).toHaveAttribute( + "href", + "#what-stands-out", + ); + + for (const id of ["customers", "sources"]) { + await page.locator(`.dr-body #${id}`).evaluate((heading) => { + window.scrollTo(0, window.scrollY + heading.getBoundingClientRect().top - 80); + }); + await expect(page.locator(".dr-nav a[aria-current]")).toHaveAttribute("href", `#${id}`); + await expect(page.locator(".dr-side ol a[aria-current]")).toHaveAttribute("href", `#${id}`); + } +}); + +test("outline fragments leave wrapped headings below the sticky header", async ({ page }) => { + await page.goto(url); + const headerBottom = await page + .locator(".dr-top") + .evaluate((header) => header.getBoundingClientRect().bottom); + await page.locator('.dr-nav a[href="#customers"]').click(); + await expect(page).toHaveURL(/#customers$/); + await expect + .poll(() => + page + .locator(".dr-body #customers") + .evaluate((heading) => heading.getBoundingClientRect().top), + ) + .toBeGreaterThanOrEqual(headerBottom + 15); +}); diff --git a/packages/ui/test/editor.test.ts b/packages/ui/test/editor.test.ts index 826412ec..927e7282 100644 --- a/packages/ui/test/editor.test.ts +++ b/packages/ui/test/editor.test.ts @@ -290,6 +290,7 @@ describe("the docs examples", async () => { const examples = await loadExamples(packages); const skills = [ "before-after-evidence", + "data-report", "report", "review-document", "teach-lesson", diff --git a/skills/data-report/SKILL.md b/skills/data-report/SKILL.md new file mode 100644 index 00000000..c8f32f62 --- /dev/null +++ b/skills/data-report/SKILL.md @@ -0,0 +1,71 @@ +--- +name: data-report +description: Turns a dataset into an analytics report page, with headline figures, what stands out, one section of charts per question, and a sidebar, built as one HTML file. Use when someone has data, such as an export, a database, logs, or eval results, and wants a dashboard, analytics, or a report of it. +license: MIT +--- + +# Data report + +A reader opens a data report to learn how much, where, when, and who, and to check any number they doubt. So the page has a fixed shape: the headline figures in one strip, what stands out, one section per question with its charts, and how the numbers were made. Every number comes from one script you keep, never from your head. + +For a page someone decides from, such as options or a plan, use `report`. This skill starts from raw data. + +## 1. Profile the data + +Load the data in a script, in whatever reads it and is installed: Python (its standard library is enough), pandas or DuckDB, SQL, or Node. Print the row count, each column's type, the date range, the distinct values of each category with their counts, and the empty cells per column. + +Done when you can say in one line what one row is, such as "one customer message", one line per source when there are several, and you've listed the **dimensions** (categories, dates, people) and the **measures** (counts, durations, costs, scores). + +## 2. Write the questions + +Write four to seven questions a reader of this data would ask, in the order they'd ask them. Each names one measure and one or two dimensions: "Which customers ask the most, and about what?" Each becomes a `##` section. + +Done when every question names its measure and its dimensions, and none needs a number the data doesn't hold. + +## 3. Compute every number in one script + +Write `analyze.py` (or `.ts`, or `.sql`) that reads the raw data and prints each chart's table as Markdown, the headline figures as the `::stats` table, and each finding's number. Keep at most seven series per chart, and seven bars when the x column holds names; add the rest up as `other`. A time axis keeps every period. Sort by value unless the order means something, such as time or steps. + +Done when the script runs from a clean start and prints every table and number the page will use. Rerun it after any change to the data or the questions, and paste its output rather than retyping it. + +## 4. Pick each section's chart + +Read [references/charts.md](references/charts.md) for which chart answers which question shape, and its house style, the rules every chart on the page follows. [references/blocks.md](references/blocks.md) has the syntax of every block the page uses. Give each question one chart. Add a plain Markdown table after it only for numbers a reader looks up, at most five columns. + +Done when every question has its chart and each chart follows every house-style rule: a `title` with the measure, a `description` with what to see, a `unit` when the numbers have one, and at most eight series. + +## 5. Write the page + +Follow [references/example.md](references/example.md), a finished report, in this order: + +1. **Frontmatter**: `title`, `description` (what the data covers, in one line), then fields about the data, such as `source`, `period`, `rows`, and `updated`, each source's span on its own when they differ. How the numbers were made goes in the Method section, not here. The sidebar lists every field besides the title and description under "About the data". +2. **Headline strip**: `::stats{tiles}` with four to seven figures, a name and a value each. +3. **What stands out**: three to five numbered findings. Each puts its number in bold and links to the section whose chart shows it. +4. **One `##` section per question**, opening with one sentence on what to look at when the chart's description doesn't say it, then its chart. +5. **Method**: a `::details` that says what a row is, what was left out, how each measure was computed, and the name of the script. + +Done when every number in a finding appears in a chart or table of the section it links to. + +## 6. Build and check it + +Copy this skill's [scripts/build.mjs](scripts/build.mjs) beside `report.md`. From that directory, build the page in a folder of its own: + +```sh +mkdir -p tmp/report && cd tmp/report +echo '{ "private": true, "type": "module" }' > package.json +pnpm add --ignore-workspace comark @comark/html comark-ui playwright +pnpm exec playwright install chromium +node ../../build.mjs ../../report.md report.html --shots shots +``` + +Without its own `package.json`, pnpm walks up to the repository's and can fail on its catalog. If the build says `description` or `unit` isn't a prop of `::chart`, that comark-ui is older than this skill: install `comark-ui@latest`. The script refuses a report with issues and prints each one with its fix. It writes `report.html`: a top bar with a link per section, the report, and a sidebar with the page's outline and the data's fields, in light and dark with a toggle. `--shots` saves the page in both themes, in slices about two screens tall, with the Data folds closed. + +Read every slice. Each chart must say what its description says, with no label on another, no empty chart, and the same category in the same color on every chart. + +Done when the build exits 0 and every slice in both themes passes that read. + +## 7. Hand it over + +`report.html` is one file with its styles, charts, and script inside, so it opens from disk, attaches to a message, or deploys as a static page. To share a link, publish it to a host such as [Drop](https://drop.vitehub.dev/?utm_source=comark-ui&utm_medium=skills) as HTML. A host that renders the Markdown itself draws the charts only if its comark-ui has the props the report uses. + +Hand over the HTML, the Markdown, and `analyze.py`, so the next run starts from the script rather than from the page. diff --git a/skills/data-report/agents/openai.yaml b/skills/data-report/agents/openai.yaml new file mode 100644 index 00000000..c0651225 --- /dev/null +++ b/skills/data-report/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Data report" + short_description: "Turn a dataset into an analytics page of charts with a sidebar" diff --git a/skills/data-report/references/blocks.md b/skills/data-report/references/blocks.md new file mode 100644 index 00000000..f6909e58 --- /dev/null +++ b/skills/data-report/references/blocks.md @@ -0,0 +1,56 @@ +# Blocks + +The syntax of each block a data report uses. Which chart answers which question is in [charts.md](charts.md). + +## Charts + +```md +::chart{type="line" unit="h" log title="Time to first reply" description="The median halved since May."} +| month | median | p90 | +| --- | --: | --: | +| 2026-05 | 2.79 | 11.5 | +| 2026-06 | 2.37 | 7.5 | +:: +``` + +The first column is x; every other column is a series, named by its header. Cells hold plain numbers, with commas between thousands if you like. Dates on x such as `2026`, `2026-05`, or `2026-05-04` sit on a time axis. + +| Prop | What it does | +| --- | --- | +| `type` | `bar` (default), `stacked`, `line`, `area`, `scatter`, `dumbbell`, `waterfall`, `pie`, `heatmap`, `gantt`, or `sankey`. | +| `title` | What is measured, per what. Also the chart's name for screen readers. | +| `description` | One sentence under the title: what to see. | +| `unit` | The numbers' unit: `$` prints before them, `%`, `h`, `ms` after. From a thousand up they print short, as `$22.2k`. Gantts and sankeys don't take it. | +| `log` | A log scale: alone for the numbers' axis, `log="x"` for the x column of a line or scatter over numbers, `log="both"`. Lines, scatter plots, and bar charts take it, with every number above 0; other types, dumbbells included, refuse it. | +| `better` | On a heatmap, `higher` or `lower`: cells run red, amber, green. | +| `horizontal` | Sideways bars, for long names or many rows. Written alone. Only `bar` and `stacked` take it. | +| `x`, `y` | The x column, and the series columns separated by commas, when they aren't the first and the rest. | +| `labels` | Bars, stacked bars, and heatmaps print their numbers; `labels=false` turns it off. On waterfalls, dumbbells, gantts, and sankeys, `labels` alone turns it on. | + +A line chart of two to eight series names each line at its end, in its color. Every other chart with several series gets a legend over the plot. + +## Headline strip + +```md +::stats{tiles} +| Figure | Value | +| --- | --- | +| Questions | 418 | +| Answerable today | 80% | +| Median first reply | 1.3 h | +:: +``` + +Two columns: a name and a value, any text. A third column between them is the value before, and the tile shows the change in a pill; add `better="lower"` when a drop is good. + +## Folded detail + +```md +::details{summary="How the numbers were computed"} +The export holds one row per customer message... +:: +``` + +## Links between sections + +Every `##` heading gets an id from its words, so `## Question types` is `#question-types`. Link a finding to its section with `[Question types](#question-types)`, or set the id with `## Question types {#types}`. diff --git a/skills/data-report/references/charts.md b/skills/data-report/references/charts.md new file mode 100644 index 00000000..083bb7a6 --- /dev/null +++ b/skills/data-report/references/charts.md @@ -0,0 +1,182 @@ +--- +title: Chart gallery +description: Which chart answers which question, in the house style +source: Made-up numbers, one table per chart +--- + +Every chart is a Markdown table inside `::chart`: the first column is x, every other column is a series, and the table stays under the chart in a **Data** fold. Each section below is one question shape, its chart, and the rules that keep it readable. + +## House style + +These hold for every chart on the page: + +- `title` says what is measured, per what, and in what unit when there's no `unit`: "Questions per customer", "Time to first reply (h)". +- `description` says what to see, in one sentence: "Northwind asks twice as many as anyone." +- `unit` goes on every chart whose numbers have one: `$` prints before the number, `%`, `h`, and `ms` after. Numbers from a thousand up print short, as `$22.2k`. +- One measure per chart. Two measures, such as questions and reply time, are two charts. +- At most eight series. Past that, keep the seven largest and add the rest up as `other`. +- Rows sorted by value, largest first, unless their order means something: time, steps, or a funnel. +- A category keeps its column position in every chart, so it keeps its color across the page. Series take their colors by column: blue, tan, teal, violet, rose, amber, then six more. Put a series that means trouble, such as failures, fifth, so it draws in rose. +- Bars and heatmaps print their numbers, and stacked bars print each stack's total past its end and each part inside it where it fits. Leave that on; `labels=false` is for charts so dense the numbers collide. + +## How many, per name + +Bars, sideways when the names are long or there are more than about six. Sort them. + +::chart{horizontal title="Questions per type" description="Data questions are a quarter of the total."} +| type | questions | +| --- | --: | +| Data missing | 88 | +| How it works | 63 | +| Forecast | 44 | +| Purchasing | 37 | +| Bug | 37 | +| Alerts | 30 | +:: + +## How each total splits + +Stacked bars, the parts in a fixed order, from the one the reader cares about first. + +::chart{type="stacked" horizontal title="Questions per customer, by the last source a reply needs" description="Northwind needs the team for one question in five."} +| customer | thread only | docs and code | product data | team only | +| --- | --: | --: | --: | --: | +| Northwind | 13 | 26 | 24 | 17 | +| Contoso | 19 | 18 | 16 | 11 | +| Fabrikam | 15 | 9 | 10 | 8 | +| Tailspin | 11 | 15 | 11 | 1 | +:: + +## Two groupings crossed + +A heatmap, rows and columns in the order the reader scans them. It reads best up to about 12 columns. + +::chart{type="heatmap" title="Questions per customer and type" description="Northwind's data questions are the largest cell."} +| customer | Data missing | How it works | Forecast | Bug | +| --- | --: | --: | --: | --: | +| Northwind | 21 | 9 | 10 | 16 | +| Contoso | 15 | 17 | 7 | 7 | +| Fabrikam | 11 | 7 | 4 | 2 | +| Tailspin | 7 | 11 | 3 | 5 | +:: + +## Scores or pass rates + +A heatmap with `better`, so red and green say good and bad without a legend. `better="lower"` for error rates and costs. + +::chart{type="heatmap" better="higher" title="Judge score per section, out of 10" description="Every agent struggles with validation."} +| agent | Score | Features | Data | Auth | Validation | Security | +| --- | --: | --: | --: | --: | --: | --: | +| Fable 5.2 high | 8.6 | 10 | 10 | 10 | 6.2 | 4.3 | +| Opus 5.5 high | 8.5 | 10 | 9 | 9.6 | 4.6 | 5 | +| GPT-6.1 Sol xhigh | 7.9 | 9.1 | 8.3 | 10 | 1.5 | 6.4 | +| GPT-6 Luna low | 4 | 1.9 | 7.6 | 6.7 | 0 | 2.1 | +:: + +## A trend over time + +A line, x as dates such as `2026-05` or `2026-05-04`. Two to eight lines are named at their ends; past that, split the chart. + +::chart{type="line" unit="h" log title="Time to first reply" description="The median halved since May; the slowest tenth still waits half a day."} +| month | median | p90 | +| --- | --: | --: | +| 2026-05 | 2.79 | 11.5 | +| 2026-06 | 2.37 | 7.5 | +| 2026-07 | 0.97 | 2.9 | +| 2026-08 | 1.46 | 6.7 | +| 2026-09 | 1.41 | 4.1 | +| 2026-10 | 0.72 | 3.8 | +:: + +## Volume over time, by part + +Stacked bars per month or week. Use bars rather than an area when there are fewer than about 20 periods. + +::chart{type="stacked" title="Questions per month, by type" description="August doubled May, mostly in data questions."} +| month | Data missing | How it works | other | +| --- | --: | --: | --: | +| 2026-05 | 7 | 2 | 12 | +| 2026-06 | 10 | 10 | 17 | +| 2026-07 | 14 | 10 | 30 | +| 2026-08 | 21 | 13 | 42 | +:: + +## One measure against another + +A scatter for points, or a line over numbers when each series is one thing at several settings, such as a model at each effort. Costs and token counts that run from cents to thousands go on `log`. + +::chart{type="line" log="x" unit="pts" title="Score against cost per task ($)" description="Each line is one model from low to max effort."} +| cost per task | Luna | Opus | Fable | +| --: | --: | --: | --: | +| 0.005 | 10 | | | +| 0.05 | 19 | | | +| 0.2 | 28 | | | +| 1 | | 35 | | +| 2 | | 48 | 55 | +| 10 | | 59 | 62 | +| 20 | | | 65 | +:: + +## Before and after + +Grouped bars for one to three metrics, a dumbbell past that. + +::chart{type="dumbbell" unit="ms" title="Page load before and after the cache" description="Every page got faster; search the most."} +| page | before | after | +| --- | --: | --: | +| home | 820 | 540 | +| search | 1450 | 610 | +| checkout | 980 | 720 | +| account | 640 | 510 | +:: + +## How spread out, per name + +A dumbbell from the median to the slowest tenth, as two columns. It reads both the typical case and the tail, which an average hides. + +::chart{type="dumbbell" unit="h" labels title="Time to merge, median to the slowest tenth" description="Fixes merge within hours; a tenth of features wait a day."} +| kind | median | p90 | +| --- | --: | --: | +| feature | 1.6 | 22 | +| fix | 0.6 | 4.3 | +| docs | 0.6 | 2.8 | +:: + +## Money across groups + +Grouped bars with `unit="$"`. Keep the scale linear even when the largest bar flattens the rest, since bars on a log scale don't compare by length; say the gap in the description instead. + +::chart{unit="$" title="Cost of a full benchmark run" description="High effort costs eight times low on the largest model."} +| model | low | medium | high | +| --- | --: | --: | --: | +| small | 9 | 40 | 63 | +| medium | 980 | 2,000 | 4,800 | +| large | 2,900 | 11,700 | 22,200 | +:: + +## How changes add up + +A waterfall: the start total, each change, then the end total, which must be the start plus every change. + +::chart{type="waterfall" title="Open tickets this quarter" description="More tickets closed than opened, so the queue fell to 73."} +| step | tickets | +| --- | --: | +| July | 140 | +| opened | 310 | +| closed | -365 | +| merged | -12 | +| October | 73 | +:: + +## Shares of one whole + +A pie only for five or fewer slices that add up to a whole. Otherwise use sideways bars. + +::chart{type="pie" title="Replies per person (%)" description="Ana writes almost half."} +| person | share | +| --- | --: | +| Ana | 46 | +| Ben | 25 | +| Chen | 21 | +| Dara | 8 | +:: diff --git a/skills/data-report/references/example.md b/skills/data-report/references/example.md new file mode 100644 index 00000000..4f2d1463 --- /dev/null +++ b/skills/data-report/references/example.md @@ -0,0 +1,143 @@ +--- +title: Support analytics +description: 418 answered questions from 297 threads, 13 Apr to 25 Oct 2026 +source: Helpdesk export, one row per customer question +period: 13 Apr to 25 Oct 2026 +rows: 418 questions, 297 threads +updated: 26 Oct 2026 +--- + +::stats{tiles} +| Figure | Value | +| --- | --- | +| Questions | 418 | +| Threads | 297 | +| Customers | 8 | +| Answerable today | 78% | +| With action drafts | 87% | +| Median first reply | 1.3 h | +| With a screenshot | 37% | +:: + +## What stands out + +1. **78% of questions** can be answered today from the thread, the docs and code, and product data. Action drafts lift that to 87%. See [Sources](#sources). +2. **Data missing** is the largest type, 103 questions (25%). See [Question types](#question-types). +3. **Contoso** asks 93 questions, and 9 of them need someone from the team. See [Customers](#customers). + +## Sources + +Each step adds one source an answer can draw on, from what the thread already holds to what only the team knows. What stays below 100% at the last step needs a person. + +::chart{type="line" unit="%" title="Share of questions answerable, adding one source at a time" description="All questions, and the two customers who ask the most."} +| step | all | Contoso | Northwind | +| --- | --: | --: | --: | +| thread only | 20 | 20 | 16 | +| + docs and code | 55 | 49 | 55 | +| + product data | 78 | 78 | 76 | +| + action draft | 87 | 90 | 87 | +:: + +## Customers + +::chart{type="stacked" horizontal title="Questions per customer, by the last source a reply needs" description="The first three steps are answerable today; team only needs a person."} +| customer | thread only | docs and code | product data | action draft | team only | +| --- | --: | --: | --: | --: | --: | +| Contoso | 19 | 27 | 27 | 11 | 9 | +| Northwind | 12 | 30 | 16 | 8 | 10 | +| Tailspin | 15 | 17 | 10 | 1 | 9 | +| Fabrikam | 8 | 16 | 13 | 4 | 7 | +| Wingtip | 10 | 15 | 9 | 1 | 8 | +| Litware | 8 | 15 | 5 | 6 | 3 | +| Adventure Works | 7 | 13 | 9 | 3 | 5 | +| Proseware | 4 | 13 | 7 | 4 | 4 | +:: + +::chart{type="heatmap" title="Questions per customer and type" description="Darker cells hold more questions."} +| customer | Data missing | How it works | Forecast | Purchasing | Bug | Alerts | +| --- | --: | --: | --: | --: | --: | --: | +| Contoso | 20 | 20 | 18 | 13 | 10 | 7 | +| Northwind | 20 | 19 | 8 | 10 | 8 | 1 | +| Tailspin | 11 | 10 | 9 | 7 | 4 | 3 | +| Fabrikam | 13 | 10 | 7 | 8 | 4 | 3 | +| Wingtip | 10 | 13 | 5 | 4 | 1 | 3 | +| Litware | 10 | 6 | 1 | 3 | 6 | 8 | +| Adventure Works | 10 | 4 | 4 | 5 | 4 | 5 | +| Proseware | 9 | 6 | 5 | 3 | 3 | 4 | +:: + +## Question types + +::chart{horizontal title="Questions per type" description="Sorted by volume."} +| type | questions | +| --- | --: | +| Data missing | 103 | +| How it works | 88 | +| Forecast | 57 | +| Purchasing | 53 | +| Bug | 40 | +| Alerts | 34 | +| Feature request | 27 | +| Setup | 16 | +:: + +::chart{type="heatmap" better="higher" unit="%" title="Share answerable today, per type" description="Setup, feature requests, and bugs need the team most often."} +| type | answerable today | +| --- | --: | +| Data missing | 80 | +| How it works | 86 | +| Forecast | 77 | +| Purchasing | 75 | +| Bug | 65 | +| Alerts | 88 | +| Feature request | 63 | +| Setup | 62 | +:: + +## Over time + +::chart{type="stacked" title="Questions per month, by type" description="The four largest types, and the rest together."} +| month | Data missing | How it works | Forecast | Purchasing | other | +| --- | --: | --: | --: | --: | --: | +| 2026-05 | 9 | 13 | 4 | 6 | 11 | +| 2026-06 | 20 | 13 | 6 | 6 | 13 | +| 2026-07 | 17 | 6 | 11 | 10 | 13 | +| 2026-08 | 19 | 15 | 17 | 10 | 29 | +| 2026-09 | 18 | 15 | 7 | 9 | 25 | +| 2026-10 | 18 | 20 | 10 | 10 | 18 | +:: + +::chart{type="line" log unit="h" title="Time to first reply" description="Median and 90th percentile per month, on a log scale."} +| month | median | p90 | +| --- | --: | --: | +| 2026-05 | 2.04 | 15.7 | +| 2026-06 | 1.23 | 8.8 | +| 2026-07 | 1.04 | 6.0 | +| 2026-08 | 1.33 | 8.4 | +| 2026-09 | 1.01 | 4.8 | +| 2026-10 | 1.02 | 3.1 | +:: + +## Team + +::chart{type="heatmap" title="Replies per person and type" description="Ana answers most of every type."} +| person | Data missing | How it works | Forecast | Purchasing | Bug | Alerts | +| --- | --: | --: | --: | --: | --: | --: | +| Ana | 52 | 42 | 25 | 26 | 24 | 17 | +| Ben | 26 | 19 | 13 | 15 | 9 | 10 | +| Chen | 16 | 20 | 12 | 9 | 4 | 6 | +| Dara | 9 | 7 | 7 | 3 | 3 | 1 | +:: + +| Person | Replies | Median first reply | +| --- | --: | --: | +| Ana | 215 | 1.4 h | +| Ben | 101 | 1.1 h | +| Chen | 71 | 1.1 h | +| Dara | 31 | 1.2 h | + +## Method + +::details{summary="How the numbers were computed"} +The export holds one row per customer message. A question is a message that asks for something; thanks and scheduling are left out. Each question was classified by its type and by the last source a sendable reply needed, in the order the line chart adds them. First reply is the time from the customer's first message to the team's first reply, in calendar hours. Every number on this page comes from one script over the export, `analyze.py`, and nothing was typed by hand. +:: diff --git a/skills/data-report/scripts/build.mjs b/skills/data-report/scripts/build.mjs new file mode 100644 index 00000000..e02ef743 --- /dev/null +++ b/skills/data-report/scripts/build.mjs @@ -0,0 +1,214 @@ +// Builds a data report's Markdown into one self-contained HTML page: a top bar with the title and a link per +// section, the report, and a sidebar with the page's outline and where the data comes from. Light and dark follow +// the reader's system, with a toggle. With --shots it also screenshots the page in both themes, in slices. +// +// Usage: node build.mjs report.md [report.html] [--shots outDir] [--force] +// Run it from a folder where comark, @comark/html, and comark-ui are installed, plus playwright for --shots. +import { createRequire } from "node:module"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const args = process.argv.slice(2); +const flag = (name) => { + const index = args.indexOf(name); + if (index === -1) return undefined; + const [, value] = args.splice(index, 2); + return value ?? ""; +}; +const shots = flag("--shots"); +const force = args.includes("--force"); +const [input, output = input?.replace(/\.md$/, ".html")] = args.filter((arg) => arg !== "--force"); +if (!input) + throw new Error("Usage: node build.mjs report.md [report.html] [--shots outDir] [--force]"); + +// Packages resolve from this folder, then from the report's. +const load = (name) => { + for (const dir of new Set([process.cwd(), dirname(resolve(input))])) { + try { + return import(pathToFileURL(createRequire(join(dir, "noop.js")).resolve(name)).href); + } catch {} + } + console.error( + `Can't find ${name} from this folder or the report's. Install it here: pnpm add ${name}`, + ); + process.exit(1); +}; +const { parseMarkdown } = await load("comark"); +const { renderHtmlFromDocument } = await load("@comark/html"); +const { default: comarkUi } = await load("comark-ui/html"); + +const { plugins, components } = comarkUi(); +const document = await parseMarkdown(readFileSync(input, "utf8"), { plugins }); +const issues = document.meta.issues ?? []; +if (issues.length) { + console.error(issues.map((issue) => `- ${issue}`).join("\n")); + if (!force) { + console.error( + `\n${issues.length} issue(s) in ${input}. Fix them, or pass --force to build anyway.`, + ); + process.exit(1); + } +} + +const escape = (text) => + String(text ?? "").replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); +const textOf = (node) => + typeof node === "string" ? node : Array.isArray(node) ? node.slice(2).map(textOf).join("") : ""; +const outline = document.nodes + .filter((node) => Array.isArray(node) && (node[0] === "h2" || node[0] === "h3") && node[1]?.id) + .map((node) => ({ level: node[0], id: node[1].id, text: textOf(node).trim() })); +const front = document.frontmatter ?? {}; +const title = front.title ?? basename(input, ".md"); +// Every frontmatter field besides these describes the data, and the sidebar lists it as written. +const about = Object.entries(front).filter( + ([key, value]) => !["title", "description"].includes(key) && typeof value !== "object", +); +const body = await renderHtmlFromDocument(document, { components }); + +const css = ` +:root{color-scheme:light dark;--dr-side:17rem;--dr-top:3.5rem} +html.light,html:not(.dark){--comark-background:#ffffff;--comark-foreground:#0b0b0d;--comark-muted:#6b6b74;--comark-border:#e6e6ea;--comark-accent:#f3f3f5;--dr-hover:#f5f5f7} +html.dark{--comark-background:#0a0a0b;--comark-foreground:#ececef;--comark-muted:#8b8b94;--comark-border:#232327;--comark-accent:#18181b;--dr-hover:#141416} +@media (prefers-color-scheme:dark){html:not(.light){--comark-background:#0a0a0b;--comark-foreground:#ececef;--comark-muted:#8b8b94;--comark-border:#232327;--comark-accent:#18181b;--dr-hover:#141416}} +:root{--comark-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;--comark-radius:.625rem;--comark-grain:none} +*{box-sizing:border-box} +body{margin:0;background:var(--comark-background);color:var(--comark-foreground);font:14px/1.6 var(--comark-font);font-variant-numeric:tabular-nums;-webkit-font-smoothing:antialiased} +a{color:inherit} +.dr-top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:1.5rem;height:var(--dr-top);padding:0 1.5rem;border-bottom:1px solid var(--comark-border);background:var(--comark-background)} +.dr-brand{display:flex;flex-direction:column;min-width:0;line-height:1.25} +.dr-brand b{font-weight:600;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dr-brand span{color:var(--comark-muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dr-nav{display:flex;flex:1;gap:1.25rem;align-self:stretch;min-width:0;padding-left:1.5rem;border-left:1px solid var(--comark-border);overflow-x:auto;scrollbar-width:none} +.dr-nav a{display:flex;align-items:center;border-bottom:1px solid transparent;color:var(--comark-muted);font-size:13px;text-decoration:none;white-space:nowrap} +.dr-nav a:hover,.dr-nav a[aria-current]{color:var(--comark-foreground)} +.dr-nav a[aria-current]{border-bottom-color:var(--comark-foreground)} +.dr-theme{flex:none;height:2rem;padding:0 .75rem;border:1px solid var(--comark-border);border-radius:.5rem;background:none;color:var(--comark-muted);font:inherit;font-size:12.5px;cursor:pointer} +.dr-theme:hover{color:var(--comark-foreground);background:var(--dr-hover)} +.dr-layout{display:grid;grid-template-columns:minmax(0,1fr) var(--dr-side);min-height:calc(100vh - var(--dr-top))} +.dr-main{min-width:0;padding:1.75rem 2rem 4rem} +.dr-main>.dr-body{max-width:52rem} +.dr-body :is(h2,h3)[id]{scroll-margin-top:calc(var(--dr-top) + 1rem)} +.dr-side{position:sticky;top:var(--dr-top);height:calc(100vh - var(--dr-top));overflow-y:auto;padding:1.75rem 1.25rem;border-left:1px solid var(--comark-border);font-size:13px} +.dr-side h2{margin:0 0 .5rem;color:var(--comark-foreground);font-size:13px;font-weight:600} +.dr-side ol{margin:0 0 1.75rem;padding:0;list-style:none} +.dr-side li a{display:block;padding:.2rem 0 .2rem .75rem;border-left:1px solid var(--comark-border);color:var(--comark-muted);text-decoration:none} +.dr-side li.dr-h3 a{padding-left:1.5rem;font-size:12.5px} +.dr-side li a:hover{color:var(--comark-foreground)} +.dr-side li a[aria-current]{border-left-color:var(--comark-foreground);color:var(--comark-foreground)} +.dr-side dl{display:grid;gap:.6rem;margin:0} +.dr-side dt{color:var(--comark-muted);font-size:12px} +.dr-side dd{margin:0;overflow-wrap:anywhere} +.dr-foot{max-width:52rem;margin-top:3rem;padding-top:1rem;border-top:1px solid var(--comark-border);color:var(--comark-muted);font-size:12px} +@media (max-width:1100px){.dr-layout{grid-template-columns:1fr}.dr-side{position:static;height:auto;border-left:0;border-top:1px solid var(--comark-border)}.dr-brand span{display:none}} +@media (max-width:640px){.dr-nav{display:none}.dr-main{padding:1.25rem 1rem 3rem}} +@media print{.dr-top,.dr-side{display:none}.dr-layout{display:block}} +`; + +// Picks a theme the reader chose before, follows the system otherwise, and marks the section in view in the top +// bar and the outline. +const script = `(() => { + const root = document.documentElement; + const saved = localStorage.getItem("dr-theme"); + if (saved) root.classList.add(saved); + document.querySelector(".dr-theme").addEventListener("click", () => { + const dark = root.classList.contains("dark") || (!root.classList.contains("light") && matchMedia("(prefers-color-scheme: dark)").matches); + root.classList.remove("dark", "light"); + root.classList.add(dark ? "light" : "dark"); + localStorage.setItem("dr-theme", dark ? "light" : "dark"); + }); + const links = [...document.querySelectorAll(".dr-nav a, .dr-side ol a")]; + const headings = [...document.querySelectorAll(".dr-side ol a")] + .map((link) => document.getElementById(link.getAttribute("href").slice(1))) + .filter(Boolean); + const mark = () => { + let current = headings[0]; + for (const heading of headings) if (heading.getBoundingClientRect().top < 120) current = heading; + const section = current && (current.tagName === "H2" ? current : headings.slice(0, headings.indexOf(current)).reverse().find((h) => h.tagName === "H2")); + for (const link of links) { + const id = link.getAttribute("href").slice(1); + const on = link.closest(".dr-nav") ? id === section?.id : id === current?.id; + link.toggleAttribute("aria-current", on); + } + }; + addEventListener("scroll", mark, { passive: true }); + mark(); +})();`; + +const nav = outline.filter((item) => item.level === "h2"); +const html = ` + + + + +${escape(title)} +${front.description ? `` : ""} + + + +
+
${escape(title)}${front.description ? `${escape(front.description)}` : ""}
+ + +
+
+
+
${body}
+ ${front.source ? `
Data: ${escape(front.source)}${front.updated ? `, as of ${escape(front.updated)}` : ""}.
` : ""} +
+ +
+ + + +`; +writeFileSync(output, html); +console.log(resolve(output)); + +if (shots !== undefined) { + const outDir = shots || "shots"; + mkdirSync(outDir, { recursive: true }); + const playwright = await load("playwright"); + const { chromium } = playwright.chromium ? playwright : playwright.default; + // Playwright's own browser may be missing; fall back to the newest one in its cache. + const cache = join(homedir(), ".cache/ms-playwright"); + const fallback = existsSync(cache) + ? readdirSync(cache) + .filter((dir) => /^chromium-\d+$/.test(dir)) + .sort((a, b) => Number(b.slice(9)) - Number(a.slice(9))) + .map((dir) => join(cache, dir, "chrome-linux64/chrome")) + .find((path) => existsSync(path)) + : undefined; + const browser = await chromium + .launch() + .catch(() => chromium.launch({ executablePath: fallback })); + const name = basename(output, ".html"); + for (const theme of ["light", "dark"]) { + const page = await browser.newPage({ + viewport: { width: 1440, height: 900 }, + colorScheme: theme, + }); + await page.goto(pathToFileURL(resolve(output)).href); + await page.waitForTimeout(800); + // A whole report is several thousand pixels tall, and an image that tall is read shrunk past its labels, so the + // page is saved in slices of about two screens. + const height = await page.evaluate(() => document.documentElement.scrollHeight); + for (let y = 0, part = 1; y < height; y += 1400, part++) { + const path = resolve(outDir, `${name}-${theme}-${part}.png`); + await page.screenshot({ + path, + fullPage: true, + clip: { x: 0, y, width: 1440, height: Math.min(1400, height - y) }, + }); + console.log(path); + } + } + await browser.close(); +}