@ai-matrx/records-ui 0.53.0 → 0.56.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,97 @@
1
1
  # Changelog — @ai-matrx/records-ui
2
2
 
3
+ ## 0.56.0
4
+
5
+ **A condition the builder cannot draw is said in words, not blanked.**
6
+
7
+ Found by a screenshot, which is what the screenshots were for. The Birchwood
8
+ Avenue renovation's own rule demands "a second quote from a DIFFERENT
9
+ contractor" — a `sibling_count`, which the one-clause builder cannot draw — and
10
+ the editor rendered it as **"— pick a column —"**. That is the screen saying
11
+ there is no condition when there is one, beside a live Save button, on a rule
12
+ that is turning people's work away right now.
13
+
14
+ It now reads: *"it cannot get here unless **how many others there are for the
15
+ same Room from a different Contractor** — this part of the rule is not something
16
+ this builder can draw yet, so it is shown as it reads"*, with a **Replace it**
17
+ button. Replacing is a thing a person DID, never a thing that happened to them
18
+ because a control looked empty — and until they press it, the clause they cannot
19
+ draw is still the one that saves.
20
+
21
+ `conditionIsSimple` and `conditionInWords` are exported, so every screen that
22
+ mounts the builder gets the same honesty rather than each deciding for itself.
23
+
24
+ ## 0.55.0
25
+
26
+ **Three things the bulk paste got wrong on its first real fifty rows.**
27
+
28
+ Proved headlessly on **Rincon Plumbing Co's September billing run** — fifty real
29
+ invoices, `INV-5100`…`INV-5149`, a `$1,250.50` and a `(285.00)` credit, two
30
+ cells deliberately wrong. The paste itself worked end to end: the preview
31
+ appeared with the store untouched, the money arrived as `1250.5` rather than the
32
+ sheet's string, the bracketed credit as `-285`, and the outcome read *"All 198
33
+ values are in the table — read back out of it, not just accepted"*, confirmed by
34
+ an independent read as the same identity. These are the three things that were
35
+ wrong with it anyway.
36
+
37
+ * **Every refusal said "Row 0" and named a raw column key.** `validateCell` and
38
+ the planner ask the same question, and for a cell on a row that already exists
39
+ the primitive asks first — so the refusal arrived already decided, with a
40
+ column id and no line, and the planner's correct "Row 12, Amount Due" could
41
+ only ever fire for an overflow row. The refused cells are folded back into the
42
+ block with their own values (design-system 0.31.0 keeps them) and the planner
43
+ judges the whole thing: one place deciding, one set of words, a line number and
44
+ the column's own name on every refusal. With fifty rows on a clipboard that is
45
+ the difference between a fixable cell and a shrug.
46
+ * **"This will change 28 Invoicess and create 22 new Invoicess."** The sentence
47
+ appended an "s" to `label_singular`, which for that table is already
48
+ "Invoices". A plural is a word a table knows (`label_plural`), never a rule
49
+ applied to another word.
50
+ * **Every pasted date drew one day early**, systematically, across all fifty.
51
+ `2026-09-01` parsed as an INSTANT is midnight UTC, and a Ventura grid renders
52
+ that as 31 August. A date field holds a DAY — the day the invoice was issued is
53
+ the same day in Ventura and in Berlin — so a pasted day is kept as that day,
54
+ and only a field that actually declares a time becomes an instant. A
55
+ spreadsheet's own format ("9/1/2026") keeps the day the reader can see, not the
56
+ UTC one.
57
+
58
+ **And the demo harness could not stand up in a fresh organization.** All four
59
+ `ensure*Field` helpers updated the Table's `fields` list BEFORE inserting the
60
+ Field definition, which `custom._claimed_column_guard` refuses outright — *"Records
61
+ UI Demo says it has a column called \"signed_by\", and there is no such field."*
62
+ The harness only worked in organizations provisioned before that guard landed.
63
+ The order is the fix; the guard stays.
64
+
65
+ Needs `@ai-matrx/design-system` 0.31.0.
66
+
67
+ ## 0.54.0
68
+
69
+ **The grid shows a column the panel beside it just added, without a reload.**
70
+
71
+ Measured on production 2026-09-21, Ridgeline Physical Therapy's patients table:
72
+ "Add field" succeeded, the panel confirmed the column with its own read-back and
73
+ closed — and the grid did not show it until the page was reloaded. The panel was
74
+ telling the truth; the grid next to it was drawing a table that no longer
75
+ existed.
76
+
77
+ The cause is not in this package's screens, and that is the point: the grid and
78
+ the field panel are siblings, each calling `useFields(tableId)`, each holding
79
+ its own copy, with nothing joining them. Fixing the pair that surfaced it would
80
+ have left the same staleness between every other pair — the rail and the grid,
81
+ the filter and the form builder. So the signal is `@ai-matrx/records` 0.39.0's
82
+ `tableShapeChanged`, which every shape-changing door announces and every reader
83
+ subscribes to, and this package simply inherits it.
84
+
85
+ `src/shape-changed.test.tsx` holds the five clauses at the pair a person
86
+ actually sees, against the real client through the real doors: a column added in
87
+ the panel appears in the grid beside it; every screen on that table re-reads,
88
+ not one; a screen on a DIFFERENT table is left alone (a signal that woke the
89
+ whole app would be a storm with a sensible name on it); the tick carries no data
90
+ and the listener re-reads through the door; and a retired column disappears the
91
+ same way. Four of the five are RED on 0.53.0's bytes.
92
+
93
+ Needs `@ai-matrx/records` 0.39.0.
94
+
3
95
  ## 0.53.0
