Skip to content

Commit 71cc82d

Browse files
Programcodeclaude
andcommitted
Keep a paused prep bank on the Lock Screen, and send every new signed version
The timer's round could not tell which clock was on screen once nothing ran, so pausing prep from the Lock Screen ended the activity (or showed the paused speech) and Resume could not restart that prep. The session now keeps the bank last paused while it ran as held; the activity shows it paused and its Resume restarts it, until another clock starts, the bank runs out or the format changes. Stored with the round. Purchases skipped any transaction whose id this run had already delivered, so a new unfinished version of it (a refund, a new expiry) was counted as applied without being sent or finished. The cache is now keyed by the signed version, and updates and pending deliveries share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5b11a2b commit 71cc82d

6 files changed

Lines changed: 110 additions & 27 deletions

File tree

‎apps/mobile/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E
3535
- A case's and an attack's next steps are `result-links.ts`: "Open in Crossfire" fills the next Crossfire form through the one-shot `crossfirePrefill` slot (the website's route state; the builders are `packages/core/src/crossfire/prefill.ts`), and a case is built into an unsaved map with `flowchartFromCase` (`packages/core/src/flowchart.ts`, shared with the website), only in the debate the Flowchart is on, as on the website.
3636
- Limits mirror the website's subscription dialogs: `refusal.ts` turns a 402 or 403 refusal (with the limit details `errorFrom` keeps) into the `limit` sheet's parameters and holds the website's messages (`limit-message.ts`); the tools open the sheet instead of an inline error, and Ask, Crossfire and Sparring open it beside theirs. Where the website offers Pro, the sheet's next step is `plans.tsx` in a build with StoreKit, else the plan in You. `usage.ts` reads today's uses for the line under each tool's title (`DailyUsageLine`, the website's DailyUsage) and reads again after every AI request (`onAIRequestSettled`). `session-quota.ts` asks what a Crossfire or Sparring round costs (`quotaCheck`), shows it above Start (`SessionCostNote`) and opens the sheet before a round the day cannot cover.
3737
- Timer mirrors the website's free round timer (`src/components/free-tools/DebateTimer.tsx`), not the in-app TimerPage: the seven formats with their speeches, points-of-information windows, prep banks, overtime and grace come from `packages/core/src/free-tools/` (moved there from `src/lib/free-tools/`, which re-exports them; the custom format's URL codec stays in the website). `timer.ts` holds the session (format choice, round, mute), its stored form and the Live Activity's times (`timer.test.ts`); `timer-store.ts` keeps it and the custom formats saved on the device in AsyncStorage, so a running speech survives closing the timer and restarting the app. `components/timer-runner.tsx`, in the root layout, sounds the cues (a knock when POIs open or close, a beep at one minute left, two at time; after a gap in the background only time up) and stops an exhausted prep bank, with signals mounted only while a clock runs, and keeps the Live Activity in step. `timer-signals.ts` plays the bundled sounds in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while the timer screen shows a running clock; `timer-signals.web.ts` only records the signals for the screenshot script.
38-
- The Lock Screen timer: `src/widgets/timer-widgets.tsx` holds the Live Activity (`DebateTimerActivity`: Lock Screen banner and Dynamic Island) and the lock-screen widget (`DebateTimerWidget`, the `widgets[]` entry in `app.config.ts`), drawn by iOS from SwiftUI timer texts so they count without the app. Their functions run in the extension's own runtime ('widget' directive): no imports or outside names, every string in the props. `live-activity.ts` loads them only when `nativeCapabilities().liveActivity` (a development build with `DEBATELAB_APP_GROUP`), takes over or ends an activity left by an earlier launch, and ends the activity when no clock has time on it; `live-activity.web.ts` records the state for the screenshot script. Round 4's Home Screen widget adds its own entry to the same `widgets[]` array. From iOS 17 the activity has Pause or Resume and Next speech buttons with fixed targets (`timer-toggle`, `timer-next`): iOS runs them in the app's process, `live-activity.ts` hears them through `addUserInteractionListener` and applies `applyTimerActivityAction` (`timer.ts`) to the stored round, and `TimerRunner` sends the new clock back. `timer-alerts.ts` sets a local, time-sensitive notification for the moment the running clock runs out (`timerAlertAt`) whenever the app is off screen, because the app's own signals stop when iOS suspends it; it asks for permission the first time a clock starts.
38+
- The Lock Screen timer: `src/widgets/timer-widgets.tsx` holds the Live Activity (`DebateTimerActivity`: Lock Screen banner and Dynamic Island) and the lock-screen widget (`DebateTimerWidget`, the `widgets[]` entry in `app.config.ts`), drawn by iOS from SwiftUI timer texts so they count without the app. Their functions run in the extension's own runtime ('widget' directive): no imports or outside names, every string in the props. `live-activity.ts` loads them only when `nativeCapabilities().liveActivity` (a development build with `DEBATELAB_APP_GROUP`), takes over or ends an activity left by an earlier launch, and ends the activity when no clock has time on it; `live-activity.web.ts` records the state for the screenshot script. Round 4's Home Screen widget adds its own entry to the same `widgets[]` array. From iOS 17 the activity has Pause or Resume and Next speech buttons with fixed targets (`timer-toggle`, `timer-next`): iOS runs them in the app's process, `live-activity.ts` hears them through `addUserInteractionListener` and applies `applyTimerActivityAction` (`timer.ts`) to the stored round, and `TimerRunner` sends the new clock back. A prep bank paused while it ran stays on the Lock Screen as the session's `held` bank, so Resume restarts it; `settleHeld` keeps `held` in step on every change (`updateSession`), from either screen's buttons. `timer-alerts.ts` sets a local, time-sensitive notification for the moment the running clock runs out (`timerAlertAt`) whenever the app is off screen, because the app's own signals stop when iOS suspends it; it asks for permission the first time a clock starts.
3939
- The generating Live Activity (`widgets/generating-activity.tsx`, `generating-activity.ts`, the words in `generating-activity-state.ts`): `useLiveRun({ tool, parts })` in Generate, Attack, Brainstorm and Factoids starts it with the run's motion, updates the step and the parts written, `complete()` marks it ready and `end('failed')` failed. Ollie's poses are copied into the App Group folder by `widget-images.ts`, which the Current Debate widget uses too. When the app leaves the screen the activity is given a stale date 30 seconds later, and the layout then says to open DebateOwl, because the stream stops while iOS suspends the app; the answer comes back from `ai_run_results` when it opens. A finished or failed run stays on the Lock Screen for 15 minutes when the app is away. Every live run also sends `notifyWhenReady` with this install's id (`setReadyPushInstall`, set at launch), so the `ai` function pushes `result_ready` to this phone once the answer is stored; `push.ts` hides that push while the app is on screen.
4040
- Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. A saved round opens read only from History and the Debate tab (`crossfire-round/[id].tsx`, `fetchCrossfireRound`, with an offline copy): debrief, spoken delivery and transcript. The website lists saved rounds but cannot open them.
4141
- Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them and exports the website's Sparring PDF (`sparringExportHtml`; Word export is not in the app).

‎apps/mobile/src/lib/purchases.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,19 @@ describe('delivering unfinished transactions', () => {
167167
expect(env.posts).toHaveLength(2);
168168
});
169169

170+
it('sends and finishes a new unfinished version of a transaction it already delivered', async () => {
171+
const bought = transaction(USER_A);
172+
store.unfinishedList.push(bought);
173+
env.respond = () => Promise.resolve(APPLIED);
174+
await expect(deliverPending(USER_A)).resolves.toBe(1);
175+
176+
// The same id, signed again (a refund or a new expiry), waiting unfinished.
177+
store.unfinishedList.push({ ...bought, signedTransaction: `${bought.signedTransaction}-v2` });
178+
await expect(deliverPending(USER_A)).resolves.toBe(1);
179+
expect(env.posts).toHaveLength(2);
180+
expect(store.unfinishedList).toHaveLength(0);
181+
});
182+
170183
it('does not send a purchase under the account that signed in while the sheet was open', async () => {
171184
const sheet = deferred<StorePurchaseResult>();
172185
let opened = false;

‎apps/mobile/src/lib/purchases.ts‎

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,11 @@ const REQUEST_BUDGET_MS = 20_000;
8383
// deliverPending and an update event never send the same transaction at once.
8484
const serial = createSerialQueue();
8585

86-
// What the server answered for each transaction finished in this run of the
87-
// app, so a purchase that a queued deliverPending already sent is not sent again.
86+
// What the server answered for each signed transaction it took in this run of
87+
// the app, so a version a queued deliverPending already sent is not sent again.
88+
// Keyed by the signed version, not the id: StoreKit signs the same transaction
89+
// again when it changes (a refund, a new expiry), and that version must be sent.
8890
const delivered = new Map<string, PurchasedPlan | null>();
89-
/** The signed versions the server has taken this run, so an update that repeats one is not sent again. */
90-
const deliveredVersions = new Set<string>();
9191

9292
function planOf(payload: Record<string, unknown> | null): PurchasedPlan | null {
9393
const plan = payload?.entitlement;
@@ -139,11 +139,11 @@ async function post(transaction: StoreTransaction, userId: string): Promise<Deli
139139
* and sending it again is harmless.
140140
*/
141141
async function deliver(transaction: StoreTransaction, userId: string): Promise<Delivery> {
142-
if (delivered.has(transaction.transactionId)) return { ok: true, plan: delivered.get(transaction.transactionId) ?? null };
142+
const version = transaction.signedTransaction;
143+
if (delivered.has(version)) return { ok: true, plan: delivered.get(version) ?? null };
143144
const result = await post(transaction, userId);
144145
if (!result.ok) return result;
145-
delivered.set(transaction.transactionId, result.plan);
146-
deliveredVersions.add(transaction.signedTransaction);
146+
delivered.set(version, result.plan);
147147
await storeKit()
148148
?.finish(transaction.transactionId)
149149
.catch((cause: unknown) => console.warn('Could not finish an App Store transaction:', storeKitErrorCode(cause) ?? cause));
@@ -264,7 +264,6 @@ export async function manageSubscriptions(): Promise<boolean> {
264264
);
265265
}
266266

267-
268267
/**
269268
* A transaction StoreKit reports as updated: sent even when this run already
270269
* delivered its id, because an update can be a new signed version of the same
@@ -274,14 +273,7 @@ export async function manageSubscriptions(): Promise<boolean> {
274273
function deliverUpdate(transaction: StoreTransaction, userId: string): Promise<void> {
275274
if (!belongsTo(transaction, userId)) return Promise.resolve();
276275
return serial(async () => {
277-
if (deliveredVersions.has(transaction.signedTransaction)) return;
278-
const result = await post(transaction, userId);
279-
if (!result.ok) return;
280-
delivered.set(transaction.transactionId, result.plan);
281-
deliveredVersions.add(transaction.signedTransaction);
282-
await storeKit()
283-
?.finish(transaction.transactionId)
284-
.catch((cause: unknown) => console.warn('Could not finish an App Store transaction:', storeKitErrorCode(cause) ?? cause));
276+
await deliver(transaction, userId);
285277
});
286278
}
287279

‎apps/mobile/src/lib/timer-store.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import AsyncStorage from '@react-native-async-storage/async-storage';
22
import { useSyncExternalStore } from 'react';
33

4-
import { initialSession, parseSavedFormats, parseSession, serializeSession, upsertSavedFormat, type SavedFormat, type TimerSession } from './timer';
4+
import { initialSession, parseSavedFormats, parseSession, serializeSession, settleHeld, upsertSavedFormat, type SavedFormat, type TimerSession } from './timer';
55

66
// The timer's round and the custom formats saved on this device, as the
77
// website keeps its round in localStorage. Not tied to an account: a timer
@@ -64,7 +64,8 @@ export function getTimer(): TimerStoreState {
6464
}
6565

6666
export function updateSession(change: (current: TimerSession) => TimerSession) {
67-
const next = change(state.session);
67+
// Which prep bank was last paused, whichever button stopped it (timer.ts's settleHeld).
68+
const next = settleHeld(state.session, change(state.session));
6869
if (next === state.session) return;
6970
if (!state.loaded) changedBeforeLoad.session = true;
7071
state = { ...state, session: next };

‎apps/mobile/src/lib/timer.test.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { togglePrep, toggleSpeech } from '@debatelab/core/free-tools/round-timer';
1+
import { settlePrep, togglePrep, toggleSpeech } from '@debatelab/core/free-tools/round-timer';
22
import { describe, expect, it } from 'vitest';
33

44
import {
@@ -7,6 +7,7 @@ import {
77
parseSavedFormats,
88
parseSession,
99
serializeSession,
10+
settleHeld,
1011
timerActivityState,
1112
timerAlertAt,
1213
type TimerSession,
@@ -71,9 +72,37 @@ describe('Live Activity buttons and the time-up alert', () => {
7172
const paused = applyTimerActivityAction(session, 'toggle', T0 + 20_000);
7273
expect(paused.round.prep.b.deadline).toBeNull();
7374
expect(paused.round.speech.startedAt).toBeNull();
74-
// With no prep running, the same button resumes the speech where it stopped.
75+
// The paused bank stays on the Lock Screen, so the same button restarts it, not the speech.
7576
const resumed = applyTimerActivityAction(paused, 'toggle', T0 + 30_000);
76-
expect(resumed.round.speech).toEqual({ elapsedMs: 10_000, startedAt: T0 + 30_000 });
77+
expect(resumed.round.prep.b.deadline).not.toBeNull();
78+
expect(resumed.round.speech).toEqual({ elapsedMs: 10_000, startedAt: null });
79+
// With no bank held, it resumes the speech where it stopped.
80+
const speech = applyTimerActivityAction({ ...paused, held: null }, 'toggle', T0 + 30_000);
81+
expect(speech.round.speech).toEqual({ elapsedMs: 10_000, startedAt: T0 + 30_000 });
82+
});
83+
84+
it('keeps a prep bank paused from the Lock Screen on it, so Resume restarts that prep', () => {
85+
let session = initialSession({ id: 'custom', custom });
86+
session = { ...session, round: togglePrep(session.round, 'b', T0) };
87+
const paused = applyTimerActivityAction(session, 'toggle', T0 + 30_000);
88+
const shown = timerActivityState(paused, T0 + 40_000, { ...text, controls: true });
89+
expect(shown).toMatchObject({ kind: 'prep', side: 'b', pausedAt: T0 + 40_000, endsAt: T0 + 40_000 + 90_000 });
90+
const resumed = applyTimerActivityAction(paused, 'toggle', T0 + 50_000);
91+
expect(resumed.round.prep.b.deadline).toBe(T0 + 50_000 + 90_000);
92+
expect(resumed.round.speech.startedAt).toBeNull();
93+
});
94+
95+
it('lets go of a paused bank once another clock starts, it runs out, or the format changes', () => {
96+
let session = initialSession({ id: 'custom', custom });
97+
session = { ...session, round: togglePrep(session.round, 'a', T0) };
98+
const paused = applyTimerActivityAction(session, 'toggle', T0 + 10_000);
99+
expect(paused.held).toBe('a');
100+
expect(settleHeld(paused, { ...paused, round: toggleSpeech(paused.round, T0 + 20_000) }).held).toBeUndefined();
101+
expect(settleHeld(paused, { ...paused, choice: { id: 'bp' }, round: initialSession({ id: 'bp' }).round }).held).toBeUndefined();
102+
const emptied = { ...session, round: settlePrep(session.round, T0 + 121_000) };
103+
expect(settleHeld(session, emptied).held).toBeUndefined();
104+
// Stored and read back with the round.
105+
expect(parseSession(JSON.parse(JSON.stringify(serializeSession(paused))))?.held).toBe('a');
77106
});
78107

79108
it('moves on to a fresh, paused next speech, and not past the last one', () => {

0 commit comments

Comments
 (0)