Skip to content

Commit 2751410

Browse files
Hotragnclaude
andcommitted
Fix live conditions bugs: 10min is a delay, bare hours pick the next one, with you means the host, missed words are said back, tap-to-fill chips
Friend's sentence 'After 10min, a quick visit works' at 8:22 PM: before 'after 10 AM', now 'you can come in 10 min' plus 'I didn't catch: a quick visit works'. blind_40 30/40 before and after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Entire-Checkpoint: 01M4PRC68GKG4JQGEDGSRZJRA7
1 parent 7792e2c commit 2751410

12 files changed

Lines changed: 2769 additions & 33 deletions

File tree

‎apps/web/src/lib/conditions/index.ts‎

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,42 @@ const ORDER: Rule["type"][] = ["max_step", "min_step", "with", "after", "back_by
1616

1717
export const countWords = (s: string): number => s.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
1818

19+
const MIN = 60_000;
20+
// Rules made from a delay ("in 10 min"), so the read-back says it the way it was said.
21+
const delayMin = new WeakMap<Rule, number>();
22+
23+
/**
24+
* A bare hour ("after 10") means the next one coming up from now: 8:22 PM plus "after 10" is
25+
* 10 PM, 9 AM plus "after 10" is 10 AM. Never a time already gone (live bug, Oct 11).
26+
*/
27+
function upcoming(hhmm: string, ctx: ParseContext): string {
28+
const [h, mi] = hhmm.split(":").map(Number) as [number, number];
29+
const h12 = h % 12;
30+
const mm = String(mi).padStart(2, "0");
31+
const now = Date.parse(ctx.now);
32+
const tomorrow = new Date(now + 24 * 60 * MIN).toISOString();
33+
const tries = [
34+
localToIso(`${String(h).padStart(2, "0")}:${mm}`, ctx.now, ctx.tz), // the usual reading first
35+
localToIso(`${String(h12 + (h >= 12 ? 0 : 12)).padStart(2, "0")}:${mm}`, ctx.now, ctx.tz),
36+
localToIso(`${String(h12).padStart(2, "0")}:${mm}`, tomorrow, ctx.tz),
37+
];
38+
return tries.find((iso) => Date.parse(iso) >= now - MIN) ?? (tries[2] as string);
39+
}
40+
1941
function toRule(r: RawRule, ctx: ParseContext): Rule | null {
2042
switch (r.type) {
43+
case "after_in": {
44+
const rule: Rule = { type: "after", at: new Date(Date.parse(ctx.now) + r.value * MIN).toISOString() };
45+
delayMin.set(rule, r.value);
46+
return rule;
47+
}
2148
case "with": {
2249
const m = ctx.members.find((x) => x.name.toLowerCase() === r.value.toLowerCase());
2350
return m ? { type: "with", member_id: m.id } : null;
2451
}
2552
case "after":
2653
case "back_by":
27-
return { type: r.type, at: localToIso(r.value, ctx.now, ctx.tz) };
54+
return { type: r.type, at: r.bare ? upcoming(r.value, ctx) : localToIso(r.value, ctx.now, ctx.tz) };
2855
case "max_walk":
2956
return { type: "max_walk", minutes: r.value };
3057
case "max_step":
@@ -47,7 +74,9 @@ function fragments(rules: Rule[], ctx: ParseContext, style: "rule" | "chip"): st
4774
namesDone = true;
4875
const joined = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
4976
out.push(t(`readback.${style}.with`, { name: joined ?? "" }));
50-
} else if (r.type === "after") out.push(t(`readback.${style}.after`, { time: timeLabel(r.at, ctx.tz) }));
77+
} else if (r.type === "after" && style === "rule" && delayMin.has(r))
78+
out.push(t("readback.rule.afterIn", { min: delayMin.get(r) ?? 0 }));
79+
else if (r.type === "after") out.push(t(`readback.${style}.after`, { time: timeLabel(r.at, ctx.tz) }));
5180
else if (r.type === "back_by") out.push(t(`readback.${style}.backBy`, { time: timeLabel(r.at, ctx.tz) }));
5281
else if (r.type === "max_walk") out.push(t(`readback.${style}.maxWalk`, { min: r.minutes }));
5382
else if (r.type === "max_step") out.push(t(`readback.${style}.maxStep`, { k: r.step }));
@@ -67,11 +96,36 @@ export function readBack(rules: Rule[], ctx: ParseContext): string {
6796
/** One chip label per rule group, for the I3 sheet ("Rules that will be sent"). */
6897
export const readBackChips = (rules: Rule[], ctx: ParseContext): string[] => fragments(rules, ctx, "chip");
6998

99+
// Words that carry no rule on their own, so a clause made only of them isn't "missed".
100+
const FILLER = new Set(
101+
"i im i'm can could come make it works work fine ok okay yes yeah yep sure but so and then me count in sounds good totally lol haha maybe probably will be there down great cool love to the a an that this we us go going lets let's do i'll ill would should too also".split(
102+
" ",
103+
),
104+
);
105+
106+
/** Clauses the parser read nothing from, so the read-back never drops words quietly. */
107+
function missedWords(text: string, ctx: ParseContext): string {
108+
const names = ctx.members.map((m) => m.name);
109+
const out: string[] = [];
110+
for (const clause of text.split(/[,;.!?]+|\s+but\s+/i)) {
111+
const c = clause.trim();
112+
const words = c.toLowerCase().match(/[\p{L}'0-9]+/gu) ?? [];
113+
if (!words.some((w) => !FILLER.has(w))) continue;
114+
const r = parseRaw(c, names, ctx.steps ?? [], ctx.host ?? "");
115+
if (r.ok && r.rules.some((x) => toRule(x, ctx))) continue;
116+
if (!r.ok && r.reason !== "didnt_catch") continue;
117+
out.push(c);
118+
}
119+
const all = out.join(", ").split(/\s+/);
120+
return all.length > 8 ? `${all.slice(0, 8).join(" ")}...` : all.join(" ");
121+
}
122+
70123
export function parse(text: string, ctx: ParseContext): ParseResult {
71124
const raw = parseRaw(
72125
text,
73126
ctx.members.map((m) => m.name),
74127
ctx.steps ?? [],
128+
ctx.host ?? "",
75129
);
76130
if (!raw.ok) return raw.reason === "not_in_group" ? { ok: false, reason: raw.reason, name: raw.name } : raw;
77131
const rules: Rule[] = [];
@@ -81,7 +135,25 @@ export function parse(text: string, ctx: ParseContext): ParseResult {
81135
}
82136
// Only step words on a Flare: nothing usable was said.
83137
if (raw.rules.length && !rules.length) return { ok: false, reason: "didnt_catch" };
84-
return { ok: true, rules, readBack: readBack(rules, ctx) };
138+
const missed = rules.length ? missedWords(text, ctx) : "";
139+
return { ok: true, rules, readBack: readBack(rules, ctx), ...(missed ? { missed } : {}) };
140+
}
141+
142+
/**
143+
* Tap-to-fill rules for when typing didn't work (live bug, Oct 11): built from the time of day
144+
* and the host's name, never the reader's. Each `text` parses back to exactly its rule.
145+
*/
146+
export function suggestions(ctx: ParseContext): { label: string; text: string }[] {
147+
const out: { label: string; text: string }[] = [{ label: t("cond.chip.in", { min: 10 }), text: "in 10 min" }];
148+
const host = ctx.host && ctx.members.some((m) => m.name.toLowerCase() === ctx.host?.toLowerCase()) ? ctx.host : "";
149+
if (host) out.push({ label: t("cond.chip.with", { name: host }), text: `only with ${host}` });
150+
const now = Date.parse(ctx.now);
151+
const at = new Date(Math.ceil((now + 30 * MIN) / (60 * MIN)) * 60 * MIN).toISOString(); // the next full hour, 30+ min away
152+
const label = timeLabel(at, ctx.tz);
153+
const [h, ap] = label.split(" ");
154+
out.push({ label: t("cond.chip.after", { time: label }), text: `after ${h}${(ap ?? "").toLowerCase()}` });
155+
out.push({ label: t("cond.chip.walk", { min: 15 }), text: "15 min walk max" });
156+
return out;
85157
}
86158

87159
/** User-facing message for a failed parse (SRS-COND-7, SRS-COND-8, BR-C-2). */
@@ -91,4 +163,4 @@ export function parseError(r: Extract<ParseResult, { ok: false }>): string {
91163
return t("cond.didntCatch");
92164
}
93165

94-
export const conditions: ConditionsIcd.Conditions = { parse, readBack };
166+
export const conditions: ConditionsIcd.Conditions = { parse, readBack, suggestions };

‎apps/web/src/lib/conditions/rules.ts‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
export type RawRule =
88
| { type: "with"; value: string } // canonical member name as the group knows it
9-
| { type: "after" | "back_by"; value: string } // "HH:MM", 24 h, group time zone
9+
| { type: "after" | "back_by"; value: string; bare?: boolean } // "HH:MM", 24 h, group time zone; bare: no am/pm said
10+
| { type: "after_in"; value: number } // a delay from now in minutes: "in 10 min", "10min", "give me 10"
1011
| { type: "max_walk"; value: number }
1112
| { type: "max_step" | "min_step"; value: 1 | 2 | 3 | 4 };
1213

@@ -67,7 +68,36 @@ export function parseTime(raw: string): string | null {
6768
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
6869
}
6970

70-
const TIME = String.raw`(noon|midday|(?:quarter|half) (?:past|after|to) \w+|half \w+|\d{1,2}(?::\d{2})?\s*(?:am|pm|o'?clock)?)`;
71+
/** True when a spoken time names an hour with no am or pm ("after 10"), so the next upcoming one is meant. */
72+
export const isBareTime = (raw: string): boolean => {
73+
const s = raw.trim().toLowerCase().replace(/\./g, "");
74+
if (/(am|pm)\b|\b(noon|midday)\b/.test(s)) return false;
75+
const h = Number(s.match(/(\d{1,2})/)?.[1] ?? WORD_NUM[s.split(" ").at(-1) ?? ""] ?? 0);
76+
return h >= 1 && h <= 12;
77+
};
78+
79+
// A number followed by a minutes unit is a length of time, never a clock time ("after 10min").
80+
const TIME = String.raw`(noon|midday|(?:quarter|half) (?:past|after|to) \w+|half \w+|\d{1,2}(?::\d{2})?(?!\s*(?:m|mins?|minutes?)\b)\s*(?:am|pm|o'?clock)?)`;
81+
// Delays from now (live bug, Oct 11): "after 10min", "can come in 10", "give me 10", "in half an hour".
82+
const UNIT = String.raw`(?:m|mins?|minutes?)`;
83+
const DELAY = [
84+
new RegExp(
85+
String.raw`\b(?:in|after|give me|gimme|there in|here in)\s+(?:about\s+|like\s+)?(\d{1,3})\s*${UNIT}\b(?!\s*(?:walk|away|on foot|of walk))`,
86+
"gi",
87+
),
88+
new RegExp(
89+
String.raw`\b(?:in|give me|gimme)\s+(?:about\s+|like\s+)?(\d{1,3})\b(?!\s*(?::|am\b|pm\b|o'?clock|${UNIT}\b|walk|step))`,
90+
"gi",
91+
),
92+
// A bare "10min" opening a sentence or clause: "10min only with you".
93+
new RegExp(
94+
String.raw`(?:^|[,;.!]\s*)(\d{1,3})\s*${UNIT}\b(?!\s*(?:walk|away|on foot|of walk|max|tops|at most|or less|no more))`,
95+
"gi",
96+
),
97+
];
98+
// "with you", "only if you come", "you and me": the friend is talking to the host.
99+
const WITH_YOU =
100+
/\b(?:only with|with|only if|if|as long as)\s+you\b(?!\s+(?:guys|all|two|lot))|\b(?:you and me|me and you|you and i)\b/i;
71101
const AFTER = [
72102
new RegExp(String.raw`\b(?:after|not before|earliest|from)\s+${TIME}`, "gi"),
73103
new RegExp(String.raw`\bafter my\s+${TIME}`, "gi"),
@@ -136,7 +166,9 @@ const softName = (n: string) =>
136166
// Step words that need no Plan context (fixture core_40 and hard_20).
137167
// S1 (fine-tune): sitting or saying hi is the smallest step ("sit somewhere close", "just wanna say hi").
138168
const SMALLEST = /\b(porch|smallest|tiniest|only the small one|the small one|say (?:hi|hello)|sit(?:ting)?)\b/;
139-
const SHORT = /\b(short one|quick (one|version|lap)|shortest|short (?:lap|version))\b/;
169+
// Short-stay words (live bug, Oct 11) read as the shorter steps on a Plan; a Flare has no steps.
170+
const SHORT =
171+
/\b(short one|quick (one|version|lap|visit|stop)|shortest|short (?:lap|version|visit)|for a bit|stopping by|pop(?:ping)? by|swing by)\b/;
140172
const LONGEST = /\b(long one|longest|full walk|full loop|whole (walk|loop|thing)|biggest|big one)\b/;
141173

142174
const MIN_WORD: Record<string, number> = { five: 5, ten: 10, fifteen: 15, twenty: 20, thirty: 30 };
@@ -187,25 +219,37 @@ function stepRules(low: string, steps: StepName[]): RawRule[] {
187219
* Text to raw rules. `members` are display names already decrypted on this phone (BR-C-3).
188220
* Returns [] for a plain yes, didnt_catch for anything it can't read (SRS-COND-8).
189221
*/
190-
export function parseRaw(text: string, members: string[], steps: StepName[] = []): RawResult {
222+
export function parseRaw(text: string, members: string[], steps: StepName[] = [], host = ""): RawResult {
191223
// M0 (fine-tune): spoken durations to minutes before anything else, so "half hour" never reads as a time.
192224
const t = text
193225
.trim()
194226
.replace(/\bhalf(?:\s+an|\s+a|-|\s+)\s*hour\b/gi, "30 min")
195227
.replace(/\b(?:an|one) hour\b/gi, "60 min")
196228
.replace(/\b(five|ten|fifteen|twenty|thirty)(?:\s+|-)min/gi, (_, w: string) => `${MIN_WORD[w.toLowerCase()]} min`);
197229
if (!t) return { ok: false, reason: "didnt_catch" };
230+
const delays: number[] = [];
231+
let rest = t;
232+
for (const re of DELAY)
233+
rest = rest.replace(re, (_, n: string) => {
234+
delays.push(Number(n));
235+
return " ";
236+
});
198237
if (PLAIN_YES.test(t)) return { ok: true, rules: [] };
199238
if (OR_ALTERNATIVE.test(t)) return { ok: false, reason: "didnt_catch" };
200239
const out: RawRule[] = [];
201-
const low = t.toLowerCase();
240+
const low = rest.toLowerCase();
202241
const add = (r: RawRule) => {
203242
if (!out.some((x) => x.type === r.type && x.value === r.value)) out.push(r);
204243
};
244+
for (const n of delays) {
245+
if (n < 1 || n > 240) return { ok: false, reason: "didnt_catch" };
246+
add({ type: "after_in", value: n });
247+
}
248+
if (host && WITH_YOU.test(rest)) add({ type: "with", value: host });
205249

206250
// with(X): names after with / if / only if, joined by "and".
207251
const byLower = new Map(members.map((n) => [n.toLowerCase(), n]));
208-
for (const m of t.matchAll(NAME_RE)) {
252+
for (const m of rest.matchAll(NAME_RE)) {
209253
for (const cand of [m[1], m[2]]) {
210254
if (!cand) continue;
211255
const k = cand.toLowerCase();
@@ -214,22 +258,22 @@ export function parseRaw(text: string, members: string[], steps: StepName[] = []
214258
else if (/^[A-Z]/.test(cand) && !NOT_NAMES.has(k)) return { ok: false, reason: "not_in_group", name: cand };
215259
}
216260
}
217-
for (const name of members) if (softName(escapeRe(name)).test(t)) add({ type: "with", value: name });
261+
for (const name of members) if (softName(escapeRe(name)).test(rest)) add({ type: "with", value: name });
218262

219263
for (const re of AFTER)
220-
for (const m of t.matchAll(re)) {
264+
for (const m of rest.matchAll(re)) {
221265
const v = parseTime(m[1] ?? "");
222266
if (!v) return { ok: false, reason: "didnt_catch" };
223-
add({ type: "after", value: v });
267+
add({ type: "after", value: v, ...(isBareTime(m[1] ?? "") ? { bare: true } : {}) });
224268
}
225269
for (const re of BACK_BY)
226-
for (const m of t.matchAll(re)) {
270+
for (const m of rest.matchAll(re)) {
227271
const v = parseTime(m[1] ?? "");
228272
if (!v) return { ok: false, reason: "didnt_catch" };
229-
add({ type: "back_by", value: v });
273+
add({ type: "back_by", value: v, ...(isBareTime(m[1] ?? "") ? { bare: true } : {}) });
230274
}
231275
if (!out.some((r) => r.type === "back_by") && EVENT_BACK.test(low) && !/\bafter\b/.test(low)) {
232-
const m = t.match(EVENT_AT);
276+
const m = rest.match(EVENT_AT);
233277
const v = m?.[1] ? parseTime(m[1]) : null;
234278
if (v) add({ type: "back_by", value: v });
235279
}

‎apps/web/src/lib/screens/I2Condition.svelte‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
listening = false,
1616
canDictate = false,
1717
sample = false,
18+
suggest = [],
1819
oncheck,
1920
ondictate,
2021
onclose,
@@ -26,6 +27,8 @@
2627
listening?: boolean;
2728
canDictate?: boolean;
2829
sample?: boolean;
30+
/** Tap-to-fill rules after a miss: one tap fills and checks it. */
31+
suggest?: { label: string; text: string }[];
2932
oncheck?: (text: string) => void;
3033
ondictate?: () => void;
3134
onclose?: () => void;
@@ -37,7 +40,17 @@
3740
{#snippet overlay()}
3841
<Sheet inline title={t("cond.title")} description={t("cond.body")}>
3942
<TextField label={t("cond.label")} bind:value={text} placeholder={t("cond.placeholder")} mic={canDictate} {listening} onmic={ondictate} {error} multiline max={120} />
43+
{#if suggest.length}
44+
<ul class="chips" aria-label={t("cond.chipsAria")}>
45+
{#each suggest as c (c.text)}<li><button type="button" onclick={() => { text = c.text; oncheck?.(c.text); }}>{c.label}</button></li>{/each}
46+
</ul>
47+
{/if}
4048
<Button onclick={() => oncheck?.(text)} disabled={!text.trim()}>{t("cond.check")}</Button>
4149
</Sheet>
4250
{/snippet}
4351
</Screen>
52+
53+
<style>
54+
.chips { list-style: none; margin: 0; padding: 0; display: flex; gap: 8px; flex-wrap: wrap; }
55+
.chips button { min-height: 44px; padding: 0 14px; border-radius: var(--r-pill); background: var(--bg); border: 2px solid var(--line); color: inherit; font: inherit; font-weight: 700; }
56+
</style>

‎apps/web/src/lib/screens/I3Readback.svelte‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
chips = [],
1414
busy = false,
1515
sample = false,
16+
missed = "",
1617
onyes,
1718
onfix,
1819
onclose,
@@ -23,6 +24,8 @@
2324
chips?: string[];
2425
busy?: boolean;
2526
sample?: boolean;
27+
/** Words that made no rule: said back, never dropped quietly. */
28+
missed?: string;
2629
onyes?: () => void;
2730
onfix?: () => void;
2831
onclose?: () => void;
@@ -34,6 +37,7 @@
3437
{#snippet overlay()}
3538
<Sheet inline title={t("readback.title")}>
3639
<p class="t-title readback" aria-live="polite">{line}</p>
40+
{#if missed}<p class="missed">{t("readback.missed", { words: missed })}</p>{/if}
3741
{#if chips.length}
3842
<ul class="chips" aria-label={t("readback.chipsAria")}>
3943
{#each chips as c (c)}<li>{c}</li>{/each}
@@ -47,6 +51,7 @@
4751

4852
<style>
4953
.readback { margin: 0; }
54+
.missed { margin: 0; font-weight: 700; }
5055
.chips { list-style: none; margin: 0; padding: 0; display: flex; gap: 8px; flex-wrap: wrap; }
5156
.chips li { padding: 8px 14px; border-radius: var(--r-pill); background: var(--bg); border: 2px solid var(--line); font-weight: 700; }
5257
</style>

‎apps/web/src/lib/screens/LinkPage.svelte‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,9 @@
185185
{#if flow.screen === "invite"}
186186
<I1Invite view={v.state === "lit" ? "lit" : "kindling"} {info} have={flow.have} quorum={v.quorum} error={flow.error} busy={flow.busy} canCondition={flow.canCondition} onyes={() => flow.yes()} oncondition={() => flow.startCondition()} onclose={exit} />
187187
{:else if flow.screen === "condition"}
188-
<I2Condition {info} bind:text={condText} error={flow.condError} oncheck={(x) => flow.check(x)} onclose={() => flow.notNow()} />
188+
<I2Condition {info} bind:text={condText} error={flow.condError} suggest={flow.suggest} oncheck={(x) => flow.check(x)} onclose={() => flow.notNow()} />
189189
{:else if flow.screen === "readback"}
190-
<I3Readback {info} line={flow.readBack} chips={flow.chips} busy={flow.busy} onyes={() => flow.confirmReadBack()} onfix={() => flow.fix()} onclose={() => flow.notNow()} />
190+
<I3Readback {info} line={flow.readBack} missed={flow.missed} chips={flow.chips} busy={flow.busy} onyes={() => flow.confirmReadBack()} onfix={() => flow.fix()} onclose={() => flow.notNow()} />
191191
{:else if flow.screen === "not-suit"}
192192
<I5NotSuit {info} reasons={flow.notSuit} busy={flow.busy} onyes={() => flow.closerOne()} onno={() => flow.notNow()} />
193193
{:else if flow.screen === "in"}

‎apps/web/src/lib/screens/flow/link.svelte.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ export class LinkFlow {
7171
walkMin = $state<number | null>(null);
7272
/** Read-back of the parsed condition (I3). */
7373
readBack = $state("");
74+
/** Words the read-back couldn't turn into a rule, named back to the friend (live bug, Oct 11). */
75+
missed = $state("");
76+
/** Tap-to-fill rules after a miss, from the time of day and the host's name. */
77+
suggest = $state<{ label: string; text: string }[]>([]);
7478
chips = $state<string[]>([]);
7579
condError = $state("");
7680
notSuit = $state<{ icon: string; text: string }[]>([]);
@@ -502,22 +506,29 @@ export class LinkFlow {
502506
const c = this.#d.conditions;
503507
if (!c || !this.view) return;
504508
const steps = this.view.plan?.steps.map((s) => ({ step: s.step, name: this.stepName(s.step) }));
505-
const res = c.parse(text, {
509+
const ctx = {
506510
members: Object.entries(this.#names).map(([id, name]) => ({ id, name })),
507511
tz: this.#d.tz,
508512
now: new Date(this.serverNow).toISOString(),
509513
kind: this.view.kind,
510514
steps,
511-
});
515+
host: this.sealed?.host_name ?? "",
516+
};
517+
const res = c.parse(text, ctx);
518+
this.suggest = [];
512519
if (!res.ok) {
513-
this.condError =
514-
res.reason === "not_in_group"
515-
? t("cond.notInGroup", { name: res.name ?? "" })
516-
: res.reason === "too_many"
517-
? t("cond.tooMany")
518-
: t("cond.didntCatch");
520+
if (res.reason === "not_in_group") this.condError = t("cond.notInGroup", { name: res.name ?? "" });
521+
else if (res.reason === "too_many") this.condError = t("cond.tooMany");
522+
else {
523+
// Examples for this moment and this host, never the reader's own name; one tap fills one.
524+
this.suggest = c.suggestions?.(ctx) ?? [];
525+
const [a, b] = this.suggest;
526+
this.condError =
527+
a && b ? t("cond.didntCatchNow", { a: `"${a.text}"`, b: `"${b.text}"` }) : t("cond.didntCatch");
528+
}
519529
return;
520530
}
531+
this.missed = res.missed ?? "";
521532
this.#parsed = res.rules;
522533
this.readBack = res.readBack;
523534
this.chips = res.rules.map((r) => ruleChip(r, this.#d.tz, this.#names));

0 commit comments

Comments
 (0)