Skip to content

Commit 6ff2383

Browse files
Hotragnclaude
andcommitted
Fresh-eyes round 6 fixes: Your Flare after a reload, Ready. Share the link., demo with you, after 10 at 10:40 PM stays tonight
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Entire-Checkpoint: 01M4Q0HZDBSRD1AVW3JGP186X2
1 parent bbacf06 commit 6ff2383

22 files changed

Lines changed: 2576 additions & 22 deletions

File tree

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const delayMin = new WeakMap<Rule, number>();
2424
* A bare hour ("after 10") means the next one coming up from now: 8:22 PM plus "after 10" is
2525
* 10 PM, 9 AM plus "after 10" is 10 AM. Never a time already gone (live bug, Oct 11).
2626
*/
27-
function upcoming(hhmm: string, ctx: ParseContext): string {
27+
function upcoming(hhmm: string, ctx: ParseContext, kind: "after" | "back_by" = "back_by"): string {
2828
const [h, mi] = hhmm.split(":").map(Number) as [number, number];
2929
const h12 = h % 12;
3030
const mm = String(mi).padStart(2, "0");
@@ -35,6 +35,14 @@ function upcoming(hhmm: string, ctx: ParseContext): string {
3535
localToIso(`${String(h12 + (h >= 12 ? 0 : 12)).padStart(2, "0")}:${mm}`, ctx.now, ctx.tz),
3636
localToIso(`${String(h12).padStart(2, "0")}:${mm}`, tomorrow, ctx.tz),
3737
];
38+
// Round 6: "after 10" said at 10:40 PM means 10 PM, which already holds, not 10 AM tomorrow.
39+
if (kind === "after") {
40+
const held = tries
41+
.slice(0, 2)
42+
.filter((iso) => Date.parse(iso) <= now && now - Date.parse(iso) < 6 * 60 * MIN)
43+
.sort((a, b) => Date.parse(b) - Date.parse(a))[0];
44+
if (held) return held;
45+
}
3846
return tries.find((iso) => Date.parse(iso) >= now - MIN) ?? (tries[2] as string);
3947
}
4048

@@ -51,7 +59,7 @@ function toRule(r: RawRule, ctx: ParseContext): Rule | null {
5159
}
5260
case "after":
5361
case "back_by":
54-
return { type: r.type, at: r.bare ? upcoming(r.value, ctx) : localToIso(r.value, ctx.now, ctx.tz) };
62+
return { type: r.type, at: r.bare ? upcoming(r.value, ctx, r.type) : localToIso(r.value, ctx.now, ctx.tz) };
5563
case "max_walk":
5664
return { type: "max_walk", minutes: r.value };
5765
case "max_step":

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
onnear,
2424
onmenu,
2525
onagain,
26+
onlive,
2627
}: {
2728
golden?: Golden | null;
2829
weather?: { temp_f: number; rain_likely: boolean } | null;
@@ -35,6 +36,8 @@
3536
onlight?: () => void;
3637
/** Automations: one tap, the last crew and size, no photo needed (shown once you have sent one). */
3738
onagain?: () => void;
39+
/** Round 6: this phone's live Flare, after a reload. */
40+
onlive?: () => void;
3841
onplan?: () => void;
3942
onnear?: () => void;
4043
onmenu?: () => void;
@@ -79,6 +82,7 @@
7982
{#snippet actions()}
8083
<Button icon="camera" haptic="tap" onclick={onlight}>{t("home.light")}</Button>
8184
<div class="row">
85+
{#if onlive}<Button variant="text" onclick={onlive}>{t("home.live")}</Button>{/if}
8286
{#if onagain}<Button variant="text" onclick={onagain}>{t("home.again")}</Button>{/if}
8387
<Button variant="text" onclick={onplan}>{t("home.plan")}</Button>
8488
{#if !located}<Button variant="text" onclick={onnear}>{t("home.near")}</Button>{/if}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
<h1 class="t-display">{t("planSent.title")}</h1>
4444
<p class="t-sun">{t("planSent.body", { deadline })}</p>
4545
{:else}
46-
<h1 class="t-display">{t("sent.title")}</h1>
46+
<h1 class="t-display">{t(shareFirst ? "sent.titleShare" : "sent.title")}</h1>
4747
<p class="t-sun">{t(sentBodyKey(buzz))}</p>
4848
{#if buzz !== "push" && !installed}
4949
<details class="home">

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
$effect(() => {
6161
if (onboarding && (flow.screen !== "camera" || !home)) finishOnboarding();
6262
});
63+
// Round 6: after a reload, Home offers the live Flare and its link ("Your Flare").
64+
let liveSaved = $state(false);
65+
onMount(() => (liveSaved = flow.hasLive()));
66+
6367
// Live bug 5: say only what this device will do, and catch the host up when they come back.
6468
let pushOn = $state(false);
6569
let backNote = $state("");
@@ -239,7 +243,7 @@
239243
<H00Onboarding onlight={() => { finishOnboarding(); light(); }} ondemo={() => { finishOnboarding(); location.assign("/app/demo/"); }} />
240244
{/await}
241245
{:else if home && flow.screen === "camera"}
242-
<H0Home at={homeAt} onagain={flow.groupId ? () => { home = false; flow.startAgain(); } : undefined} golden={goldenNow} {weather} {green} located={!!homeAt} onlight={light} onplan={startPlan} onnear={nearHere} onmenu={() => (menu = true)} />
246+
<H0Home at={homeAt} onlive={liveSaved ? () => { if (flow.resume()) home = false; } : undefined} onagain={flow.groupId ? () => { home = false; flow.startAgain(); } : undefined} golden={goldenNow} {weather} {green} located={!!homeAt} onlight={light} onplan={startPlan} onnear={nearHere} onmenu={() => (menu = true)} />
243247
{:else if flow.screen === "camera"}
244248
<H1Camera view={camera} {stream} onshoot={shoot} onpick={pick} onallow={openCamera} onmenu={() => (menu = true)} onvoice={(d) => { stopCamera(); flow.useVoice(d); }} />
245249
{:else if flow.screen === "develop"}

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const HOST_GROUP = "flare.hostGroup"; // id only; the key stays in IndexedDB
4848
const DRAFT_BUDGET_MS = 6000; // the server answers with the template after 6 s (openapi drafts)
4949
const CLOUD_WAIT_MS = 40_000; // DigitalOcean answers in about 30 s; the server gives up at 35 s (ADR-0022)
5050

51+
const LIVE_KEY = "flare.live";
52+
5153
export class HostFlow {
5254
screen = $state<HostScreen>("camera");
5355
photoBlob: Blob | null = null;
@@ -263,6 +265,34 @@ export class HostFlow {
263265
if (n) this.spot = { name: n, key: n, lat: this.spot?.lat ?? 0, lon: this.spot?.lon ?? 0 };
264266
}
265267

268+
#saved(): { id: string; link: string; until: number } | null {
269+
try {
270+
const v = JSON.parse(localStorage.getItem(LIVE_KEY) ?? "null") as {
271+
id: string;
272+
link: string;
273+
until: number;
274+
} | null;
275+
return v && typeof v.id === "string" && typeof v.link === "string" && v.until > Date.now() ? v : null;
276+
} catch {
277+
return null;
278+
}
279+
}
280+
281+
/** Round 6: this phone sent a Flare that may still be live. */
282+
hasLive(): boolean {
283+
return !!this.#saved();
284+
}
285+
286+
/** Round 6: reopening the app brings back this phone's live Flare and its link. */
287+
resume(): boolean {
288+
const v = this.#saved();
289+
if (!v) return false;
290+
this.link = v.link;
291+
this.screen = "kindling";
292+
this.#follow(v.id);
293+
return true;
294+
}
295+
266296
/** Automations, Again: the last crew and size, no photo; place-word chips or your own words. */
267297
startAgain(): void {
268298
this.photoBlob = null;
@@ -407,6 +437,10 @@ export class HostFlow {
407437
}
408438
await d.store.rememberGathering(id, group.groupId);
409439
this.link = link;
440+
try {
441+
// Round 6: a reload must not lose the live Flare. Same origin as the key store; cleared at Gone.
442+
localStorage.setItem(LIVE_KEY, JSON.stringify({ id, link, until: Date.now() + 4 * 3600_000 }));
443+
} catch {}
410444
this.view = r.value ?? null;
411445
if (this.photoBlob && kind === "flare") void this.#uploadPhoto(id, key, this.photoBlob);
412446
// ADR-0023: the real photo goes to Telegram only when the group turned photos on.
@@ -575,7 +609,12 @@ export class HostFlow {
575609
} else if (v.state === "ember") feedback("ember");
576610
else if (v.state === "gone") feedback("gone");
577611
}
578-
if (v.state === "gone") void this.#recordHot(v);
612+
if (v.state === "gone") {
613+
void this.#recordHot(v);
614+
try {
615+
localStorage.removeItem(LIVE_KEY);
616+
} catch {}
617+
}
579618
if (this.screen !== "sent" || v.state !== "kindling") this.#route();
580619
});
581620
}

‎apps/web/src/routes/app/demo/+page.svelte‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
4141
now: new Date(demo.now).toISOString(),
4242
kind: "flare" as const,
43+
host: me, // "only with you" means the host, as on a real friend's phone
4344
});
4445
// The same parser a friend's phone uses (fresh-eyes round 3: "Check it" checked nothing).
4546
function checkCondition(text: string) {

‎apps/web/test/ai/conditions-live.test.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ describe("live bug: delays and bare hours", () => {
6060
expect(simple("after 10", 20, 22)).toBe("after=22:00");
6161
expect(simple("after 10", 9, 0)).toBe("after=10:00");
6262
expect(simple("after 10am is good", 20, 22)).toBe("after=10:00"); // said with am: kept as said
63+
expect(simple("after 10", 22, 40)).toBe("after=22:00"); // round 6: 10 PM already holds, not 10 AM tomorrow
6364
});
6465
it("with you means the host", () => {
6566
for (const s of ["with you", "only with you", "if you're there", "you and me", "only if you come"])
@@ -76,7 +77,7 @@ describe("live bug: delays and bare hours", () => {
7677
]);
7778
for (const c of chips) expect(parse(c.text, ctx).ok, c.text).toBe(true);
7879
});
79-
it("property: a delay or a bare hour never resolves before the Flare's now", () => {
80+
it("property: back_by and delays never resolve before now; a bare after at most 6 h back", () => {
8081
fc.assert(
8182
fc.property(
8283
fc.integer({ min: 0, max: 23 }),
@@ -89,7 +90,14 @@ describe("live bug: delays and bare hours", () => {
8990
for (const text of [`after ${hour}`, `back by ${hour}`, `in ${min} min`, `${min}min`]) {
9091
const r = parse(text, ctx);
9192
if (!r.ok) return false;
92-
for (const x of r.rules) if ("at" in x && Date.parse(x.at) < now - 60_000) return false;
93+
for (const x of r.rules) {
94+
if (!("at" in x)) continue;
95+
const at = Date.parse(x.at);
96+
// back_by and delays are never in the past; a bare "after" may be one that already
97+
// holds, but never more than 6 h back (round 6: "after 10" at 10:40 PM is 10 PM).
98+
if (x.type === "back_by" && at < now - 60_000) return false;
99+
if (x.type === "after" && at < now - 6 * 3_600_000) return false;
100+
}
93101
}
94102
return true;
95103
},

‎apps/web/test/e2e/cloud-only.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ try {
148148
const where = host.getByLabel("Where are you?");
149149
if (await where.isVisible().catch(() => false)) await where.fill("Ford Woods Park, by the pond");
150150
await host.getByRole("button", { name: "Send it" }).click();
151-
await host.getByRole("heading", { name: "Sent." }).waitFor({ timeout: 15_000 });
151+
await host.getByRole("heading", { name: /^(Sent\.|Ready\. Share the link\.)$/ }).waitFor({ timeout: 15_000 });
152152
await host.getByRole("button", { name: "Copy as text" }).click();
153153
const link =
154154
(await host.evaluate(() => navigator.clipboard.readText())).match(

‎apps/web/test/e2e/flow.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ for (let run = 1; run <= RUNS; run++) {
119119
const name = host.getByLabel("Your first name");
120120
if (await name.isVisible().catch(() => false)) await name.fill("Fuego");
121121
await host.getByRole("button", { name: "Send it" }).click();
122-
await host.getByRole("heading", { name: "Sent." }).waitFor({ timeout: 15_000 });
122+
await host.getByRole("heading", { name: /^(Sent\.|Ready\. Share the link\.)$/ }).waitFor({ timeout: 15_000 });
123123
const hostMs = Date.now() - t0;
124124
if (run === 1) await host.screenshot({ path: path.join(OUT, "e2e-h4-sent.png") });
125125
await host.getByRole("button", { name: "Copy as text" }).click();

‎apps/web/test/e2e/photo-fit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ try {
250250
const name = host.getByLabel("Your first name");
251251
if (await name.isVisible().catch(() => false)) await name.fill("Fuego");
252252
await host.getByRole("button", { name: "Send it" }).click();
253-
await host.getByRole("heading", { name: "Sent." }).waitFor({ timeout: 15_000 });
253+
await host.getByRole("heading", { name: /^(Sent\.|Ready\. Share the link\.)$/ }).waitFor({ timeout: 15_000 });
254254
await host.getByRole("button", { name: "Copy as text" }).click();
255255
const clip = await host.evaluate(() => navigator.clipboard.readText());
256256
const link = clip.match(/(\/g\/[0-9a-f-]{36}\/#k=[A-Za-z0-9_-]{43})/)?.[1];

0 commit comments

Comments
 (0)