Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"expo": {
"name": "BaseAut",
"slug": "baseaut",
"version": "1.1.3",
"version": "1.1.4",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "baseaut",
Expand Down
73 changes: 55 additions & 18 deletions features/exercises/components/stopwatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
Timer,
} from "lucide-react-native";
import React, { useEffect, useRef, useState } from "react";
import { Image, Pressable, Text, View } from "react-native";
import { AppState, Image, Pressable, Text, View } from "react-native";

export type StopwatchVariant = "minimize" | "form";

Expand Down Expand Up @@ -68,6 +68,20 @@ export type StopwatchProps = {
stopSpotlightKey?: string | string[];
};

/** Wall-clock anchor backing the stopwatch's own (uncontrolled) timer. */
type StopwatchAnchor = {
/** Seconds accumulated before the current running stretch. */
baseSeconds: number;
/** When the current running stretch started, or `null` while paused. */
startedAtMs: number | null;
};

/** Elapsed seconds of `anchor` measured at `nowMs`. */
function anchorSeconds(anchor: StopwatchAnchor, nowMs: number): number {
if (anchor.startedAtMs === null) return anchor.baseSeconds;
return anchor.baseSeconds + Math.max(0, Math.floor((nowMs - anchor.startedAtMs) / 1000));
}

function formatTime(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safe / 60);
Expand All @@ -90,6 +104,13 @@ function formatTime(seconds: number): string {
* counter for the episode.
*
* @remarks
* Every elapsed time on the card — the timer in uncontrolled mode and the live
* crisis/flight counters — is derived from a wall-clock anchor rather than from
* a counter advanced once per tick. Android suspends JavaScript timers while
* the screen is off, so a counter would lose every second the device slept; the
* interval here only refreshes what is displayed, and an `AppState` listener
* makes it catch up the instant the app comes back to the foreground.
*
* Each of the six controls can be registered as a tutorial spotlight target via
* its own `*SpotlightKey` prop. The targets are the control pressables
* themselves rather than a wrapper around the card, so the highlight ring hugs
Expand Down Expand Up @@ -125,7 +146,10 @@ export function Stopwatch({
const colors = useThemeColors();
const sim = useTutorialSimulation();
const [internalIsRunning, setInternalIsRunning] = useState(autoStart);
const [internalSeconds, setInternalSeconds] = useState(initialSeconds);
const [internalTimer, setInternalTimer] = useState<StopwatchAnchor>(() => ({
baseSeconds: initialSeconds,
startedAtMs: autoStart ? Date.now() : null,
}));
const [nowMs, setNowMs] = useState(() => Date.now());
const criseStartedAtRef = useRef<number | null>(null);
const fugaStartedAtRef = useRef<number | null>(null);
Expand Down Expand Up @@ -175,11 +199,33 @@ export function Stopwatch({
fugaStartedAtRef.current = null;
}

const isRunning = controlledIsRunning !== undefined ? controlledIsRunning : internalIsRunning;
const isInternalTimerRunning = controlledSeconds === undefined && isRunning;

useEffect(() => {
if (controlledSeconds !== undefined) return;
setInternalTimer((prev) => {
if (isRunning === (prev.startedAtMs !== null)) return prev;
const nowMs = Date.now();
return isRunning
? { baseSeconds: prev.baseSeconds, startedAtMs: nowMs }
: { baseSeconds: anchorSeconds(prev, nowMs), startedAtMs: null };
});
}, [isRunning, controlledSeconds]);

useEffect(() => {
if (!isCriseActive && !isFugaActive) return;
const id = setInterval(() => setNowMs(Date.now()), 500);
return () => clearInterval(id);
}, [isCriseActive, isFugaActive]);
if (!isCriseActive && !isFugaActive && !isInternalTimerRunning) return;
const tick = () => setNowMs(Date.now());
tick();
const id = setInterval(tick, 500);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") tick();
});
return () => {
clearInterval(id);
subscription.remove();
};
}, [isCriseActive, isFugaActive, isInternalTimerRunning]);

