Skip to content

Insights: don't count never-answered journal days as "without" - #682

Closed
digitalerdude wants to merge 1 commit into
ryanbr:mainfrom
digitalerdude:fix/631-insights-unlogged-without
Closed

digitalerdude wants to merge 1 commit into
ryanbr:mainfrom
digitalerdude:fix/631-insights-unlogged-without

Conversation

@digitalerdude

@digitalerdude digitalerdude commented Jul 20, 2026 •

Copy link
Copy Markdown

Summary

Standalone fix, split off after review discussion on this PR — does not fully resolve #631 (see below), but addresses a real, separate bug in the same area.

The "Behaviour Effects" with/without split (BehaviorInsights.effect /
EffectRanker, both platforms) partitioned every outcome-day into "with" or "without"
based solely on whether a journal question was answered "yes" — a day it was never
asked/answered fell into "without" right alongside a genuine "no" answer. For a rarely
logged question this makes "without" wildly outnumber real "no" days. A screenshot
attached to #631 shows exactly this: with n=3 vs without n=305 for a question asked
on only a handful of days — the 305 are almost all "never answered", not "no".

This is not the #631 root cause. bartmuskala confirmed (in the PR comments) that
he answered every question directly for 300+ days and imported answers were still all
mislabeled, while native logging is fine — an import-specific symptom this
source-agnostic partition fix can't explain. #631 stays open for that; current lead is
a missing Dutch locale entry in the CSV header/value alias tables (CsvParser.kt /
CSVParsing.swift), pending confirmation from the reporter's actual export.

The earlier #136 fix only corrected which day a journal entry lands on (onset evening
→ wake day); it never touched this with/without partition, so the underlying mislabeling
survived independently of this bug.

Fix

Thread an askedDays/asked set alongside the existing "yes" set through the effect
computation, so a day only counts in with or without when it was actually
answered (yes or no) — a never-asked day is now excluded from both groups, on both
platforms:

  • Swift core: BehaviorInsights.effect/.rank (Packages/StrandAnalytics) and
    EffectRanker.bestLag/.rank.
  • Kotlin core: com.noop.analytics.EffectRanker.effect/.bestLag/.rank (the
    byte-identical port), plus the separate, non-shared partition logic still living
    inline in InsightsScreen.kt (the older Insights tab never called the shared
    EffectRanker).
  • All 6 call sites that build the behaviour-days maps (3 per platform: the Insights
    tab, the v5 Insights Hub, and the AI coach) now build an "asked" set in the same
    loop, no extra DB query.

The partition change is additive/defensive: behaviorDays OR askedDays membership
gates a day into the comparison, so a "yes" day is never dropped even if a caller's
askedDays build has a gap.