4
96
 
5
97
  **Every card on every board said "Loading…", forever.**
package/dist/index.cjs CHANGED
@@ -94,6 +94,7 @@ __export(src_exports, {
94
94
  MyChecklistSteps: () => MyChecklistSteps,
95
95
  NEEDS_A_SENTENCE: () => NEEDS_A_SENTENCE,
96
96
  NOT_ANSWERED_YET: () => NOT_ANSWERED_YET,
97
+ NOT_DRAWN_HERE: () => NOT_DRAWN_HERE,
97
98
  NO_AGENT_PORT: () => NO_AGENT_PORT,
98
99
  NO_CHAT_REASON: () => NO_CHAT_REASON,
99
100
  NO_COLUMNS_WHY: () => NO_COLUMNS_WHY,
@@ -162,6 +163,8 @@ __export(src_exports, {
162
163
  chooseSurface: () => chooseSurface,
163
164
  colorFromTheValue: () => colorFromTheValue,
164
165
  columnForField: () => columnForField,
166
+ conditionInWords: () => conditionInWords,
167
+ conditionIsSimple: () => conditionIsSimple,
165
168
  conditionValue: () => conditionValue,
166
169
  controlFor: () => controlFor,
167
170
  currencyCodeFor: () => currencyCodeFor,
@@ -2728,7 +2731,7 @@ function EnrichPanel({ tableId, fieldId, className }) {
2728
2731
  var import_react17 = require("react");
2729
2732
  var import_design_system8 = require("@ai-matrx/design-system");
2730
2733
  var import_jsx_runtime11 = require("react/jsx-runtime");
2731
- function PastePreview({ plan, noun, onConfirm, onCancel }) {
2734
+ function PastePreview({ plan, noun, nounPlural, onConfirm, onCancel }) {
2732
2735
  const [busy, setBusy] = (0, import_react17.useState)(false);
2733
2736
  const [outcome, setOutcome] = (0, import_react17.useState)(null);
2734
2737
  const nothingToDo = plan.cells === 0;
@@ -2748,7 +2751,7 @@ function PastePreview({ plan, noun, onConfirm, onCancel }) {
2748
2751
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_design_system8.Separator, {}),
2749
2752
  outcome === null ? /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_jsx_runtime11.Fragment, { children: [
2750
2753
  nothingToDo ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { className: "text-sm", children: "Nothing in what you pasted can be written to this table. Every cell is listed below with the reason." }) : /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("p", { className: "text-sm", children: [
2751
- sentence(plan, noun),
2754
+ sentence(plan, noun, nounPlural),
2752
2755
  " The values below are exactly what will be stored."
2753
2756
  ] }),
2754
2757
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(PlanTable, { plan }),
@@ -2788,7 +2791,7 @@ function PastePreview({ plan, noun, onConfirm, onCancel }) {
2788
2791
  setBusy(true);
2789
2792
  void onConfirm().then(setOutcome).finally(() => setBusy(false));
2790
2793
  },
2791
- children: busy ? "Writing\u2026" : confirmLabel(plan, noun)
2794
+ children: busy ? "Writing\u2026" : confirmLabel(plan, noun, nounPlural)
2792
2795
  }
2793
2796
  )
2794
2797
  ] })
@@ -2843,19 +2846,19 @@ function plural(n, one, many) {
2843
2846
  function summarize(plan) {
2844
2847
  return `${plural(plan.cells, "value", "values")} across ${plural(plan.rows.length, "row", "rows")}`;
2845
2848
  }
2846
- function sentence(plan, noun) {
2849
+ function sentence(plan, noun, nounPlural) {
2847
2850
  const parts = [];
2848
- if (plan.updates > 0) parts.push(`change ${plural(plan.updates, noun, `${noun}s`)}`);
2849
- if (plan.creates > 0) parts.push(`create ${plural(plan.creates, `new ${noun}`, `new ${noun}s`)}`);
2851
+ if (plan.updates > 0) parts.push(`change ${plural(plan.updates, noun, nounPlural)}`);
2852
+ if (plan.creates > 0) parts.push(`create ${plural(plan.creates, `new ${noun}`, `new ${nounPlural}`)}`);
2850
2853
  const what = parts.length === 2 ? `${parts[0]} and ${parts[1]}` : parts[0] ?? "write nothing";
2851
2854
  return `This will ${what}.`;
2852
2855
  }
2853
- function confirmLabel(plan, noun) {
2856
+ function confirmLabel(plan, noun, nounPlural) {
2854
2857
  if (plan.creates > 0 && plan.updates === 0) {
2855
- return `Create ${plural(plan.creates, noun, `${noun}s`)}`;
2858
+ return `Create ${plural(plan.creates, noun, nounPlural)}`;
2856
2859
  }
2857
2860
  if (plan.creates > 0) return `Write ${plural(plan.rows.length, "row", "rows")}`;
2858
- return `Update ${plural(plan.updates, noun, `${noun}s`)}`;
2861
+ return `Update ${plural(plan.updates, noun, nounPlural)}`;
2859
2862
  }
2860
2863
 
2861
2864
  // src/pasteValues.ts
@@ -2890,13 +2893,21 @@ function valueFromPastedText(field, raw) {
2890
2893
  return { value: negative ? -n : n };
2891
2894
  }
2892
2895
  case "datetime": {
2896
+ const wantsTime = String(field.config?.["kind"] ?? "date") === "datetime";
2897
+ const plainDay = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(raw);
2898
+ if (!wantsTime && plainDay) return { value: `${plainDay[1]}-${plainDay[2]}-${plainDay[3]}` };
2893
2899
  const at = Date.parse(text);
2894
2900
  if (Number.isNaN(at)) {
2895
2901
  return {
2896
2902
  refusal: `${label} holds a date, and \u201C${raw}\u201D is not one this can read. Paste it as 2026-09-21, or as your spreadsheet's own date format.`
2897
2903
  };
2898
2904
  }
2899
- return { value: new Date(at).toISOString() };
2905
+ const parsed = new Date(at);
2906
+ if (wantsTime) return { value: parsed.toISOString() };
2907
+ const y = parsed.getFullYear();
2908
+ const m = String(parsed.getMonth() + 1).padStart(2, "0");
2909
+ const d = String(parsed.getDate()).padStart(2, "0");
2910
+ return { value: `${y}-${m}-${d}` };
2900
2911
  }
2901
2912
  case "json": {
2902
2913
  try {
@@ -3087,42 +3098,55 @@ function Grid({
3087
3098
  const fieldList = fields.data ?? [];
3088
3099
  const byRecord = /* @__PURE__ */ new Map();
3089
3100
  const order = [];
3090
- for (const patch of sheetPlan.accepted) {
3091
- let held = byRecord.get(patch.rowId);
3101
+ const take = (rowId, columnId, value) => {
3102
+ let held = byRecord.get(rowId);
3092
3103
  if (held === void 0) {
3093
3104
  held = {};
3094
- byRecord.set(patch.rowId, held);
3095
- order.push(patch.rowId);
3105
+ byRecord.set(rowId, held);
3106
+ order.push(rowId);
3096
3107
  }
3097
- held[patch.columnId] = patch.value === null ? "" : String(patch.value);
3108
+ held[columnId] = value === null || value === void 0 ? "" : String(value);
3109
+ };
3110
+ for (const patch of sheetPlan.accepted) take(patch.rowId, patch.columnId, patch.value);
3111
+ for (const refused of sheetPlan.refusals) {
3112
+ if (refused.rowId === null) continue;
3113
+ take(refused.rowId, refused.columnId, refused.value);
3098
3114
  }
3115
+ const onScreen = rows.map((r) => r.id).filter((id) => byRecord.has(id));
3116
+ const touched = [...onScreen, ...order.filter((id) => !onScreen.includes(id))];
3099
3117
  const columnKeys = Array.from(
3100
3118
  /* @__PURE__ */ new Set([
3101
3119
  ...sheetPlan.accepted.map((p) => p.columnId),
3120
+ ...sheetPlan.refusals.map((r) => r.columnId),
3102
3121
  ...sheetPlan.overflow.flatMap((row) => Object.keys(row))
3103
3122
  ])
3104
3123
  );
3105
3124
  const built = planPastedBlock({
3106
3125
  fields: fieldList,
3107
3126
  columnKeys,
3108
- onExisting: order.map((recordId) => ({
3127
+ onExisting: touched.map((recordId) => ({
3109
3128
  recordId,
3110
3129
  raw: columnKeys.map((key) => byRecord.get(recordId)?.[key])
3111
3130
  })),
3112
3131
  onNew: sheetPlan.overflow.map((row) => columnKeys.map((key) => row[key])),
3113
3132
  pastRightEdge: sheetPlan.pastRightEdge
3114
3133
  });
3134
+ const notWritable = /* @__PURE__ */ new Map();
3115
3135
  for (const refused of sheetPlan.refusals) {
3136
+ if (built.refusals.some((r) => r.column === columnLabel(fieldList, refused.columnId))) continue;
3137
+ notWritable.set(refused.columnId, refused.reason);
3138
+ }
3139
+ for (const [columnId, why] of notWritable) {
3116
3140
  built.refusals.push({
3117
3141
  fromLine: 0,
3118
- column: refused.columnId,
3142
+ column: columnLabel(fieldList, columnId),
3119
3143
  raw: "",
3120
- why: refused.reason
3144
+ why
3121
3145
  });
3122
3146
  }
3123
3147
  setPastePlan({ plan: built });
3124
3148
  },
3125
- [fields.data]
3149
+ [fields.data, rows]
3126
3150
  );
3127
3151
  const applyPaste = (0, import_react18.useCallback)(async () => {
3128
3152
  const held = pastePlan;
@@ -3433,6 +3457,7 @@ function Grid({
3433
3457
  {
3434
3458
  plan: pastePlan.plan,
3435
3459
  noun: table.data?.label_singular ?? "record",
3460
+ nounPlural: table.data?.label_plural ?? `${table.data?.label_singular ?? "record"}s`,
3436
3461
  onConfirm: applyPaste,
3437
3462
  onCancel: () => setPastePlan(null)
3438
3463
  }
@@ -3450,6 +3475,10 @@ function Grid({
3450
3475
  ] })
3451
3476
  ] }) });
3452
3477
  }
3478
+ function columnLabel(fields, columnId) {
3479
+ const field = fields.find((f) => f.key === columnId);
3480
+ return field ? fieldName(field) : humanize(columnId);
3481
+ }
3453
3482
  function AddFieldButton({ onClick }) {
3454
3483
  return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
3455
3484
  import_design_system9.Button,
@@ -4788,6 +4817,60 @@ var CONDITION_OPS = [
4788
4817
  { op: "gt", label: "is more than" },
4789
4818
  { op: "lt", label: "is less than" }
4790
4819
  ];
4820
+ function conditionIsSimple(expr) {
4821
+ if (expr === null || expr === void 0) return true;
4822
+ const node = expr;
4823
+ if (typeof node.op !== "string" || !CONDITION_OPS.some((c) => c.op === node.op)) return false;
4824
+ if (!Array.isArray(node.args)) return false;
4825
+ const first = node.args[0];
4826
+ if (!first || typeof first.field !== "string") return false;
4827
+ if (node.op === "present") return node.args.length === 1;
4828
+ const second = node.args[1];
4829
+ return node.args.length === 2 && !!second && "const" in second;
4830
+ }
4831
+ function conditionInWords(expr, fields) {
4832
+ const name = (id) => {
4833
+ const f = fields.find((x) => x.id === id);
4834
+ return f ? f.label || f.key : "a column";
4835
+ };
4836
+ const said = {
4837
+ eq: "is",
4838
+ ne: "is not",
4839
+ gt: "is more than",
4840
+ lt: "is less than",
4841
+ gte: "is at least",
4842
+ lte: "is at most",
4843
+ and: "and",
4844
+ or: "or",
4845
+ matches: "matches"
4846
+ };
4847
+ const walk = (node) => {
4848
+ if (node === null || node === void 0) return "nothing";
4849
+ const n = node;
4850
+ if ("const" in n) return JSON.stringify(n["const"]);
4851
+ if (typeof n["field"] === "string" && !("op" in n)) return name(n["field"]);
4852
+ const op = typeof n["op"] === "string" ? n["op"] : "";
4853
+ if (op === "previous" && typeof n["field"] === "string") return `what ${name(n["field"])} used to say`;
4854
+ if (op === "sibling_count") {
4855
+ const same = n["same"] ?? [];
4856
+ const differs = n["differs"] ?? [];
4857
+ const sameWords = same.length > 0 ? ` for the same ${same.map(name).join(" and ")}` : "";
4858
+ const diffWords = differs.length > 0 ? ` from a different ${differs.map(name).join(" and ")}` : "";
4859
+ return `how many others there are${sameWords}${diffWords}`;
4860
+ }
4861
+ if (op === "stage_count") return "how many are in that column already";
4862
+ if (op === "actor_at_least") return `whoever is moving it is at least ${String(n["level"] ?? "somebody")}`;
4863
+ const args = Array.isArray(n["args"]) ? n["args"] : [];
4864
+ if (op === "not") return `not ${walk(args[0])}`;
4865
+ if (op === "present") return `${walk(args[0])} has been answered`;
4866
+ const word = said[op] ?? op;
4867
+ if (op === "and" || op === "or") return args.map(walk).join(` ${word} `);
4868
+ if (args.length === 2) return `${walk(args[0])} ${word} ${walk(args[1])}`;
4869
+ if (args.length === 1) return `${walk(args[0])} ${word}`;
4870
+ return op;
4871
+ };
4872
+ return walk(expr);
4873
+ }
4791
4874
  function conditionValue(typed) {
4792
4875
  const trimmed = typed.trim();
4793
4876
  if (trimmed === "") return typed;
@@ -4859,6 +4942,7 @@ function ConditionRow({ lead, expr, fields, onChange, emptyLabel = "always", cla
4859
4942
  var import_jsx_runtime18 = require("react/jsx-runtime");
4860
4943
  var NO_RULES_YET = "Nothing has to be true to get here yet.";
4861
4944
  var NEEDS_A_SENTENCE = "Write the sentence somebody reads when this stops them, the way you would say it out loud.";
4945
+ var NOT_DRAWN_HERE = "This part of the rule is not something this builder can draw yet";
4862
4946
  var ON_FAIL = [
4863
4947
  { value: "refuse", label: "Turn it away", note: "The card stays where it is and the person is told why." },
4864
4948
  {
@@ -5039,7 +5123,7 @@ function StageRulesSection({ tableId, stage, className }) {
5039
5123
  }
5040
5124
  ),
5041
5125
  /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
5042
- ConditionRow,
5126
+ Clause,
5043
5127
  {
5044
5128
  lead: "When",
5045
5129
  emptyLabel: "every card",
@@ -5049,7 +5133,7 @@ function StageRulesSection({ tableId, stage, className }) {
5049
5133
  }
5050
5134
  ),
5051
5135
  /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
5052
- ConditionRow,
5136
+ Clause,
5053
5137
  {
5054
5138
  lead: "it cannot get here unless",
5055
5139
  emptyLabel: "\u2014 pick a column \u2014",
@@ -5093,6 +5177,50 @@ function StageRulesSection({ tableId, stage, className }) {
5093
5177
  /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_design_system15.Separator, {})
5094
5178
  ] });
5095
5179
  }
5180
+ function Clause({
5181
+ lead,
5182
+ emptyLabel,
5183
+ expr,
5184
+ fields,
5185
+ onChange
5186
+ }) {
5187
+ const [replacing, setReplacing] = (0, import_react29.useState)(false);
5188
+ const drawable = conditionIsSimple(expr) || replacing;
5189
+ if (drawable) {
5190
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
5191
+ ConditionRow,
5192
+ {
5193
+ lead,
5194
+ emptyLabel,
5195
+ expr: replacing && !conditionIsSimple(expr) ? null : expr,
5196
+ fields,
5197
+ onChange
5198
+ }
5199
+ );
5200
+ }
5201
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "flex flex-wrap items-center gap-1 text-xs", "data-testid": "stage-rule-clause-in-words", children: [
5202
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { className: "text-muted-foreground", children: lead }),
5203
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { className: "font-medium", children: conditionInWords(expr, fields) }),
5204
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("span", { className: "text-muted-foreground", children: [
5205
+ "\u2014 ",
5206
+ NOT_DRAWN_HERE,
5207
+ ", so it is shown as it reads."
5208
+ ] }),
5209
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
5210
+ import_design_system15.Button,
5211
+ {
5212
+ size: "sm",
5213
+ variant: "ghost",
5214
+ className: "h-6",
5215
+ onClick: () => {
5216
+ setReplacing(true);
5217
+ onChange(null);
5218
+ },
5219
+ children: "Replace it"
5220
+ }
5221
+ )
5222
+ ] });
5223
+ }
5096
5224
 
5097
5225
  // src/TableSettings.tsx
5098
5226
  var import_react31 = require("react");