const criseLabel =
isCriseActive && criseStartedAtRef.current != null
Expand All @@ -190,17 +236,8 @@ export function Stopwatch({
? formatTime((nowMs - fugaStartedAtRef.current) / 1000)
: "Fuga";

const isRunning = controlledIsRunning !== undefined ? controlledIsRunning : internalIsRunning;
const seconds = controlledSeconds !== undefined ? controlledSeconds : internalSeconds;

useEffect(() => {
if (controlledSeconds !== undefined) return;
if (!isRunning) return;
const id = setInterval(() => {
setInternalSeconds((current) => current + 1);
}, 1000);
return () => clearInterval(id);
}, [isRunning, controlledSeconds]);
const seconds =
controlledSeconds !== undefined ? controlledSeconds : anchorSeconds(internalTimer, nowMs);

const handleToggle = () => {
const next = !isRunning;
Expand All @@ -219,7 +256,7 @@ export function Stopwatch({

const handleRestart = () => {
if (controlledSeconds === undefined) {
setInternalSeconds(0);
setInternalTimer({ baseSeconds: 0, startedAtMs: Date.now() });
}
if (controlledIsRunning === undefined) {
setInternalIsRunning(true);
Expand Down
151 changes: 113 additions & 38 deletions features/sessions/contexts/session-global-context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { AppState } from "react-native";

/** Execution mode of an active session. */
export type SessionType = "semi-structured" | "structured";
Expand Down Expand Up @@ -29,6 +30,18 @@ export interface ActiveSessionInfo {
totalElapsed?: number;
/** Flight intervals (start/end on the total stopwatch) used by the Control Record. */
fugaIntervals?: { start: number; end: number }[];
/**
* Wall-clock instant (ms) the running stretch of the exercise stopwatch
* started at, or `null` while it is paused. Owned by
* {@link SessionGlobalProvider}; callers never set it.
*/
timerStartedAtMs?: number | null;
/** Exercise seconds accumulated before {@link ActiveSessionInfo.timerStartedAtMs}. */
timerBaseSeconds?: number;
/** Wall-clock instant (ms) the total stopwatch counts from. */
totalStartedAtMs?: number;
/** Total seconds accumulated before {@link ActiveSessionInfo.totalStartedAtMs}. */
totalBaseSeconds?: number;
/**
* True when the session was started inside a tutorial simulation (mock data).
* The global session widget never surfaces these, and concurrent-session
Expand All @@ -52,6 +65,35 @@ export function formatSessionClock(totalSeconds: number): string {
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}

/**
* Recomputes both stopwatches of a session from their wall-clock anchors.
*
* @param session - Session entry to refresh.
* @param nowMs - Reference instant, normally `Date.now()`.
* @returns The session with updated `timeElapsed`/`totalElapsed`, or the same
* reference when neither value changed.
*/
function tickSession(session: ActiveSessionInfo, nowMs: number): ActiveSessionInfo {
const secondsSince = (startedAtMs: number) =>
Math.max(0, Math.floor((nowMs - startedAtMs) / 1000));

const timeElapsed =
(session.timerBaseSeconds ?? 0) +
(session.isRunning && session.timerStartedAtMs != null
? secondsSince(session.timerStartedAtMs)
: 0);
const totalElapsed = Math.min(
SESSION_TOTAL_CAP_SECONDS,
(session.totalBaseSeconds ?? 0) +
(session.totalStartedAtMs != null ? secondsSince(session.totalStartedAtMs) : 0),
);

if (timeElapsed === session.timeElapsed && totalElapsed === session.totalElapsed) {
return session;
}
return { ...session, timeElapsed, totalElapsed };
}

/** Value exposed by the global session context. */
interface SessionGlobalContextData {
activeSessions: Record<string, ActiveSessionInfo>;
Expand Down Expand Up @@ -82,58 +124,74 @@ const SessionGlobalContext = createContext<SessionGlobalContextData>({} as Sessi

/**
* Provides the global registry of active sessions and a 1-second ticker that
* advances each session's exercise and total stopwatches (the total runs
* refreshes each session's exercise and total stopwatches (the total runs
* continuously up to {@link SESSION_TOTAL_CAP_SECONDS}).
*
* @remarks
* Neither stopwatch is incremented by the ticker: both are derived from the
* wall-clock anchors kept on the session entry ({@link tickSession}). Android
* suspends JavaScript timers while the screen is off, so a counter advanced one
* second per tick silently lost every second the device slept — the reason the
* stopwatch appeared to stop when the user locked the phone mid-session. With
* the anchors the ticker only refreshes what is displayed, and the elapsed time
* is already correct the moment the screen comes back. The same recomputation
* runs on the `AppState` transition to `active` so the UI catches up without
* waiting for the next tick.
*/
export function SessionGlobalProvider({ children }: { children: ReactNode }) {
const [activeSessions, setActiveSessions] = useState<Record<string, ActiveSessionInfo>>({});

useEffect(() => {
const id = setInterval(() => {
const refresh = () => {
const nowMs = Date.now();
setActiveSessions((prev) => {
let hasChanges = false;
const next = { ...prev };
for (const key in next) {
let entry = next[key];
let changed = false;

if (entry.isRunning) {
entry = { ...entry, timeElapsed: entry.timeElapsed + 1 };
changed = true;
}

const total = entry.totalElapsed ?? 0;
if (total < SESSION_TOTAL_CAP_SECONDS) {
entry = { ...entry, totalElapsed: total + 1 };
changed = true;
}

if (changed) {
next[key] = entry;
const ticked = tickSession(next[key], nowMs);
if (ticked !== next[key]) {
next[key] = ticked;
hasChanges = true;
}
}
return hasChanges ? next : prev;
});
}, 1000);
return () => clearInterval(id);
};

const id = setInterval(refresh, 1000);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") refresh();
});
return () => {
clearInterval(id);
subscription.remove();
};
}, []);

const registerSession = (session: ActiveSessionInfo) => {
setActiveSessions((prev) => ({
...prev,
[session.sessionId]: {
...session,
timeElapsed: prev[session.sessionId]?.timeElapsed ?? session.timeElapsed ?? 0,
isRunning: prev[session.sessionId]?.isRunning ?? session.isRunning ?? true,
historico: prev[session.sessionId]?.historico ?? session.historico,
activeExerciseId: prev[session.sessionId]?.activeExerciseId ?? session.activeExerciseId,
isEngagementRunning: prev[session.sessionId]?.isEngagementRunning ?? session.isEngagementRunning ?? false,
isFormVisible: prev[session.sessionId]?.isFormVisible ?? session.isFormVisible ?? true,
totalElapsed: prev[session.sessionId]?.totalElapsed ?? session.totalElapsed ?? 0,
fugaIntervals: prev[session.sessionId]?.fugaIntervals ?? session.fugaIntervals ?? [],
},
}));
const nowMs = Date.now();
setActiveSessions((prev) => {
const existing = prev[session.sessionId];
const isRunning = existing?.isRunning ?? session.isRunning ?? true;
return {
...prev,
[session.sessionId]: {
...session,
timeElapsed: existing?.timeElapsed ?? session.timeElapsed ?? 0,
isRunning,
historico: existing?.historico ?? session.historico,
activeExerciseId: existing?.activeExerciseId ?? session.activeExerciseId,
isEngagementRunning: existing?.isEngagementRunning ?? session.isEngagementRunning ?? false,
isFormVisible: existing?.isFormVisible ?? session.isFormVisible ?? true,
totalElapsed: existing?.totalElapsed ?? session.totalElapsed ?? 0,
fugaIntervals: existing?.fugaIntervals ?? session.fugaIntervals ?? [],
timerBaseSeconds: existing?.timerBaseSeconds ?? session.timeElapsed ?? 0,
timerStartedAtMs: existing?.timerStartedAtMs ?? (isRunning ? nowMs : null),
totalBaseSeconds: existing?.totalBaseSeconds ?? session.totalElapsed ?? 0,
totalStartedAtMs: existing?.totalStartedAtMs ?? nowMs,
},
};
});
};

const updateSessionProgress = (sessionId: string, progress: string) => {
Expand Down Expand Up @@ -166,12 +224,22 @@ export function SessionGlobalProvider({ children }: { children: ReactNode }) {
};

const toggleTimer = (sessionId: string, forceIsRunning?: boolean) => {
const nowMs = Date.now();
setActiveSessions((prev) => {
if (!prev[sessionId]) return prev;
const nextIsRunning = forceIsRunning !== undefined ? forceIsRunning : !prev[sessionId].isRunning;
const current = tickSession(prev[sessionId], nowMs);
const nextIsRunning = forceIsRunning !== undefined ? forceIsRunning : !current.isRunning;
if (nextIsRunning === current.isRunning) {
return current === prev[sessionId] ? prev : { ...prev, [sessionId]: current };
}
return {
...prev,
[sessionId]: { ...prev[sessionId], isRunning: nextIsRunning },
[sessionId]: {
...current,
isRunning: nextIsRunning,
timerBaseSeconds: current.timeElapsed,
timerStartedAtMs: nextIsRunning ? nowMs : null,
},
};
});
};
Expand Down Expand Up @@ -213,11 +281,18 @@ export function SessionGlobalProvider({ children }: { children: ReactNode }) {
};

const updateTimeElapsed = (sessionId: string, seconds: number) => {
const nowMs = Date.now();
setActiveSessions((prev) => {
if (!prev[sessionId]) return prev;
const current = prev[sessionId];
if (!current) return prev;
return {
...prev,
[sessionId]: { ...prev[sessionId], timeElapsed: seconds },
[sessionId]: {
...current,
timeElapsed: seconds,
timerBaseSeconds: seconds,
timerStartedAtMs: current.isRunning ? nowMs : null,
},
};
});
};
Expand Down
Loading