Coach 400/503 fixed (#77); Activity day nav (#76); sleep record delete (#78); quiet-hours gate (#79) - #80
Conversation
…77) strict: true makes OpenAI validate the JSON schema itself, and two things in CoachResponseSchema failed that validation, so every native-OpenAI coach turn 400'd with "Invalid schema for response_format 'coach_response'": - the chart object omitted additionalProperties: false (the reported error) - required listed four properties where strict mode demands all eleven, optionality expressed as a null union rather than a missing key Gemini is unaffected: cleanSchema strips additionalProperties and rewrites the null unions to its own nullable flag. Also on this ticket: Gemini's 503 "high demand" (the -latest aliases routing to a congested pool) now gets a bounded retry with backoff — a 503 means the request was never served, so a retry cannot double-bill; a 429 still surfaces immediately, since a quota bucket refills daily, not in seconds. And the model picker drops the retired gpt-4o / gpt-4o-mini / o4-mini slugs.
Sleep and the vital detail screens could step back through stored days; the Activity tab could not, so once a day rolled over its steps, distance and calories were out of reach — and a deleted block could not be re-checked later (the exact check the reporter wanted on #70's deletion). The header mirrors Sleep's: chevrons stepping a clamped [0, maxDayOffset] window (a week past the earliest stored day, one-year floor), Today / Yesterday / weekday / date label. The summary card and the RECORDS card follow the shown day; the weekly-goal widget keeps reading live-today, as a week widget should. deleteBucket no longer resets the shown day — verifying a past-day deletion was the point. New ActivityDailyDao.earliestDay bounds the pager, same rule as sleep's.
A night can arrive as several ring records merged into one session, and the merge is usually right — but the ring opens a record on a still wrist, so the evening before the wearer got into bed can land as a full phantom session and the night runs an hour high. The RECORDS card (issue #68) shows the parts; now each row gets a delete. Three rules, mirroring the activity deletion's (issue #70): - Tombstone the blocks, not the session row: the write path re-derives the waking day from the blocks the ring re-sends, so the row alone would rebuild on the next sync. A block's minute-grid startAt is the stable identity — the same record re-sent reproduces it — keyed by waking day (sleep:<day>:<start>), and upsertSleepSessionAtomic consults the tombstones before writing. - Re-derive the session from the blocks that remain (bounds, asleep minutes, score), never hand-patch; empty means the row goes and the day reads as unslept. The stage-score banding moves to SleepInsights.sleepStageScore so the restatement and the write path share one source of truth. - sleepRecordRuns re-splits the survivors, so the card reseals itself. Also fixes the card's now-reachable singular: a one-record night says "1 part", not "1 parts".
A still wrist reads as sleep, so an evening on the sofa can land as a phantom session and run the night an hour high (the same night #78's delete handles, caught before import instead of after). Opt-in, off by default, on the Wearable screen: sleep the ring opens outside a daily window is declined before any write. A time window rather than the reporter's first choice of Android quiet Modes: a Mode gate needs notification-policy access plus a recorded interruption-filter history to compare imports against, and the sofa case is mostly caught by a plain window. The Modes version stays open as a follow-up; the reporter offered to build it. Properties worth the defaults: the gate reads per record, so a settings change takes effect on the next packet; it never deletes (already-imported nights are untouched); and drop-on-import is one-way — what the gate skips while narrower stays skipped, which is why it is opt-in. Window boundaries are start-inclusive/end-exclusive, a start==end window covers the whole day, and the default 22:00→07:00 wraps midnight.
The ring reports a brief night-waking two ways — an AWAKE stage, or closing the record and reopening a new one. The second form appears on the RECORDS card as "Awake Xm between" but contributed nothing to the Awake card, so one night read 4m awake on one card and 0m on the other (five wakings, four reopened, one staged). awakeMinutes() counts staged blocks plus inter-record gaps at the same 2-minute real-boundary threshold sleepRecordRuns draws — the one-minute seam (issue #63) stays excluded, or every unsplit night grows a spurious minute. The Day-view Awake card (single session + carousel) and the aggregate's per-night average both use it.
…, #81) Deleting a middle record restated the survivors as one session, so the hole it left was counted by awakeMinutes() as a between-record waking: deleting 00:25-03:19 from the reported night showed ~3h20m Awake. The next sync would also have disagreed, since reconcileWakingDay splits at the 60-minute session gap. SleepRecordDeletion now re-segments the survivors the same way; the best-overlapping segment keeps the row id, others take the write path's sleep-<day>-<start> id so a re-sync matches rather than twins them. The Awake card and the RECORDS card's "Awake Xm between" line now read one rule (betweenRecordAwakeMinutes), so the one-minute seam no longer shows as "Awake 1m between" while counting 0. Multi-session days (night + nap) had no RECORDS card and so no delete; a phantom session the merge didn't join to the night couldn't be removed. The carousel pages now show it, even for a one-record session. A failed delete shows a toast instead of silently doing nothing.
…ropping them (#79) The gate accepted or declined a whole record by its start minute. The ring often opens one record on the sofa and runs it straight into the real night, so the reported 23:08 start either kept the sofa hour (it is inside the default 22:00-07:00) or, with a later start set, threw the whole night away - permanently, since the gate is drop-on-import. keptMinutes() now keeps the longest contiguous stretch of the record inside the window, and the record is imported from there. The settings copy tells the wearer to set the start to their real bedtime and says naps outside the window are skipped.
Dropping gpt-4o / gpt-4o-mini / o4-mini from the picker left anyone who had one selected stuck on it, with nothing in the list showing what was stored. OpenAIModel.normalize() maps the retired slugs (and blank) to the default in ApiKeyStore.model; a typed unknown slug is left alone. The picker now lists OpenAIModel's entries instead of a second hand-kept list, which had already drifted (gpt-5.4-mini and gpt-5.5 were missing). The Gemini 503 retry backs off 2 s then 4 s (2000 shl 1), not 8 s as the comment said.
maxDayOffset was computed once in init, so days synced (or a midnight rolled) since the ViewModel was built stayed out of the chevrons' reach.
Review follow-up: 4 fix commits pushedI checked the PR against what each linked ticket asked for (#76, #77, #78 plus the follow-up data, #79, #81). The new commits fix the problems that review found. Fixed65fc875 — sleep delete × Awake (#78, #81)
a1ae0e3 — the quiet-hours gate trims instead of dropping (#79)
8770aa2 — Activity pager (#76)
Still open, and not code
Still needs a hardware check: the trimmed quiet-hours import on a real night, and a delete of a middle record followed by a re-sync. |
|
@foureight84 , #78 editing: happy for delete-only to ship and for editing to be tracked separately. I'll open a follow-up issue for adjusting a record's start and end, so it doesn't vanish when this merges. #79: yes, I still want to build the quiet-Modes version. I'll open a follow-up issue for it too so the offer has somewhere to live. Worth saying the trimming change makes the time window a lot more usable in the meantime, since my bad night started at 23:08 and would have survived the old all-or-nothing gate. #77's 429: for the record, quota exhaustion doesn't explain it on my side. When I hit it, my Google AI Studio dashboard showed peak usage well under every ceiling over 28 days: 13/20 requests per day on 2.5 Flash, 3/20 on 3.8 Flash, 3/500 on 3.5 Flash Lite. So it wasn't me running out. I'm fine with #77 closing on the schema and model-list fixes, which are the parts that were clearly broken, and I'll file it separately if the 429 comes back once the retry is in. Happy to run both hardware checks whenever there's a build: the trimmed quiet-hours import on a real night, and deleting a middle record then re-syncing. I get a split night most nights, so the second one should be easy to reproduce. |
|
@Albabit , Thanks again for the detailed numbers and breakdown! This is now merged into Whenever you have a moment, please run the two hardware checks on this build:
Also, as discussed, please feel free to open the two follow-up tickets whenever you're ready:
Once you've had a chance to verify on hardware, we'll promote this to a full release. |
Closes #77, closes #76, closes #78, closes #79, closes #81.
Started as the #77 fix; while in there, the other three tester requests went in as their own commits, and the #78 follow-up's side-note became its own fix. Everything is unit-tested and
./gradlew testDebugUnitTestis green (1381+ tests). Reviewable per commit.1 · #77 — the OpenAI 400 was the schema, and there were two bugs in it
@Albabit's mechanism was exactly right: the request was rejected before the model ever saw it, reproducible for anyone on OpenAI. The chat sends
strict: true, and under structured outputs the schema itself is validated first:chartobject declared five properties but noadditionalProperties: false.requiredlisted 4 of 11 properties — strict mode demands every property, with optionality as a null union instead of a missing key.Both fixed (
chartis now["object","null"]with fullrequired;safety_note/data_quality_notenull unions;publisherrequired in sources). Gemini is untouched — itscleanSchemaalready stripsadditionalPropertiesand rewrites null unions tonullable. A new test walks the whole schema asserting the two strict-mode invariants so the next field can't reintroduce this.Gemini 503 "high demand" (
-latestaliases routing to a congested pool):GeminiClientnow retries a 503 up to twice with backoff (2 s, 4 s, cancellable). A 503 means the request was never served — nothing generated, nothing billed — so a retry cannot double-charge; that reasoning is whyResponsesHttpnever retries HTTP statuses, so the retry lives inGeminiClient's call site and the shared layer's policy is untouched. A 429 is deliberately not retried (quota buckets refill daily, not in seconds) — the unexplained 429-with-headroom is most plausibly per-model free-tier buckets; if it recurs on the pinnedgemini-2.5-flashslug, that's a Google support conversation, not a client fix.Model picker: retired
gpt-4o/gpt-4o-mini/o4-minidropped;gpt-5.4stays (the errors were schema-level, the model was never the problem). A stale stored selection still renders until changed.2 · #76 — Day navigation on the Activity screen
The header mirrors Sleep's (iOS #84): chevrons stepping a clamped window (a week past the earliest stored day, one-year floor), Today / Yesterday / weekday / date. The summary card and the RECORDS card follow the shown day, with the same delete affordance; the weekly-goal widget keeps reading live-today, as a week widget should.
deleteBucketno longer resets the shown day — verifying a past-day deletion was exactly the reported use case. NewActivityDailyDao.earliestDaybounds the pager.3 · #78 — delete one ring record from the night
The RECORDS card's rows each get a delete with a two-step confirm. Three rules, mirroring the activity deletion's (#70):
startAtis the stable identity (same record re-sent → same starts), keyed by waking day;upsertSleepSessionAtomicconsults the tombstones before writing.SleepInsights.sleepStageScoreso restatement and the write path share one source of truth). Empty means the row goes and the day reads as unslept.sleepRecordRunsre-splits the survivors, so the card reseals itself. Also fixes its now-reachable singular ("1 part", not "1 parts").Tests cover the tombstone identity: a re-sent record's blocks are all suppressed, neighbours survive, the same minute-of-day on another night doesn't, and re-deleting replaces rather than grows.
Fixed in the same PR, on its own issue #81: the side-note above — brief wakings that reopen a record show as "Awake Xm between" on this card but didn't count in the Awake card.
awakeMinutes()now counts staged blocks plus inter-record gaps at the same 2-minute real-boundary thresholdsleepRecordRunsdraws — the one-minute seam (#63) stays excluded, or every unsplit night grows a spurious minute. The Day view (single session + carousel) and the aggregate's per-night average all use the one function, so the two displays can't drift again.4 · #79 — opt-in quiet-hours gate on import
Shipped as a time window (off by default, Wearable screen) rather than the quiet-Modes design the ticket proposed: a Mode gate needs notification-policy access plus a recorded interruption-filter history to compare imports against, and the sofa case is mostly caught by a plain window. The Modes version remains open as a follow-up — @Albabit offered to build it, and that offer stands; the gate reads [QuietHoursPrefs.covers] per record, so a Modes implementation would swap that one decision point.
Properties the defaults bake in: the gate reads per record (a settings change takes effect on the next packet); it never deletes (already-imported nights untouched); drop-on-import is one-way — what the gate skips while narrower stays skipped — which is exactly why it's opt-in. Default 22:00 → 07:00, wraps midnight, start-inclusive/end-exclusive;
start == endcovers the whole day.Testing
./gradlew testDebugUnitTest— all green. New: schema-strictness walk, three Gemini retry tests, four tombstone-identity tests, five window-logic tests. As before, the discriminating checks are on hardware: @Albabit on the OpenAI key (the built-in trend question), and a night with a phantom record for #78/#79.