fix(measurements,coach): restore the live-fallback spread band and the stream lifecycle

The tiered rollup read emits minValue/maxValue; readLiveDaily omitted them, so
on a rollup-cold account or a coverage miss the chart silently dropped the
min/max spread band for the same underlying data — contradicting the file's own
byte-for-byte parity claim. The live aggregate now selects MIN/MAX alongside the
average.

useSendCoachMessage never aborted its in-flight reader on unmount. The drawer
works around that through handleOpenChange, but the full-page /coach surface
passes no registerReset, so navigating away mid-stream left the SSE loop alive:
the provider kept generating and billing, and trailing setState fired on an
unmounted component.

Switching conversations reset every piece of local state except the send state,
so the previous thread's last assistant or error bubble rendered appended to the
newly selected thread until the next send.

The two lifecycle invariants are pinned structurally, not behaviourally — the
repo ships no renderHook. That is weaker, and the header says so; both defects
were a missing call, which is what a structural guard does catch.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 14:23:03 +02:00
1 parent 9d03fe13f0
commit d103699099
5 files changed
+145 -2

No files matched your search

@@ -1216,6 +1216,12 @@ export function CoachConversation({
// seed so a pending attachment can't leak across a thread switch.
setPendingAttachmentIds([]);
dispatchGuided({ type: "RESET" });
// Drop the finished stream's trailing state, as `handleNewChat`
// already does. Without it the previous thread's last assistant
// or error bubble renders appended to the newly selected thread
// — `streamingActive` stays true because that messageId is
// absent from the new thread's messages — until the next send.
send.reset();
}}
/>
}
@@ -597,6 +597,29 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
optsRef.current = opts;
}, [opts]);
// Abort any in-flight stream when the hook unmounts. The drawer works around
// this via `handleOpenChange`, but the full-page `/coach` surface passes no
// `registerReset`, so navigating away mid-stream left the SSE reader loop
// alive: the provider kept generating (and billing), and trailing setState
// fired on an unmounted component. The route-change path had no other exit.
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
// Abort any in-flight stream when the hook unmounts. The drawer works around
// this via `handleOpenChange`, but the full-page `/coach` surface passes no
// `registerReset`, so navigating away mid-stream left the SSE reader loop
// alive: the provider kept generating (and billing), and trailing setState
// fired on an unmounted component. The route-change path had no other exit.
// Abort any in-flight stream when the hook unmounts. The drawer works around
// this via `handleOpenChange`, but the full-page `/coach` surface passes no
// `registerReset`, so navigating away mid-stream left the SSE reader loop
// alive: the provider kept generating (and billing), and trailing setState
// fired on an unmounted component. The route-change path had no other exit.
const reset = useCallback(() => {
setStreaming(EMPTY_STREAMING);
setOptimisticUser(null);