Out of scope (separate, unrelated, and less-verified issue — flagging for a future
PR rather than bundling): Strand/Data/WhoopImporter.swift:156 does an exact-match
.lowercased() == "true" when parsing the imported CSV's yes/no column, which would
mis-parse any other real-world spelling (e.g. "Yes"/"1") of a positive answer. It's
Swift-only (Android's parseYesNo already accepts "yes"/"1"/"y") and unrelated to
this partition bug.

Test plan

  • swift test in Packages/StrandAnalytics — full suite (1113 tests) passes,
    including new/migrated BehaviorInsightsTests and EffectRankerTests cases
    covering: a never-asked day excluded from both groups, an asked-"no" day still
    counting as without, the defensive OR-gate, a realistic sparse-question shape
    matching the Imported journal entries are not parsed correct #631 screenshot (asked on 5/300 days), and askedDays staying
    un-shifted across the lag search.
  • ./gradlew testFullDebugUnitTest — full Android unit suite (2866 tests)
    passes, including the mirrored EffectRankerTest cases.
  • ./gradlew compileFullDebugKotlin — Android app module compiles.
  • xcodegen generate && xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build — macOS app target (which the changed
    InsightsView.swift/InsightsHubView.swift/AICoach.swift live under, not
    covered by swift-packages.yml) builds clean.
  • Not tested on-device/in a running app (no strap session this pass) — the
    fix is pure in-memory analytics over already-persisted journal data, no
    migration/schema/backup involved.

…r#631)

The Behaviour Effects with/without split (BehaviorInsights.effect / EffectRanker,
Swift and Kotlin) partitioned every outcome-day into "with" or "without" based
solely on whether a journal question was answered "yes" — any day it was never
asked/answered fell into "without" alongside genuine "no" answers. For a rarely
logged question this makes "without" wildly outnumber real "no" days (a user's
screenshot showed with n=3 vs without n=305 for a question asked on a handful
of days), producing the misleading correlation stats ryanbr#631 reports.

Thread an `askedDays`/`asked` set alongside the existing "yes" set so a day only
counts in with or without when it was actually answered; a never-asked day is
now excluded from both groups. Applied to the shared Swift BehaviorInsights/
EffectRanker engine, its Kotlin EffectRanker twin, and the still-separate
non-shared partition logic inside Android's InsightsScreen.kt (old Insights
tab), across all six call sites that build the behaviour-days maps (both
platforms' Insights tab, Insights Hub, and AI coach).
@bartmuskala

Copy link
Copy Markdown

Bewaren: the screenshot I shared in #631 showed a 100% mismatch. I mean: I answered each question for over 300 days (asked days!) with yes or no (so BehaviorDays).

So not only the empty states were mismatched, ALL answers were ignored. I hope this is clear.

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The never-asked partition fix itself is solid — clean, byte-parity across Swift/Kotlin (the guard contains||contains / !contains && !contains pair is equivalent), the OR-gate never drops a "yes" day, asked[name] ?? days is a safe fallback, and askedDays is correctly not lag-shifted. Good tests both sides. As a standalone improvement (stop never-asked days inflating "without") it's worth having.

But I don't think it fixes #631 as reported, and I'd hold the Fixes #631 on that.

#631 is explicit: Android, "imported journal entries from whoop are all labeled as 'without', however many were actually yes-es", and "new [native] entries are [correct]". bartmuskala's comment here ("ALL answers were ignored") says the same.

That's an import-specific symptom — imported yes → without while native logging works. A source-agnostic partition change treats imported and native days identically, so it can't be why import breaks but native doesn't. The real cause is in the import path's yes→answeredYes population (Android WhoopCsvImporter.kt / the WHOOP export's answer spelling or day-mapping), not the with/without split. Notably this PR flags a yes/no-parse bug but declares it out of scope and calls it "Swift-only, Android is fine" — yet the reporter is on Android with native-ok/import-broken, so the partition fix can't be the full explanation.

Suggestion — two clean options:

  1. Re-scope to actually fix the Android import yes-parse / answeredYes path (validated against bartmuskala's attached .zip), and the partition fix rides along; or
  2. Land the partition fix on its own — retitled, worded Addresses not Fixes #631, referencing a new issue for never-asked inflation — and keep #631 open for the import root cause.

Either way, worth confirming against the reporter's export before closing #631, since they've already said this doesn't match their data. It's a draft and the code is good, so this is just a comment — no changes requested on the diff.

@digitalerdude

Copy link
Copy Markdown
Author

Good catch, and thanks for flagging it before it got merged on a wrong "Fixes" claim.

Agreed the partition fix can't be the explanation here — bartmuskala's comment confirms it's not a never-asked-days problem at all: he answered every question directly for 300+ days and the import still zeroed all of them out, while native logging is fine. That's import-specific, and this fix treats imported/native days identically, so it doesn't touch the actual reported symptom.

Going with option 2: retitling/rewording this PR as a standalone partition fix (worth having on its own, per your read of the diff) and no longer claiming to fix #631. Leaving #631 open for the real import root cause.

Best lead so far, not yet confirmed: CsvParser.kt (and the Swift twin CSVParsing.swift) carry header/value alias tables for German, Spanish, French and Brazilian Portuguese WHOOP exports — each added after a real user's non-English export broke import the same way. There's no Dutch entry, and bartmuskala's "Bewaren" here suggests a Dutch-locale WHOOP account. If the answered_yes_no header or the answer cell text ("Ja"/"Nee" or similar) is Dutch, parseYesNo (Android) / the Swift equivalent would silently fail to match on every row — same failure mode as the prior locale gaps, and it would explain "all answers ignored" without needing a day-mapping bug. Asking on the issue for the real header/value text to confirm before guessing at the actual strings.

@digitalerdude digitalerdude changed the title Insights: don't count never-answered journal days as "without" (#631) Insights: don't count never-answered journal days as "without" Jul 21, 2026
@digitalerdude
digitalerdude marked this pull request as ready for review July 21, 2026 15:47
@ryanbr ryanbr closed this in #706 Jul 22, 2026
ryanbr pushed a commit that referenced this pull request Jul 22, 2026
Both the Android and Swift CSV journal importers looked up the answer
column only as answered_yes_no / answer / answer_text. That matches
NOOP's own exporter (which writes "Answered yes/no"), but a real WHOOP
export names the column "Answered yes" -> answered_yes, which never
matched. Every imported journal answer silently read false ("Without"
in Insights), regardless of what the account actually answered -
confirmed against a reporter's real journal_entries.csv (300+ days,
every answer zeroed out, native logging correct).

Fix: add answered_yes as a (first) candidate key on both platforms.
Added a regression test on each side using the real header and
TRUE/FALSE casing from the reporter's export.

Does not fully close #631 by itself if the reporter's export also has
other quirks, but fixes the confirmed root cause. Doesn't touch #682
(the separate never-asked-vs-no partition fix already split out from
this issue).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…r#631)

Both the Android and Swift CSV journal importers looked up the answer
column only as answered_yes_no / answer / answer_text. That matches
NOOP's own exporter (which writes "Answered yes/no"), but a real WHOOP
export names the column "Answered yes" -> answered_yes, which never
matched. Every imported journal answer silently read false ("Without"
in Insights), regardless of what the account actually answered -
confirmed against a reporter's real journal_entries.csv (300+ days,
every answer zeroed out, native logging correct).

Fix: add answered_yes as a (first) candidate key on both platforms.
Added a regression test on each side using the real header and
TRUE/FALSE casing from the reporter's export.

Does not fully close ryanbr#631 by itself if the reporter's export also has
other quirks, but fixes the confirmed root cause. Doesn't touch ryanbr#682
(the separate never-asked-vs-no partition fix already split out from
this issue).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…r#631)

Both the Android and Swift CSV journal importers looked up the answer
column only as answered_yes_no / answer / answer_text. That matches
NOOP's own exporter (which writes "Answered yes/no"), but a real WHOOP
export names the column "Answered yes" -> answered_yes, which never
matched. Every imported journal answer silently read false ("Without"
in Insights), regardless of what the account actually answered -
confirmed against a reporter's real journal_entries.csv (300+ days,
every answer zeroed out, native logging correct).

Fix: add answered_yes as a (first) candidate key on both platforms.
Added a regression test on each side using the real header and
TRUE/FALSE casing from the reporter's export.

Does not fully close ryanbr#631 by itself if the reporter's export also has
other quirks, but fixes the confirmed root cause. Doesn't touch ryanbr#682
(the separate never-asked-vs-no partition fix already split out from
this issue).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Imported journal entries are not parsed correct

3 participants