@bicharts/chart-mcp 0.6.111 → 0.6.112
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.
- package/dist/check-chart.mjs +105 -105
- package/dist/index.mjs +124 -112
- package/package.json +3 -3
- package/skills/bic-charts/SKILL.md +193 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bicharts/chart-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.112",
|
|
4
4
|
"description": "AI Data Components for Power BI, Excel, Fabric Apps and the web, over MCP: bivariate maps, what-if projections and about 90 everyday chart types, with selection and cross-filtering already wired. It profiles your table locally; the code it writes is yours and runs with no license.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"// dependencies": "NONE, deliberately. dist/ bundles every module the server uses, so a runtime dependency installs nothing it needs - it only makes npx fetch that package (and its tree) before the first start. Everything the bundle carries is a devDependency; scripts/verify-package.mjs fails the release if a dependency appears or a shipped file imports anything but Node's built-ins.",
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@bicharts/chart-host": "^0.6.
|
|
51
|
-
"@bicharts/shape-core": "^0.6.
|
|
50
|
+
"@bicharts/chart-host": "^0.6.112",
|
|
51
|
+
"@bicharts/shape-core": "^0.6.112",
|
|
52
52
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
53
53
|
"@types/node": "^26.6.5",
|
|
54
54
|
"@types/papaparse": "^5.3.14",
|
|
@@ -215,6 +215,20 @@ the next `generate_chart`, or, for a live setting, an option in the emitted comp
|
|
|
215
215
|
unattended run), ask nothing, use the defaults, and tell the user which settings were assumed from
|
|
216
216
|
`settings.applied`. `get_guide` with topic `settings` lists every row with its options.
|
|
217
217
|
|
|
218
|
+
**Which direction is good: ask it in that same question, when a chart will judge a measure.** A chart that compares,
|
|
219
|
+
ranks or colors a measure as good or bad (a variance against a target, a KPI rank, ahead or behind, a diverging color)
|
|
220
|
+
needs to know whether more of it is better, and the data can't say: a rising cost and a rising bookings figure move the
|
|
221
|
+
same way. So when the page has such a chart, the one question before the first `generate_chart` also asks, for each
|
|
222
|
+
measure it judges that the person hasn't already given a direction for, "is a higher <measure> better, or a lower
|
|
223
|
+
one?". Pass each answer in `generate_chart`'s `direction_of_good`, keyed by the measure's column name as sent:
|
|
224
|
+
`direction_of_good: { "Bookings": "higher", "Cost": "lower" }`. On a server whose `generate_chart` has no
|
|
225
|
+
`direction_of_good`, say it in that chart's `prompt` instead: `higher <measure> is better` or `lower <measure> is
|
|
226
|
+
better`. When you can't ask (a sub-agent, an unattended run) or the person said to use the
|
|
227
|
+
defaults, pass nothing: the engine infers a direction for a measure it can place (an actual against its own target:
|
|
228
|
+
higher is better; an expense, spend or opex: lower is better) and leaves the rest neutral; say in your final message which measures it was left to infer. A
|
|
229
|
+
measure the person already named a direction for is never asked again. `check_app` flags a chart generated with no
|
|
230
|
+
direction of good where it judges one.
|
|
231
|
+
|
|
218
232
|
**Your final message keeps what the engine chose apart from what falls short.** Under what the engine chose, list its
|
|
219
233
|
choices (the `settings.applied` rows it picked - `default`, `server-roll` - and `engineChoices`) and each chart's limits
|
|
220
234
|
(a width it won't draw below, so the layout stacks it; a row cap; a type a count ruled out). Those are choices and
|
|
@@ -568,6 +582,21 @@ overwrites a file, and its reply ends with a worked example. Building notes on m
|
|
|
568
582
|
data service is on (the notes recipe below, step a), run it again with `features: ["notes", "scenarios"]`: it writes
|
|
569
583
|
those files finished.
|
|
570
584
|
|
|
585
|
+
**Every page works in light and dark, without being asked.** `setup_fabric_app` writes `src/lib/bic-theme.ts`, the
|
|
586
|
+
app's light/dark state: it follows Fabric's `data-appearance`, else the OS, until the reader picks, and the pick holds
|
|
587
|
+
for the session. It points the template's `src/main.tsx` at it in place of the template's own hook, which watches its own
|
|
588
|
+
class change and, under a host that sets `data-appearance`, flips a reader's pick straight back. It also writes
|
|
589
|
+
`src/components/bic-theme-toggle.tsx`: put `<BicThemeToggle />` in the app's header, on every page, whether or not the
|
|
590
|
+
person asked for one. Then build every page for both themes:
|
|
591
|
+
- Colors come from the template's tokens (`bg-card`, `bg-background`, `text-foreground`, `text-muted-foreground`,
|
|
592
|
+
`border-border`, `text-destructive`), never a literal (`#fff`, `bg-white`, `text-gray-700`, `style={{ color: "#333" }}`):
|
|
593
|
+
a literal stays the same color in both themes, so white text lands on a white card.
|
|
594
|
+
- Charts follow on their own: `useBicChartOptions()` passes the theme (the background as both `backgroundColor` and
|
|
595
|
+
`themeBg`), and the charts redraw when the toggle flips it. Pass it to every chart; never a color of your own.
|
|
596
|
+
- A dialog's scrim is `bg-black/40`, which dims the page in both themes; `bg-foreground/40` washes it white in dark mode.
|
|
597
|
+
- Look at every page in both themes before you call it done.
|
|
598
|
+
`check_app` flags a page with no toggle, the template's hook, a literal color and a scrim that flips with the theme.
|
|
599
|
+
|
|
571
600
|
**The model's name is the `connection`.** Connect the semantic model the way the template's AGENTS.md says - before or
|
|
572
601
|
after `setup_fabric_app`, whose reply names it either way. On the template's current layout the model is a Rayfin
|
|
573
602
|
connector: `npx rayfin connector search "<model name>" --all-workspaces --type fabric-semanticmodel --json`, then the
|
|
@@ -601,6 +630,42 @@ is:
|
|
|
601
630
|
blank), `check_app` flags them in the app's `.dax` files, and `check_chart` warns when one value sets a diverging color
|
|
602
631
|
scale's span.
|
|
603
632
|
|
|
633
|
+
**A chart's categories come from the data.** Group by the category column and let the chart take every value the
|
|
634
|
+
model holds; never name the categories in the DAX one filter at a time (`CALCULATE([Sales], 'Channel'[Channel] =
|
|
635
|
+
"Online")`, then "Retail", then "Partner"). A value the model adds or renames would drop out silently, and a chart that
|
|
636
|
+
divides by their sum would re-scale every mark to the ones named. `check_app` flags a query that names two or more
|
|
637
|
+
values of one column that way.
|
|
638
|
+
|
|
639
|
+
**A shown total is the sum of the parts shown.** When a card shows a total and another panel shows its parts (a goal,
|
|
640
|
+
and each owner's goal in a table), compute the total from those parts, so the reader can add them up and get it:
|
|
641
|
+
|
|
642
|
+
```dax
|
|
643
|
+
"Goal", SUMX ( VALUES ( 'Owner'[Owner] ), [Goal] ) -- the sum of the owners' goals the table shows
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
A measure evaluated once at the total isn't the sum of its parts when it rounds (MROUND, ROUND), clamps or takes a
|
|
647
|
+
maximum each time it's evaluated, and a ratio over it (an attainment) then disagrees with the table's. Compute that ratio
|
|
648
|
+
from the summed total too. `check_app` flags a total panel whose measure rounds while another panel shows it by part
|
|
649
|
+
(read from the project's schema cache).
|
|
650
|
+
|
|
651
|
+
**A query reads the window its card names.** A card that says "2019 to 2025", "to December 2025" or "in 2025" gets a
|
|
652
|
+
query bounded to that window, with a filter on the model's date column inside SUMMARIZECOLUMNS:
|
|
653
|
+
|
|
654
|
+
```dax
|
|
655
|
+
EVALUATE
|
|
656
|
+
SUMMARIZECOLUMNS (
|
|
657
|
+
'Date'[Month],
|
|
658
|
+
DATESBETWEEN ( 'Date'[Date], DATE ( 2019, 1, 1 ), DATE ( 2025, 12, 31 ) ), -- "2019 to 2025"
|
|
659
|
+
"Bookings", [Bookings]
|
|
660
|
+
)
|
|
661
|
+
-- one year: TREATAS ( { 2025 }, 'Date'[Year] )
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Without it the chart reads whatever the model holds - a month after the stated end, a year before its start - while the
|
|
665
|
+
card's words stay as written. Write the card's words and the query's bound together, from the same dates; for a
|
|
666
|
+
projection, the window is the history it starts from, and its horizon is the chart's own control. `check_app` warns on a
|
|
667
|
+
card that names a window over a query with no bound on it.
|
|
668
|
+
|
|
604
669
|
**2. Profile and generate from THE WHOLE RESULT of that query - every row, never a few pasted in.**
|
|
605
670
|
Eligibility reads row, region and category counts. A bivariate country map needs at least 24
|
|
606
671
|
countries, so the same 40-country query shown as 8 sample rows hides it, and nothing fails - you are
|
|
@@ -756,10 +821,17 @@ function CountryMap({ table, onPick }) { // table = the live query resu
|
|
|
756
821
|
|
|
757
822
|
**Layout: a chart is never clipped or squashed.** Charts sit in cards, and a card is where they get cut off.
|
|
758
823
|
- Render every chart inside `BicChartPanel`. It sizes the chart from its cell, never from the chart.
|
|
759
|
-
- Give each chart its minimum: `minChartWidth` and `minChartHeight` (a world map about
|
|
824
|
+
- Give each chart its minimum: `minChartWidth` and `minChartHeight` (a world map wants about 2:1; a time series wants
|
|
760
825
|
height). Below that the panel scrolls; it never squashes or clips the chart. That scroll is a safety net, not a layout:
|
|
761
826
|
inside a page that already scrolls it's a second scrollbar, which reads as broken - size every chart's cell at or above
|
|
762
827
|
its minimum so it never shows.
|
|
828
|
+
- **A phone needs no sideways scroll.** A phone is about 360px wide, and the page's and the card's padding leave a
|
|
829
|
+
chart's box 294 of it. `BicChartPanel` draws a chart at its box's width, so set no `minChartWidth` unless the chart
|
|
830
|
+
needs more, and never one above 294; make the chart fit that width instead: check it with `check_chart` (it checks at
|
|
831
|
+
294 when no width is named), and if it doesn't fit, generate it again with `generate_chart` at `width: 294` (the
|
|
832
|
+
engine's small-screen rules then apply). A minimum wider than the phone's box draws the chart wider than its card,
|
|
833
|
+
so the card scrolls sideways and anything the chart keeps in place (a table's name column) scrolls away with it.
|
|
834
|
+
`check_app` warns on a `minChartWidth` above 294.
|
|
763
835
|
- Take the actual size from the layout - percent, flex or grid `fr` - and never a fixed height smaller than the chart's
|
|
764
836
|
minimum.
|
|
765
837
|
- Put `min-h-0` on every flex child between a card and its chart (`min-w-0` in a row). A flex item's default
|
|
@@ -843,6 +915,21 @@ What the library does, so the page doesn't:
|
|
|
843
915
|
- `country.value`, `country.values`, `country.keys`, `country.row` (the clicked row), `country.active` and
|
|
844
916
|
`country.text` ("Japan", or "Spain → Portugal" for a route) are what the page reads. For any OTHER key - a badge's,
|
|
845
917
|
a lane's end - `country.textOf(key, rows)` names it from a table that carries the display column.
|
|
918
|
+
- A pick can be a group: a legend entry, a subtotal row, a lasso or a Ctrl-click picks several keys through the same
|
|
919
|
+
filter, and `country.value` is its FIRST key only. Anything that names or acts on the pick (a panel heading, an "Add
|
|
920
|
+
an item" button) reads `country.keys`: with one, name it (`country.text`); with more, say how many ("3 countries")
|
|
921
|
+
and list them, and offer a one-mark action only while `country.keys.length === 1`. When several filters share a key
|
|
922
|
+
(one per clicked chart), read every one of them. `check_app` flags a `.value` read.
|
|
923
|
+
- **A label picks its own field.** On a chart of region by stage with a filter keyed `["Region", "PipelineStep"]`, a
|
|
924
|
+
click on a region's axis label holds that region in ANY stage, not the stage tuples drawn at that moment: the key is
|
|
925
|
+
`["EMEA", ANY_VALUE]` (`isAnyValue` tells a free column; both from `@bicharts/chart-host/react` or `/page`), the chip
|
|
926
|
+
reads "EMEA", and `treatAs(filter, "Accounts[Region]", "Deals[Stage]")` filters by the region alone. Never build tuple
|
|
927
|
+
keys by hand or `set()` the drawn combinations: a stage that comes back when another filter clears is then left out.
|
|
928
|
+
- **Key a click filter at the grain of the marks that set it.** A chart with one mark per product in each region
|
|
929
|
+
sets `useBicFilter(["Product", "Region"])`, never `useBicFilter("Product")`: keyed by product alone, one picked mark
|
|
930
|
+
picks that product in every region, the chart lights more marks than the reader picked (a lasso's group comes back
|
|
931
|
+
wider than it was drawn), and the page filters a wider group. A column from the key's own table (a product's
|
|
932
|
+
category) is an attribute, not a finer grain. `check_app` flags a filter keyed coarser than its chart's marks.
|
|
846
933
|
- Another chart whose table carries the key: `filterBy={{ filter: country, columns: [...] }}` on its component - any
|
|
847
934
|
of several columns, for a route that starts or ends there. It's applied inside the component, so the chart redraws
|
|
848
935
|
only when the filter or its data changes. A table WITHOUT the key column can't be filtered that way (it warns): filter
|
|
@@ -852,6 +939,21 @@ What the library does, so the page doesn't:
|
|
|
852
939
|
It adds a filter value and never changes the query's structure, so one `.dax` file serves both. A route filter maps
|
|
853
940
|
each end: `treatAs(route, "Routes[Origin]", "Routes[Destination]")`; `treatAs(route, "Geography[CountryIso3]")` takes
|
|
854
941
|
the route's FIRST column (its origin). Two filters on one column intersect: pass the one that should win.
|
|
942
|
+
- **A pick narrows only the measures it can mean.** A filter passed through `filterQuery` narrows EVERY measure in that
|
|
943
|
+
query, so decide per measure what the pick means. A stage pick narrows the open measures (the value in a stage, the
|
|
944
|
+
deals in it), not the closed ones (won value, the goal, attainment, the close rate): a won deal's stage isn't the
|
|
945
|
+
reader's question. Keep those out of the pick's reach - their own query that doesn't take it, or
|
|
946
|
+
`CALCULATE([Won Value], REMOVEFILTERS('Deals'[Stage]))` in the query - and say on the card which figures follow the
|
|
947
|
+
pick. Pass a pick only to panels it can reach, too: a model's relationships filter one way, so a deal's stage can't
|
|
948
|
+
narrow a table that relates only to accounts, and a `scopedBy` naming it there tells the chart something false.
|
|
949
|
+
`check_app` reads the model's relationships from the project's schema cache (`.schema/models/<model>/model.sqlite`)
|
|
950
|
+
and flags a `treatAs` column that can't reach a panel's query, and a `scopedBy` that claims it; with no cache it says
|
|
951
|
+
the check was skipped. A pick reaches a measure through the tables its expression reads, not the table it's kept on:
|
|
952
|
+
a goal kept on an owners table but computed from the deals follows a region pick on accounts (Accounts filters the
|
|
953
|
+
deals), while one computed from the owners table alone stays the whole company's beside revenue that narrows, and the
|
|
954
|
+
attainment over the two reads low. Carry the pick to it - `CALCULATE ( [Goal], TREATAS ( VALUES ( 'Accounts'[Region]
|
|
955
|
+
), 'Owners'[Region] ) )` - or say on the card that it ignores that pick. `check_app` flags a measure a pick can't
|
|
956
|
+
reach beside figures it narrows.
|
|
855
957
|
|
|
856
958
|
A Microsoft `VegaVisual` or `DataGrid` on the same page joins the same filter:
|
|
857
959
|
`onInteraction={e => fromVegaInteraction(country, e)}` (`fromVegaInteraction` from `@bicharts/chart-host/react`; a page without React takes all of these from `@bicharts/chart-host/page`). It matches
|
|
@@ -921,7 +1023,9 @@ never rewrites them:
|
|
|
921
1023
|
position: `chart`, `markKey`, `markKeyTo` (a route's other end), `body`, `author`, `authorId`, `createdAt`. Every
|
|
922
1024
|
entity it writes has an explicit permission decorator and a `max` on every `@text` (MSSQL gets `NVARCHAR(MAX)` without
|
|
923
1025
|
one, which can break the deployed GraphQL schema). Its create permission carries a policy,
|
|
924
|
-
`claims.sub.eq(item.authorId)`: the server takes a note only under the caller's own
|
|
1026
|
+
`claims.sub.eq(item.authorId).and(claims.email.eq(item.author))`: the server takes a note only under the caller's own
|
|
1027
|
+
id and email, and the `addMarkNote` function checks both against the caller's token first, so a note can't be filed
|
|
1028
|
+
under someone else's name.
|
|
925
1029
|
- `src/lib/bic-notes.ts`: `useMarkNotes(author)` - every note (paged with `.executePaginated()`: one query returns at
|
|
926
1030
|
most 100 and doesn't say there are more), an `error` when they can't load, and `add(chart, key, body)`, which saves
|
|
927
1031
|
through the `addMarkNote` Rayfin function and reloads, and REJECTS when the save fails. The author is the signed-in
|
|
@@ -965,6 +1069,15 @@ Open a mark's notes from `onAnnotationClick` (`badgeKey(a)` is its key), and its
|
|
|
965
1069
|
note" action on the selected mark - `country.keys[0]` is its key, `country.text` its name. Don't read `data-row-idx`
|
|
966
1070
|
or bounding boxes to place anything: that breaks on the next redraw, and this does it for you.
|
|
967
1071
|
|
|
1072
|
+
**A chart that carries write markers draws every row a marker can belong to.** A badge lands only on a drawn mark, so
|
|
1073
|
+
a map whose query keeps only the accounts with open pipeline (`FILTER ( SUMMARIZECOLUMNS ( ... ), NOT ISBLANK ( [Open
|
|
1074
|
+
Pipeline] ) )`) never shows a flag on an account whose deals are all won or lost, though the flag form offered it. Drop
|
|
1075
|
+
the row filter: the chart draws a row with a blank size as a hollow ring (say on the card what a ring means). An
|
|
1076
|
+
account with no fact rows at all is dropped by `SUMMARIZECOLUMNS` too, which drops a row whose measures are all blank, so
|
|
1077
|
+
when the form offers every account, list them with `ADDCOLUMNS ( SUMMARIZE ( 'Accounts', 'Accounts'[Account Number],
|
|
1078
|
+
... ), "Open Pipeline", [Revenue Open] )`. `check_app` flags a marker-hosting chart whose query drops rows the form can
|
|
1079
|
+
target.
|
|
1080
|
+
|
|
968
1081
|
The files use the template's own tokens; restyle them as you like. The table and the permissions are created on
|
|
969
1082
|
the next `npx rayfin up`, and every later `rayfin up` applies entity changes.
|
|
970
1083
|
|
|
@@ -974,6 +1087,10 @@ files do:
|
|
|
974
1087
|
entity's create permission a policy that checks it - `@role<Item>('authenticated', 'create', { policy: (claims, item) =>
|
|
975
1088
|
claims.sub.eq(item.createdById) })` - so the server refuses a row filed under anyone else. With no one signed in,
|
|
976
1089
|
refuse the save and say why (the form keeps what was typed); never store "unknown".
|
|
1090
|
+
- **The name shown comes from the caller's token too**, never only from the browser: the name is the signed-in email,
|
|
1091
|
+
and the policy checks it with the id - `claims.sub.eq(item.createdById).and(claims.email.eq(item.createdBy))` - so a
|
|
1092
|
+
hand-made call with your own id and someone else's name is refused. (`claims.email` is the token's email claim; a
|
|
1093
|
+
Rayfin function sees the same token as `ctx.accessToken`.)
|
|
977
1094
|
- **When** is the server's clock, never the browser's: every write that records a time (an item created, an item
|
|
978
1095
|
marked done) goes through a Rayfin function that stamps it on the server. Rayfin's data layer (checked on 1.36.2) has
|
|
979
1096
|
no server-time default for a field (a `default` is a fixed value), so the browser never sends the time at all. The
|
|
@@ -988,11 +1105,11 @@ files do:
|
|
|
988
1105
|
// rayfin/data/TaskDone.ts
|
|
989
1106
|
@entity()
|
|
990
1107
|
@authenticated('read')
|
|
991
|
-
@role<TaskDone>('authenticated', 'create', { policy: (claims, item) => claims.sub.eq(item.doneById) })
|
|
1108
|
+
@role<TaskDone>('authenticated', 'create', { policy: (claims, item) => claims.sub.eq(item.doneById).and(claims.email.eq(item.doneBy)) })
|
|
992
1109
|
export class TaskDone {
|
|
993
1110
|
@uuid() id!: string;
|
|
994
1111
|
@uuid({ unique: true }) taskId!: string; // one per task: a second "done" fails, so the first completer stays
|
|
995
|
-
@text({ max: 320 }) doneBy!: string; // the name shown
|
|
1112
|
+
@text({ max: 320 }) doneBy!: string; // the name shown: the session's email, checked against the token's
|
|
996
1113
|
@text({ max: 128 }) doneById!: string; // session.user.id, checked against the caller by the policy
|
|
997
1114
|
@date() doneAt!: Date; // the server's clock: set only in markTaskDone below
|
|
998
1115
|
}
|
|
@@ -1012,15 +1129,84 @@ await getRayfinClient().functions.markTaskDone.invoke({ taskId: task.id, doneBy:
|
|
|
1012
1129
|
|
|
1013
1130
|
**What this can't stop yet.** A Rayfin function writes with the same permissions as the browser, and Rayfin has no
|
|
1014
1131
|
server-time default for a field and no permission that only a function holds. So the app's own saves always carry the
|
|
1015
|
-
server's time, but a request someone writes by hand against the data API can still set its own.
|
|
1132
|
+
server's time, but a request someone writes by hand against the data API can still set its own. Who is not in that
|
|
1133
|
+
gap: the policy checks the name and the id against the caller's token on every write, however it arrives. Say so if the person
|
|
1016
1134
|
asks for an audit trail; a database the app reaches only through functions would close it, and Rayfin doesn't offer
|
|
1017
1135
|
that today.
|
|
1018
1136
|
|
|
1137
|
+
- **A write's button stays off until its reload lands.** The save resolves only once the list it reloads is on screen:
|
|
1138
|
+
make the reload a function that awaits the load itself, and end the save with `await reload()`. Bumping a counter that
|
|
1139
|
+
an effect reloads on returns before the rows arrive, so "Mark done" comes back on over a row that still looks open, and
|
|
1140
|
+
a quick second click sends the same write again (the unique key refuses it, and the row shows an error for an item that
|
|
1141
|
+
was closed). setup's notes and scenarios hooks do this.
|
|
1142
|
+
|
|
1143
|
+
```ts
|
|
1144
|
+
const seq = useRef(0);
|
|
1145
|
+
const reload = useCallback(async () => {
|
|
1146
|
+
const mine = ++seq.current;
|
|
1147
|
+
const rows = await loadTasks();
|
|
1148
|
+
if (mine === seq.current) setTasks(rows); // a reload a later one overtook drops its rows
|
|
1149
|
+
}, []);
|
|
1150
|
+
const markDone = useCallback(async (taskId: string) => {
|
|
1151
|
+
await saveTaskDone(taskId); // the markTaskDone call above
|
|
1152
|
+
await reload(); // resolves once the row shows done: the button stays off until then
|
|
1153
|
+
}, [reload]);
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
- **The rest of the pattern, each measured on a real app:**
|
|
1157
|
+
- Apply only the latest reload's rows (the `seq` above): two quick saves start two reloads, and the older one can land
|
|
1158
|
+
last, so an item just closed shows open again.
|
|
1159
|
+
- Show "Loading..." until the first load lands, and the load's error when it fails - never an empty list's words
|
|
1160
|
+
("Nothing open") before the rows are in.
|
|
1161
|
+
- A form's default that comes from data loading elsewhere (the item's owner) is adopted when it arrives, unless the
|
|
1162
|
+
reader has already chosen; a lookup behind a choice list that fails says so in the form.
|
|
1163
|
+
- A due date takes `min={today}`, and the server function refuses a past one.
|
|
1164
|
+
- Read what the page shows, not every row ever saved: bound the query with `.where(...)` and show the first page as it
|
|
1165
|
+
lands. Bound the DONE items, by when they were done; never bound the open ones, least of all by their due date: the
|
|
1166
|
+
most overdue open item is the one the reader most needs, and a due-date window drops it first.
|
|
1167
|
+
- A panel whose query failed offers Retry: setup's `BicChartPanel` does, from the query hook's `refetch` (pass the
|
|
1168
|
+
hook's whole result).
|
|
1169
|
+
- A disabled Save says why, beside it ("Pick a country from the list", "The due date can't be in the past"), tied to
|
|
1170
|
+
the button with `aria-describedby`: a grey button with no word leaves the reader guessing.
|
|
1171
|
+
- A moment (a done or created time) shows in the reader's own time zone; only a date stored as a calendar day (a due
|
|
1172
|
+
date) is formatted in UTC.
|
|
1173
|
+
- When a "done" is refused because someone else closed the item first, the list (the hook, not the row the reload
|
|
1174
|
+
removes) says "Already marked done by <name>".
|
|
1175
|
+
- A single pick across several filters on one key is one distinct key: a country held by both the map's and the
|
|
1176
|
+
table's filter is one pick, not none.
|
|
1177
|
+
- A list for a picked owner filters by the owner field the form saves (the follow-up's owner), never by the owner the
|
|
1178
|
+
model gives the item's account: a follow-up on Bob's account given to Alice belongs on Alice's list. Count an owner
|
|
1179
|
+
badge by that same field.
|
|
1180
|
+
- A "sign in" check asks what the save asks: when the save needs the user's email, `cannotSave` checks
|
|
1181
|
+
`user?.id && user.email`, not just `user`, or a reader signed in without an email claim fills in the form and is
|
|
1182
|
+
told to sign in.
|
|
1183
|
+
- A lookup whose rows reach the page shows its loading and its failure on the page, beside what it feeds, not only
|
|
1184
|
+
in the form: an owner-of-each-account lookup run for the flag form also places the table's badges and fills the
|
|
1185
|
+
follow-ups list, so while it loads, or after it fails, those read as truly empty unless the page says why. A blank
|
|
1186
|
+
model value is left out of text, never put in with `String()`: `String(null)` prints "null".
|
|
1187
|
+
- Other people's writes show without a page reload: the hook reloads when the page comes back into view
|
|
1188
|
+
(`visibilitychange` to visible, and window `focus`), never on a timer, and a write that happens once (a done)
|
|
1189
|
+
reloads whether it succeeds or is refused (`try { await markDone() } finally { await reload() }`), so a refusal
|
|
1190
|
+
shows the item as it now is, with who closed it. setup's notes and scenarios hooks do this.
|
|
1191
|
+
- A dialog's list is filtered from the hook's rows where it's shown (keep only its key in state), never a copy taken
|
|
1192
|
+
when it opened: a copy misses the flag just added in it.
|
|
1193
|
+
`check_app` flags each of these.
|
|
1194
|
+
- **Every write target is reachable by name, from the keyboard.** A write that goes to one mark (a flag on an account, a
|
|
1195
|
+
note on a product) is opened by a chart pick, and also chosen by name: setup writes
|
|
1196
|
+
`src/components/bic-target-picker.tsx`, a searchable list (type a few letters, arrows, Enter; accents and case don't
|
|
1197
|
+
matter). Put `<BicTargetPicker label="Account" options={every target as { key, text }} value={key} onChange={setKey} />`
|
|
1198
|
+
in the form, start it at the chart's pick when there is one, and keep the button that opens the form enabled. A
|
|
1199
|
+
chart's keyboard order is its drawing order (a map's bubbles by size), so a pick alone means arrowing through every
|
|
1200
|
+
mark. `check_app` flags a write button that only a chart pick enables.
|
|
1201
|
+
|
|
1019
1202
|
- **A dialog of your own** (a form over the page) uses `useDialogFocus(ref, onClose)` from `src/lib/bic-dialog.ts`:
|
|
1020
|
-
Tab stays inside it, Escape closes it, and
|
|
1203
|
+
Tab stays inside it, Escape closes it (it asks first when a field holds typed text, and an open list inside it, such
|
|
1204
|
+
as the target picker's, takes its own Escape), and focus goes back to what opened it - or, when a save repainted that (a
|
|
1205
|
+
chart's badge), to its replacement.
|
|
1021
1206
|
|
|
1022
1207
|
`check_app` flags a placeholder name in a write, a time written from the browser's clock, an entity that takes any
|
|
1023
|
-
name on create or lets any row be updated again,
|
|
1208
|
+
name on create or lets any row be updated again, a name it stores that its policy doesn't check against the token, a write that resolves before its reload lands, and a dialog that
|
|
1209
|
+
doesn't hold the keyboard.
|
|
1024
1210
|
|
|
1025
1211
|
Don't stand up a backend to test notes; the deploy creates the tables. Finish with `npx tsc -b` and the tests, and say the notes go live on deploy.
|
|
1026
1212
|
|