@iyulab/lowline-form 0.6.0 → 0.8.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/dist/client.d.ts CHANGED
@@ -24,6 +24,11 @@ export interface ThresholdChoice {
24
24
  */
25
25
  export interface FieldReport {
26
26
  field: string;
27
+ /**
28
+ * How many settled records the thresholds were chosen on — the records the report speaks for. Records settled since
29
+ * are answered from, but not in the replay until the thresholds are chosen again.
30
+ */
31
+ chosenOn: number;
27
32
  lookups: number;
28
33
  /** The threshold at which values settled alongside answer; absent while none was right often enough. */
29
34
  key?: ThresholdChoice;
package/dist/draft.d.ts CHANGED
@@ -8,10 +8,21 @@ export interface Suggestion {
8
8
  origin?: 'decider' | 'other';
9
9
  fit?: 'exact' | 'prefix';
10
10
  }
11
- interface Track {
11
+ /** The layer of the decider an answer came from. */
12
+ export type Layer = 'key' | 'similar' | 'judgement';
13
+ /** What the host knows of the answer it shows or holds back, beyond its value: the layer that gave it and what it promised. */
14
+ export interface Reported {
15
+ layer?: Layer | null;
16
+ promise?: number;
17
+ }
18
+ interface Track extends Reported {
12
19
  shown?: Suggestion;
13
20
  declined?: boolean;
14
21
  held?: number;
22
+ /** The answer held back for a check, kept so the record can say what the person was not shown. */
23
+ withheld?: Suggestion;
24
+ /** When the person reached the field, or its suggestion came into view — the first — on the session's clock, in milliseconds. */
25
+ at?: number;
15
26
  }
16
27
  /**
17
28
  * The account of one record while it is written: the values it was opened with, and for each field the suggestion
@@ -19,17 +30,42 @@ interface Track {
19
30
  * turns it, with the saved values, into what each decided field teaches — so a value taken from a suggestion is never
20
31
  * counted as one typed. It is only bookkeeping: nothing is counted here.
21
32
  *
22
- * Written as the engine reads it: `{ opened, fields: { field: { shown, declined, held } } }`.
33
+ * Written as the engine reads it: `{ opened, fields: { field: { shown, declined, held } } }`, and — only what was
34
+ * reported — `opened_at`, `saved_at` (milliseconds on one steady clock), `arrival` (the fields in the order their values
35
+ * arrived), `by` and `batch`, and in a field `layer`, `promise`, `at` and `withheld`.
23
36
  */
24
37
  export declare class Draft {
25
38
  readonly opened: Values;
26
39
  readonly fields: Record<string, Track>;
40
+ private openedAt?;
41
+ private savedAt?;
42
+ private arrival;
43
+ private by?;
44
+ private batch?;
27
45
  /** A record opened holding `values` — none for a new one. Empty values are left out. */
28
46
  constructor(values?: Values);
29
47
  /** A suggestion came into view for the field. A suggestion turned down earlier stays turned down only if it is this. */
30
- show(field: string, suggestion: Suggestion): void;
31
- /** The field's answer is held back for a check, drawn with `probability`: what the person settles is checked. */
32
- hold(field: string, probability: number): void;
48
+ show(field: string, suggestion: Suggestion, reported?: Reported): void;
49
+ /**
50
+ * The field's answer is held back for a check, drawn with `probability`: what the person settles is checked.
51
+ * `withheld` is the answer not shown, with its layer and promise, for the record to say what was held back.
52
+ */
53
+ hold(field: string, probability: number, withheld?: {
54
+ suggestion: Suggestion;
55
+ } & Reported): void;
56
+ /** The record was opened at `ms` on the session's clock (a steady clock in milliseconds: only differences are read). */
57
+ openAt(ms: number): void;
58
+ /** The record is being saved at `ms` on the same clock. */
59
+ saveAt(ms: number): void;
60
+ /** The person reached the field, or its suggestion came into view, at `ms`. Only the first report for a field counts. */
61
+ reach(field: string, ms: number): void;
62
+ /** The field's current value arrived now: it moves to the end of the order values arrived in. */
63
+ arrived(field: string): void;
64
+ /** The record is settled by the person `id`: an opaque id, one person, one id; not a name. */
65
+ settledBy(id: string): void;
66
+ /** The record is settled in the batch `id`, with the records that came at once from the same source. */
67
+ inBatch(id: string): void;
68
+ private report;
33
69
  /** The person turned down the suggestion in view; false, changing nothing, when none was shown. */
34
70
  decline(field: string): boolean;
35
71
  /** Whether `suggestion` is the one in view for the field and the person turned it down. */
@@ -39,6 +75,11 @@ export declare class Draft {
39
75
  toJSON(): {
40
76
  opened: Values;
41
77
  fields: Record<string, Track>;
78
+ opened_at?: number;
79
+ saved_at?: number;
80
+ arrival?: string[];
81
+ by?: string;
82
+ batch?: string;
42
83
  };
43
84
  }
44
85
  export declare function isEmpty(value: Value | undefined): boolean;
package/dist/draft.js CHANGED
@@ -4,27 +4,79 @@
4
4
  * turns it, with the saved values, into what each decided field teaches — so a value taken from a suggestion is never
5
5
  * counted as one typed. It is only bookkeeping: nothing is counted here.
6
6
  *
7
- * Written as the engine reads it: `{ opened, fields: { field: { shown, declined, held } } }`.
7
+ * Written as the engine reads it: `{ opened, fields: { field: { shown, declined, held } } }`, and — only what was
8
+ * reported — `opened_at`, `saved_at` (milliseconds on one steady clock), `arrival` (the fields in the order their values
9
+ * arrived), `by` and `batch`, and in a field `layer`, `promise`, `at` and `withheld`.
8
10
  */
9
11
  export class Draft {
10
12
  /** A record opened holding `values` — none for a new one. Empty values are left out. */
11
13
  constructor(values = {}) {
12
14
  this.fields = {};
15
+ this.arrival = [];
13
16
  this.opened = Object.fromEntries(Object.entries(values).filter(([, v]) => !isEmpty(v)));
14
17
  }
15
18
  /** A suggestion came into view for the field. A suggestion turned down earlier stays turned down only if it is this. */
16
- show(field, suggestion) {
19
+ show(field, suggestion, reported = {}) {
17
20
  const track = (this.fields[field] ??= {});
18
21
  if (!sameSuggestion(track.shown, suggestion))
19
22
  track.declined = false;
20
23
  track.shown = { ...suggestion };
21
24
  delete track.held;
25
+ delete track.withheld;
26
+ this.report(track, reported);
22
27
  }
23
- /** The field's answer is held back for a check, drawn with `probability`: what the person settles is checked. */
24
- hold(field, probability) {
28
+ /**
29
+ * The field's answer is held back for a check, drawn with `probability`: what the person settles is checked.
30
+ * `withheld` is the answer not shown, with its layer and promise, for the record to say what was held back.
31
+ */
32
+ hold(field, probability, withheld) {
25
33
  if (!(probability > 0 && probability <= 1))
26
34
  throw new RangeError('a check is drawn with a probability above 0 and at most 1');
27
- this.fields[field] = { held: probability };
35
+ const at = this.fields[field]?.at;
36
+ const track = { held: probability };
37
+ if (at !== undefined)
38
+ track.at = at;
39
+ if (withheld) {
40
+ track.withheld = { ...withheld.suggestion };
41
+ this.report(track, withheld);
42
+ }
43
+ this.fields[field] = track;
44
+ }
45
+ /** The record was opened at `ms` on the session's clock (a steady clock in milliseconds: only differences are read). */
46
+ openAt(ms) {
47
+ this.openedAt = whole(ms);
48
+ }
49
+ /** The record is being saved at `ms` on the same clock. */
50
+ saveAt(ms) {
51
+ this.savedAt = whole(ms);
52
+ }
53
+ /** The person reached the field, or its suggestion came into view, at `ms`. Only the first report for a field counts. */
54
+ reach(field, ms) {
55
+ const track = (this.fields[field] ??= {});
56
+ track.at ??= whole(ms);
57
+ }
58
+ /** The field's current value arrived now: it moves to the end of the order values arrived in. */
59
+ arrived(field) {
60
+ this.arrival = this.arrival.filter((f) => f !== field);
61
+ this.arrival.push(field);
62
+ }
63
+ /** The record is settled by the person `id`: an opaque id, one person, one id; not a name. */
64
+ settledBy(id) {
65
+ this.by = id;
66
+ }
67
+ /** The record is settled in the batch `id`, with the records that came at once from the same source. */
68
+ inBatch(id) {
69
+ this.batch = id;
70
+ }
71
+ report(track, { layer, promise }) {
72
+ if (layer)
73
+ track.layer = layer;
74
+ else
75
+ delete track.layer;
76
+ if (promise !== undefined)
77
+ track.promise = promise;
78
+ else
79
+ delete track.promise;
28
80
  }
29
81
  /** The person turned down the suggestion in view; false, changing nothing, when none was shown. */
30
82
  decline(field) {
@@ -46,9 +98,21 @@ export class Draft {
46
98
  return this.fields[field]?.held;
47
99
  }
48
100
  toJSON() {
49
- return { opened: this.opened, fields: this.fields };
101
+ return {
102
+ opened: this.opened,
103
+ fields: this.fields,
104
+ ...(this.openedAt !== undefined ? { opened_at: this.openedAt } : {}),
105
+ ...(this.savedAt !== undefined ? { saved_at: this.savedAt } : {}),
106
+ ...(this.arrival.length > 0 ? { arrival: this.arrival } : {}),
107
+ ...(this.by !== undefined ? { by: this.by } : {}),
108
+ ...(this.batch !== undefined ? { batch: this.batch } : {}),
109
+ };
50
110
  }
51
111
  }
112
+ /** A time the engine reads: whole milliseconds, never negative. */
113
+ function whole(ms) {
114
+ return Math.max(0, Math.round(ms));
115
+ }
52
116
  function sameSuggestion(a, b) {
53
117
  return a !== undefined && a.value === b.value && (a.origin ?? 'decider') === (b.origin ?? 'decider') && (a.fit ?? 'exact') === (b.fit ?? 'exact');
54
118
  }
@@ -29,6 +29,10 @@ export declare class LowlineForm extends LitElement {
29
29
  rate: number;
30
30
  /** The words the form shows, in the host's language. */
31
31
  strings: Strings;
32
+ /** Who is writing, as an opaque id the host gives — one person, one id; not a name. Sent with the save when set. */
33
+ settledBy: string;
34
+ /** The batch the record is written in, when several come at once from one source. Sent with the save when set. */
35
+ batch: string;
32
36
  private formdown?;
33
37
  private session?;
34
38
  private asking?;
@@ -41,11 +41,16 @@ let LowlineForm = class LowlineForm extends LitElement {
41
41
  this.rate = 0.05;
42
42
  /** The words the form shows, in the host's language. */
43
43
  this.strings = englishStrings;
44
+ /** Who is writing, as an opaque id the host gives — one person, one id; not a name. Sent with the save when set. */
45
+ this.settledBy = '';
46
+ /** The batch the record is written in, when several come at once from one source. Sent with the save when set. */
47
+ this.batch = '';
44
48
  this.client = new LowlineClient(this.src);
45
49
  this.fieldStates = {};
46
50
  /** Whether the host judges what the records could not answer — until its `judge` route says it has no model. */
47
51
  this.canJudge = true;
48
52
  this.changed = () => {
53
+ this.session?.took(this.values());
49
54
  clearTimeout(this.asking);
50
55
  this.asking = setTimeout(() => void this.ask(), ASK_DELAY_MS);
51
56
  };
@@ -114,6 +119,8 @@ let LowlineForm = class LowlineForm extends LitElement {
114
119
  if (!this.session || !this.form || !this.record)
115
120
  throw new Error('lowline-form needs a form and a record to save');
116
121
  const values = this.values();
122
+ this.session.took(values);
123
+ this.session.beforeSave(this.settledBy || undefined, this.batch || undefined);
117
124
  const saved = await this.client.settle(this.form, this.record, this.session.draft, sendValues ? values : undefined);
118
125
  this.session.saved(values);
119
126
  this.dispatchEvent(new CustomEvent('lowline-saved', { detail: { ...saved, values }, bubbles: true, composed: true }));
@@ -157,8 +164,10 @@ let LowlineForm = class LowlineForm extends LitElement {
157
164
  const formdown = this.formdown;
158
165
  if (!formdown)
159
166
  return;
160
- if (Object.keys(round.prefill).length > 0)
167
+ if (Object.keys(round.prefill).length > 0) {
161
168
  formdown.updateData({ ...formdown.data, ...round.prefill });
169
+ session.took(this.values());
170
+ }
162
171
  formdown.fieldStates = this.fieldStates;
163
172
  if (this.canJudge)
164
173
  void this.judge(session, values, shown);
@@ -213,6 +222,12 @@ __decorate([
213
222
  __decorate([
214
223
  property({ attribute: false })
215
224
  ], LowlineForm.prototype, "strings", void 0);
225
+ __decorate([
226
+ property({ attribute: 'settled-by' })
227
+ ], LowlineForm.prototype, "settledBy", void 0);
228
+ __decorate([
229
+ property()
230
+ ], LowlineForm.prototype, "batch", void 0);
216
231
  __decorate([
217
232
  query('formdown-ui')
218
233
  ], LowlineForm.prototype, "formdown", void 0);
package/dist/session.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface Proposal {
23
23
  asks?: [string, string][];
24
24
  /** For `together`: the fields of its group, still open, that are shown no suggestion. */
25
25
  with?: string[];
26
+ /** The group of fields settled together the field is in, if any: a field held back for a check holds its group's back. */
27
+ together?: string | null;
26
28
  /**
27
29
  * How often an answer of this kind for this field has been right — the replay's precision at the threshold the
28
30
  * suggestion was held to. Absent with no suggestion, for a judgement or a value from elsewhere, and before thresholds
@@ -66,6 +68,8 @@ export declare class Session {
66
68
  readonly rate: number;
67
69
  private readonly random;
68
70
  private readonly strings;
71
+ /** A steady clock in milliseconds (`performance.now()` by default): the record's times are read from it. */
72
+ private readonly clock;
69
73
  draft: Draft;
70
74
  private drawn;
71
75
  /** Fields whose value the form put in unasked, from the suggestion still shown. */
@@ -74,7 +78,18 @@ export declare class Session {
74
78
  private written;
75
79
  /** The judgement asked for each field, by what it reads; `null` while the host is still judging. */
76
80
  private judgements;
77
- constructor(id: string, opened: Values, rate?: number, random?: () => number, strings?: Strings);
81
+ /** The values the form held when it was last looked at, to tell which fields' values arrived since. */
82
+ private seen;
83
+ constructor(id: string, opened: Values, rate?: number, random?: () => number, strings?: Strings,
84
+ /** A steady clock in milliseconds (`performance.now()` by default): the record's times are read from it. */
85
+ clock?: () => number);
86
+ /**
87
+ * The form's values are `values` now: each field whose value is new since the last look arrived, and the person has
88
+ * reached it. A value the form put in unasked arrives too — it is when the record came to hold it.
89
+ */
90
+ took(values: Values): void;
91
+ /** The record is about to be saved by `by`, in `batch`: the draft says so, with the time. */
92
+ beforeSave(by?: string, batch?: string): void;
78
93
  /**
79
94
  * Takes a round of proposals for the form's `values` now. A field offered a value from the person's writing keeps it
80
95
  * in view until the person picks it or turns it down.
package/dist/session.js CHANGED
@@ -12,11 +12,14 @@ export const englishStrings = {
12
12
  * is looked at, and only for a value the decider suggests.
13
13
  */
14
14
  export class Session {
15
- constructor(id, opened, rate = 0.05, random = Math.random, strings = englishStrings) {
15
+ constructor(id, opened, rate = 0.05, random = Math.random, strings = englishStrings,
16
+ /** A steady clock in milliseconds (`performance.now()` by default): the record's times are read from it. */
17
+ clock = () => performance.now()) {
16
18
  this.id = id;
17
19
  this.rate = rate;
18
20
  this.random = random;
19
21
  this.strings = strings;
22
+ this.clock = clock;
20
23
  this.drawn = new Set();
21
24
  /** Fields whose value the form put in unasked, from the suggestion still shown. */
22
25
  this.filled = new Set();
@@ -25,6 +28,30 @@ export class Session {
25
28
  /** The judgement asked for each field, by what it reads; `null` while the host is still judging. */
26
29
  this.judgements = new Map();
27
30
  this.draft = new Draft(opened);
31
+ this.draft.openAt(this.clock());
32
+ this.seen = { ...this.draft.opened };
33
+ }
34
+ /**
35
+ * The form's values are `values` now: each field whose value is new since the last look arrived, and the person has
36
+ * reached it. A value the form put in unasked arrives too — it is when the record came to hold it.
37
+ */
38
+ took(values) {
39
+ const now = this.clock();
40
+ for (const [field, value] of Object.entries(values)) {
41
+ if (isEmpty(value) || sameValue(this.seen[field], value))
42
+ continue;
43
+ this.draft.arrived(field);
44
+ this.draft.reach(field, now);
45
+ }
46
+ this.seen = { ...values };
47
+ }
48
+ /** The record is about to be saved by `by`, in `batch`: the draft says so, with the time. */
49
+ beforeSave(by, batch) {
50
+ this.draft.saveAt(this.clock());
51
+ if (by)
52
+ this.draft.settledBy(by);
53
+ if (batch)
54
+ this.draft.inBatch(batch);
28
55
  }
29
56
  /**
30
57
  * Takes a round of proposals for the form's `values` now. A field offered a value from the person's writing keeps it
@@ -32,18 +59,25 @@ export class Session {
32
59
  */
33
60
  apply(proposals, values) {
34
61
  const round = { fieldStates: Object.fromEntries(this.written), prefill: {} };
62
+ for (const proposal of proposals) {
63
+ if (proposal.suggestion && !this.written.has(proposal.field))
64
+ this.draw(proposal);
65
+ }
66
+ // A field held back for a check holds back the open fields of its group: the person settling it with nothing in view
67
+ // is not shown what is suggested alongside it. Only the held field is the check.
68
+ const held = proposals.filter((p) => this.draft.held(p.field) !== undefined);
69
+ const hidden = (p) => held.some((h) => h.field === p.field || (h.together != null && h.together === p.together));
35
70
  for (const proposal of proposals) {
36
71
  const { field, suggestion } = proposal;
37
72
  if (!suggestion || this.written.has(field))
38
73
  continue;
39
- if (this.draw(field, suggestion))
40
- continue;
41
- if (this.draft.held(field) !== undefined)
74
+ if (hidden(proposal))
42
75
  continue;
43
76
  // Turned down, it stays down for this record: neither shown again nor put back in.
44
77
  if (this.draft.declined(field, suggestion))
45
78
  continue;
46
- this.draft.show(field, suggestion);
79
+ this.draft.show(field, suggestion, proposal);
80
+ this.draft.reach(field, this.clock());
47
81
  if (proposal.prefill && isEmpty(values[field])) {
48
82
  round.prefill[field] = suggestion.value;
49
83
  this.filled.add(field);
@@ -133,13 +167,18 @@ export class Session {
133
167
  /** The record was saved holding `values`: what is written next starts from them. */
134
168
  saved(values) {
135
169
  this.draft = new Draft(values);
170
+ this.draft.openAt(this.clock());
171
+ this.seen = { ...this.draft.opened };
136
172
  this.drawn.clear();
137
173
  this.filled.clear();
138
174
  this.written.clear();
139
175
  this.judgements.clear();
140
176
  }
141
177
  /** Whether the field's answer is held back now — drawn the first time a checkable value is proposed for it. */
142
- draw(field, suggestion) {
178
+ draw(proposal) {
179
+ const { field, suggestion } = proposal;
180
+ if (!suggestion)
181
+ return false;
143
182
  if (this.drawn.has(field))
144
183
  return this.draft.held(field) !== undefined;
145
184
  const checkable = (suggestion.origin ?? 'decider') === 'decider' && (suggestion.fit ?? 'exact') === 'exact';
@@ -147,7 +186,7 @@ export class Session {
147
186
  return false;
148
187
  this.drawn.add(field);
149
188
  if (this.rate > 0 && this.rate <= 1 && this.random() < this.rate) {
150
- this.draft.hold(field, this.rate);
189
+ this.draft.hold(field, this.rate, { ...proposal, suggestion });
151
190
  return true;
152
191
  }
153
192
  return false;
@@ -167,6 +206,9 @@ export class Session {
167
206
  return undefined;
168
207
  }
169
208
  }
209
+ function sameValue(a, b) {
210
+ return JSON.stringify(a ?? '') === JSON.stringify(b);
211
+ }
170
212
  /** What a proposal asking for a judgement reads, as one text. */
171
213
  function reads(proposal) {
172
214
  return JSON.stringify(proposal.asks ?? []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/lowline-form",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "The Lowline form element: a formdown form whose decided fields are suggested from the records people confirmed, talking to a Lowline host's routes.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,12 +40,12 @@
40
40
  "bundle": "vite build --config vite.bundle.config.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@formdown/ui": "^0.25.0",
43
+ "@formdown/ui": "^0.26.0",
44
44
  "lit": "^3.3.3"
45
45
  },
46
46
  "devDependencies": {
47
47
  "typescript": "^6.0.3",
48
- "vitest": "^5.0.2",
49
- "vite": "^8.3.1"
48
+ "vite": "^8.3.1",
49
+ "vitest": "^5.0.2"
50
50
  }
51
51
  }