Skip to content

feat(insights): habit↔Daily Score correlation engine (#221) - #224

Open
Eliaaazzz wants to merge 2 commits into
mainfrom
feat/behavior-insights
Open

feat(insights): habit↔Daily Score correlation engine (#221)#224
Eliaaazzz wants to merge 2 commits into
mainfrom
feat/behavior-insights

Conversation

@Eliaaazzz

Copy link
Copy Markdown
Owner

Summary

Implements Feature 2 of 3 from issue #221 — Behavior Insights. Adapts Whoop's 2026 Behavior Insights pattern to AuraFitness's nutrition lane. Each insight tells the user which of their habits actually moves their Daily Score — with statistical backing, not vibes.

Whoop reframes the retention question from "Did the user perform?" to "Is the user becoming more capable and self-aware over time?" That shift is the most-cited behavior change mechanism in consumer health tech (sensai.fit / WHOOP locker).

This is the strongest T2D-positioning lever in the 3-feature bundle: showing "on days you logged breakfast, your Daily Score is +6.2 higher" is directly actionable for metabolic-health users that MyFitnessPal/Noom underserve.

Closes #221.

What's in this PR

Backend (Spring Boot, Java 21)

  • V53__create_behavior_insights.sqluser_behavior_days (composite PK) + behavior_insights
  • BehaviorPredicateRegistryshared with feature feat: Nutrition Challenges — time-bound goals, rest days, badges, AI personalized (Strava Challenges pattern) #222 (Challenges) for one source of truth on "did the user X today?". 8 default predicates (breakfast_logged, late_eating, meal_count_3_or_more, protein_goal_hit, protein_high, calories_in_range, varied_meal_types, protein_per_meal_decent). Fiber/sugar/processed predicates from the issue spec are deferred until those nutrient fields land on meal_log.
  • DailyScoreCalculator — transparent 0..100 score from meal_log (meals 30 + protein 30 + calories 30 + variety 10). Documented circularity caveat: predicates and score draw from the same signal; profile-aware targets are a follow-up.
  • WelchTTest — Welch two-sample t + Welch–Satterthwaite df + Cohen's d, p-values via Apache Commons Math (commons-math3:3.6.1). Matches scipy.stats.ttest_ind(equal_var=False) to ~1e-3 on hand-computed fixtures.
  • BehaviorDeriver — 90-day backfill + per-day derivation, idempotent (delete-then-rewrite the slice).
  • InsightStatsService — thresholds p<0.10 AND |d|>=0.30, confidence tiers (high <0.01, med <0.05, low), pin / dismiss with 14-day cooldown, freshness window (insights >7d old hidden), tier gating (Free: top 3 positive; Pro/Premium: all sorted by |Δ|, pinned first).
  • InsightScheduler@EnableScheduling + nightly 03:00 UTC; per-user incremental derive (yesterday only) + recompute + trim rows >90d.
  • SubscriptionService — stub returning FREE for everyone, with aurafitness.subscription.override env hook for QA. To be replaced when RevenueCat ships.
  • BehaviorInsightControllerGET /insights, GET /insights/cold-start, POST /pin, DELETE /pin, POST /dismiss, GET /detail (90-day calendar + yes/no score split), POST /recompute, POST /backfill.
  • New ErrorCode entries (2030–2032) wired through GlobalExceptionHandler via a new BehaviorInsightException.
  • 18 Mockito unit tests:
    • WelchTTestTest — fixture vs hand-computed reference, overlapping samples, undersized samples, identical constants, two-tailed symmetry across sign
    • DefaultBehaviorPredicatesTest — every predicate against fixtures + empty-day invariant
    • DailyScoreCalculatorTest — empty / perfect / partial / cap / floor
    • InsightStatsServiceTierFilterTest — free vs pro filtering, pin floats to top

Frontend (React Native + Expo, TypeScript)

  • InsightsScreen — cold-start onboarding under 30 days; otherwise scrollable FlatList with pull-to-refresh + manual recompute button. React Query useQuery + optimistic pin toggle via onMutate/revert-on-error.
  • InsightCard — left accent bar (orange positive / muted negative), big ±Δ with up/down arrow, server-formatted sentence, ConfidenceChip, pin button, mandatory AI disclaimer footer per CLAUDE.md.
  • ConfidenceChip — pill with three-tier dot indicator (solid / half-opacity / outlined).
  • InsightsOnboardingCard — 30-day progress bar.
  • Routes registered in AppNavigator; reachable via navigation.navigate('Insights').
  • 5 Jest + RTL tests for InsightCard covering sentence rendering, sign formatting, confidence chip, AI disclaimer, onPress wiring.

Acceptance Criteria coverage

AC Status Notes
AC-1 Behavior derivation 8 predicates from meal_log; 90-day backfill
AC-2 Insight engine (Welch + thresholds) p<0.10 AND |d|≥0.30; sentence template; confidence tiers
AC-3 Cold start & decay <30 days = onboarding; insights >7d hidden via JPQL guard
AC-4 Tier gating Free: top 3 positive. Pro: positive + negative sorted
AC-5 AI disclaimer Server attaches; rendered on every card
AC-6 Privacy Insight payloads contain aggregate stats only; access checked on every mutation

Notable design decisions

  • Shared predicate registry — One BehaviorPredicate interface, registered once, used by Insights and the upcoming Challenges feature. Avoids the predicate logic drifting in two places.
  • No daily_scores table — Score is computed at deriver time and stored on user_behavior_days.daily_score. Adding a separate scores table would couple this PR to a schema change that's not yet justified.
  • Welch over Student — Two unequal-variance samples (yes-days vs no-days, often very different sizes), Welch is the correct default. Matches scipy.
  • Tier gating via stub — Until RevenueCat lands, everyone is FREE. The single place to swap is SubscriptionService.getTier().

What's intentionally out of scope (follow-ups)

  • BehaviorDetailSheet (calendar heat-grid + dual-line score split chart) — the API endpoint is implemented and tested; the bottom-sheet UI is a small follow-up so this PR stays reviewable.
  • Profile-aware targets — currently 75g protein / 1200..2400 cal are hardcoded. UserProfile-driven targets are a follow-up.
  • Real subscription resolutionSubscriptionService is a stub.
  • Per-user-timezone bucketing — derivation buckets by UTC for v1.
  • Profile screen entry-point link — routes registered, link from Profile menu is a one-liner deferred.

Test plan

  • WelchTTestTest reference fixture matches scipy to within 0.05 on t, 1e-6 on two-tailed symmetry.
  • DefaultBehaviorPredicatesTest covers every predicate + empty-day invariant.
  • DailyScoreCalculatorTest covers empty / perfect / partial / cap / floor.
  • InsightStatsServiceTierFilterTest covers free vs pro and pin priority.
  • InsightCard.test.tsx covers sentence + sign + confidence + disclaimer + onPress.
  • tsc --noEmit passes clean for all new/modified frontend files.
  • Manual: run ./gradlew test, apply V53 against fresh Postgres, hit POST /api/v1/insights/backfill, confirm a few insights render with the AI disclaimer on the screen.

Implements feature 2 of 3 from issue #221 — adapts Whoop's 2026 Behavior
Insights pattern to AuraFitness's nutrition lane. Each insight answers
"is this habit actually helping me?" with statistical backing.

Backend (Spring Boot, Java 21):
- V53 migration: user_behavior_days, behavior_insights
- Shared BehaviorPredicateRegistry (8 predicates) — single source of truth
  for "did the user X today?", reused by feature #222 (Challenges)
- DailyScoreCalculator: 0..100 from a day's meal logs (4 weighted
  components, transparent algorithm)
- WelchTTest: Welch two-sample t + Welch-Satterthwaite df + Cohen's d via
  Apache Commons Math (org.apache.commons:commons-math3:3.6.1)
- BehaviorDeriver: 90-day backfill + per-day delta derivation
- InsightStatsService: thresholds (p<0.10 AND |d|>=0.30), confidence
  tiers (high <0.01, med <0.05, low otherwise), tier gating (free top-3
  positive only; pro all positive + negative)
- InsightScheduler: nightly 03:00 UTC; idempotent per-user incremental
  derive + recompute, trims rows >90 days
- SubscriptionService: stub returning FREE for everyone, env override for
  QA — to be wired to RevenueCat
- BehaviorInsightController: list / cold-start / pin / unpin / dismiss /
  detail (90-day calendar + score split) / recompute / backfill
- 18 Mockito unit tests across WelchTTest, DefaultBehaviorPredicates,
  DailyScoreCalculator, InsightStatsService tier filter

Frontend (React Native + Expo, TypeScript):
- InsightsScreen: cold-start onboarding card under 30 days, otherwise
  scrollable list sorted by absolute delta (pinned first), pull-to-refresh,
  manual recompute button
- InsightCard: ±delta with up/down arrow, server-formatted sentence,
  ConfidenceChip (3 tiers), pin toggle, mandatory AI disclaimer footer
  per CLAUDE.md
- ConfidenceChip, InsightsOnboardingCard with 30-day progress bar
- Optimistic pin toggle via React Query onMutate / onError revert
- 5 Jest tests covering sentence rendering, sign formatting, confidence,
  AI disclaimer, onPress wiring

Routes registered in AppNavigator (`navigation.navigate('Insights')`);
nav entry from Profile is a small follow-up. @EnableScheduling enabled.
Compiles cleanly under tsc --noEmit on all new files.

Closes #221
@Eliaaazzz Eliaaazzz added the enhancement New feature or request label May 6, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
aurafit 9104e25 May 06 2026, 02:12 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
aurafitness 9104e25 May 06 2026, 02:12 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 6, 2026

Copy link
Copy Markdown

Deploying aurafitness-2 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9104e25
Status: ✅  Deploy successful!
Preview URL: https://6f2c1e9f.aurafitness-2.pages.dev
Branch Preview URL: https://feat-behavior-insights.aurafitness-2.pages.dev

View logs

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the Behavior Insights feature, which uses Welch's t-test to correlate user habits with a calculated Daily Score. The implementation includes backend services for data derivation, statistical analysis, and scheduling, as well as a React Native frontend for displaying insights. Key feedback focuses on improving backend scalability by avoiding full table loads and N+1 queries, ensuring statistical accuracy by excluding incomplete days and empty logs from calculations, and addressing timezone discrepancies in habit predicates.

LocalDate yesterday = LocalDate.now(ZoneOffset.UTC).minusDays(1);
LocalDate trimAfter = yesterday.minusDays(90);

List<User> users = userRepository.findAll();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Using userRepository.findAll() is a significant scalability risk. As the user base grows, loading all users into memory will lead to OutOfMemoryError and excessive processing times. Consider using a Stream (with @Transactional(readOnly = true)) or pagination to process users in batches.

*/
@Transactional
public int recomputeForUser(UUID userId) {
LocalDate to = LocalDate.now(ZoneOffset.UTC);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The recomputeForUser method uses LocalDate.now(ZoneOffset.UTC) as the end of the window. This includes the current day, which is likely incomplete. Including partial data for 'today' will skew the statistical results (e.g., a low daily score because the day isn't over yet). It is recommended to only compute insights on completed days by setting the end date to yesterday.

Suggested change
LocalDate to = LocalDate.now(ZoneOffset.UTC);
LocalDate to = LocalDate.now(ZoneOffset.UTC).minusDays(1);

LocalDate cursor = from;
while (!cursor.isAfter(to)) {
List<MealLog> dayMeals = grouped.getOrDefault(cursor, List.of());
Short score = (short) scoreCalculator.calculate(dayMeals);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If dayMeals is empty (i.e., the user didn't log anything that day), scoreCalculator.calculate returns 0. Storing a 0 score for empty days will heavily skew the correlation analysis, as 'no-habit' days will appear to have a much lower mean score than they actually do, leading to false positive insights. Empty days should have a null score so they are ignored by the stats engine.

Suggested change
Short score = (short) scoreCalculator.calculate(dayMeals);
Short score = dayMeals.isEmpty() ? null : (short) scoreCalculator.calculate(dayMeals);

try {
deriver.deriveRange(user.getId(), yesterday, yesterday);
// Trim everything older than the 90-day window so the table doesn't grow unbounded.
dayRepository.deleteByUserIdAndDayBetween(user.getId(), LocalDate.of(2000, 1, 1), trimAfter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Performing a delete operation per user inside the loop is inefficient. This results in N delete queries every night. It is better to perform a single bulk delete operation outside the loop to remove all records older than the 90-day window for all users at once.

Suggested change
dayRepository.deleteByUserIdAndDayBetween(user.getId(), LocalDate.of(2000, 1, 1), trimAfter);
// Move this outside the loop and use a bulk delete method in the repository
dayRepository.deleteByDayBefore(trimAfter);

Comment on lines +123 to +165
for (String key : registry.all().stream().map(p -> p.key()).toList()) {
List<UserBehaviorDay> series = byKey.getOrDefault(key, List.of());
List<Double> yes = new ArrayList<>();
List<Double> no = new ArrayList<>();
for (UserBehaviorDay r : series) {
if (r.getDailyScore() == null) continue;
if (r.isObserved()) yes.add((double) r.getDailyScore());
else no.add((double) r.getDailyScore());
}
if (yes.size() < MIN_PER_BUCKET || no.size() < MIN_PER_BUCKET) {
// Not enough data yet — drop any stale insight for this behavior
insightRepository.findByUserIdAndBehaviorKey(userId, key)
.ifPresent(insightRepository::delete);
continue;
}

WelchTTest.Result result = WelchTTest.run(toArray(yes), toArray(no));
double delta = result.meanA() - result.meanB();
double absD = Math.abs(result.cohensD());

if (result.pValue() > P_VALUE_MAX || absD < COHENS_D_MIN) {
insightRepository.findByUserIdAndBehaviorKey(userId, key)
.ifPresent(insightRepository::delete);
continue;
}

String confidence = result.pValue() < P_HIGH ? "high"
: result.pValue() < P_MED ? "med"
: "low";

Optional<BehaviorInsight> existing = insightRepository.findByUserIdAndBehaviorKey(userId, key);
BehaviorInsight insight = existing.orElseGet(() -> BehaviorInsight.builder()
.userId(userId).behaviorKey(key).build());
insight.setDeltaScore(BigDecimal.valueOf(delta).setScale(2, RoundingMode.HALF_UP));
insight.setCohensD(BigDecimal.valueOf(result.cohensD()).setScale(3, RoundingMode.HALF_UP));
insight.setPValue(BigDecimal.valueOf(result.pValue()).setScale(4, RoundingMode.HALF_UP));
insight.setSampleYes(yes.size());
insight.setSampleNo(no.size());
insight.setConfidence(confidence);
insight.setComputedAt(OffsetDateTime.now(ZoneOffset.UTC));
insightRepository.save(insight);
written++;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

This loop introduces an N+1 query pattern by calling insightRepository.findByUserIdAndBehaviorKey multiple times (lines 134, 144, 153) for each predicate. To improve performance, fetch all existing insights for the user into a Map<String, BehaviorInsight> before entering the loop.

Comment on lines +79 to +80
return meals.stream().anyMatch(m -> m.getConsumedAt() != null
&& m.getConsumedAt().getHour() < BREAKFAST_BEFORE_HOUR);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The BreakfastLogged predicate checks the hour of consumedAt against a UTC constant. Since the deriver buckets by UTC, this logic will be incorrect for any user not in the UTC timezone (e.g., 8 AM in PST is 4 PM UTC). While the PR notes mention timezone bucketing is a follow-up, this significantly impacts the correctness of the 'Logged breakfast' and 'Ate after 9pm' insights for v1.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 6, 2026

Copy link
Copy Markdown

Deploying aurafitness with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9104e25
Status: ✅  Deploy successful!
Preview URL: https://083d05f4.aurafitness.pages.dev
Branch Preview URL: https://feat-behavior-insights.aurafitness.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 6, 2026

Copy link
Copy Markdown

Deploying aurafitness-1 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9104e25
Status: ✅  Deploy successful!
Preview URL: https://93502fd9.aurafitness-1.pages.dev
Branch Preview URL: https://feat-behavior-insights.aurafitness-1.pages.dev

View logs

Six gemini-code-assist findings — 3 high, 3 medium — all addressed:

1. [HIGH] InsightScheduler.runNightly() loaded the entire users table.
   Replaced findAll() with a Pageable batch loop (200/page) so memory stays
   bounded as the user base grows.

2. [HIGH] InsightStatsService.recomputeForUser() included today (incomplete
   day) in the window, biasing means downward. Window now ends at yesterday.

3. [HIGH] BehaviorDeriver wrote a 0-score row for every empty day, which
   collapsed the no-bucket mean toward 0 and produced false positives.
   Empty days now skip persistence entirely; cold-start distinct-day counts
   stay honest as a side benefit.

4. [MED] Per-user delete inside the nightly loop turned trim into N SQL
   statements. Replaced with a single bulk JPQL delete keyed on the cutoff,
   executed once outside the loop.

5. [MED] N+1 lookup pattern in recomputeForUser (findByUserIdAndBehaviorKey
   per predicate × delete-or-update). Fetch all of the user's existing
   insights once into a Map, look up O(1), and batch the delete via
   deleteAllByIdInBatch.

6. [MED] Time-of-day predicates (breakfast_logged, late_eating) checked the
   hour on the raw UTC OffsetDateTime, so a PST user at 8am local was
   counted as a 16:00 UTC log and never qualified as "breakfast". Plumbed
   ZoneId through BehaviorPredicate.evaluate, BehaviorDeriver.deriveRange,
   and the controller backfill endpoint (X-User-Timezone header). Scheduler
   uses a configurable default zone with a TODO for per-user resolution.

New test:
- DefaultBehaviorPredicatesTest.breakfastLogged_respectsCallerZone_pstUserAt8amLocal
  asserts that 16:00 UTC is "breakfast" in America/Los_Angeles but not in UTC.

All 23 behavior tests still pass locally (4 suites, 0 failures).
@Eliaaazzz

Copy link
Copy Markdown
Owner Author

All six review comments addressed in 9104e25. Mapping each finding to the fix:

# Severity Finding Fix
1 high InsightScheduler.runNightly() loaded the full users table → OOM risk Pageable batch loop, 200 users/page; findAll() removed (InsightScheduler.java)
2 high recomputeForUser() included today (incomplete day), biasing means Window ends at today.minusDays(1) (InsightStatsService.java)
3 high BehaviorDeriver stored a 0-score row for empty days → skewed no-bucket Empty days are no longer persisted at all (BehaviorDeriver.deriveRange); side benefit: cold-start distinct-day counts stay honest
4 med Per-user delete inside nightly loop → N SQL statements per night Single bulk JPQL DELETE keyed on cutoff date, executed once outside the loop (UserBehaviorDayRepository.deleteByDayBefore + InsightScheduler)
5 med N+1 findByUserIdAndBehaviorKey inside the recompute loop One up-front findAllByUserIdMap<String, BehaviorInsight>, O(1) lookup; deletes batched via deleteAllByIdInBatch (InsightStatsService.java)
6 med Time-of-day predicates evaluated UTC hour → wrong for non-UTC users BehaviorPredicate.evaluate(meals, ZoneId) plumbed through BehaviorDeriver.deriveRange and the controller backfill endpoint (reads X-User-Timezone); scheduler uses a configurable default zone with a TODO for per-user resolution

New test: DefaultBehaviorPredicatesTest.breakfastLogged_respectsCallerZone_pstUserAt8amLocal asserts that 16:00 UTC counts as "breakfast" under America/Los_Angeles but not under UTC — directly covers finding #6.

Local test run: all 4 behavior test suites pass (23 tests, 0 failures).

Thanks for the review!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Behavior Insights — habit↔Daily Score correlation engine (Whoop pattern)

1 participant