Skip to content

feat(api): auto-activate streak freeze on inactive day (#108) - #179

Merged
thomasluizon merged 3 commits into
mainfrom
issue-108
Jun 4, 2026
Merged

feat(api): auto-activate streak freeze on inactive day (#108)#179
thomasluizon merged 3 commits into
mainfrom
issue-108

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Refs thomasluizon/orbit-ui-mobile#108

What

A dedicated StreakFreezeAutoActivationService BackgroundService that auto-activates a streak freeze for a Pro user who held an active streak but logged nothing on their fully-elapsed local "yesterday". The paired UI PR makes the Today-header streak badge tappable.

How

  • Mirrors SlipAlertSchedulerService / HabitDueDateAdvancementService: configurable poll interval (BackgroundServices:StreakFreezeIntervalMinutes, default 60), conservative UTC AddDays(-1) pre-filter, then authoritative per-user TimeZoneHelper local-yesterday guard; single SaveChangesAsync per tick.
  • Pro-only (user.HasProAccess, matching ActivateStreakFreezeCommand). Spends one freeze per missed day, bounded by MaxStreakFreezesAccumulated (inventory via User.ConsumeStreakFreeze()) and MaxStreakFreezesPerMonth (calendar-month window).
  • Presence-based invariant: inserting a StreakFreeze row for the missed date is sufficient — UserStreakService.ComputeCurrentStreak treats any freeze-covered date as covered, so the next on-read RecalculateAsync preserves CurrentStreak without any direct mutation here.
  • Idempotency: new SentStreakFreezeAlert guard entity (unique (UserId, FrozenDate)) + the existing StreakFreeze unique (UserId, UsedOnDate) index, both batch-loaded and re-checked before spending → no double-spend, no duplicate notification across polls.
  • Notify: in-app Notification.Create + push (IPushNotificationService.SendToUserAsync), deep-linking to /streak. Copy is server-generated and localized via LocaleHelper.IsPortuguese, mirroring GoalDeadlineNotificationService (no AI dependency).
  • EF migration AddSentStreakFreezeAlert (table + unique index + cascade FK). AccountResetRepository purges the new guard table. Registered in ServiceCollectionExtensions and BackgroundServiceHealthCheck.
  • No late-log refund in this issue (documented follow-up in the plan).
  • Not folded into HabitDueDateAdvancementService — kept dedicated so streak logic never re-hides overdue habits.

Rebase note

The ServiceCollectionExtensions.cs hosted-services region (~345) differs from the regions touched by #78 (Stripe ~308-326) and #79 (AI-tools ~153-198) — this is a trivial rebase at merge.

Tests / validation

  • dotnet build clean (0 errors).
  • New unit tests cover the eligibility predicate (include/exclude cases), local-yesterday computation, the 60-min interval default, localized notification copy, and the guard entity.
  • dotnet test unit projects green: Infrastructure.Tests 931, Domain.Tests 344, Application.Tests 1679. The freeze-preserves-streak invariant is already covered by existing UserStreakServiceTests (not duplicated).
  • IntegrationTests not run in CI sandbox here (host bootstrap needs a 32-byte Jwt:SecretKey + DB) — flagged as manual.

Paired UI PR

thomasluizon/orbit-ui-mobile#129

Add a dedicated StreakFreezeAutoActivationService BackgroundService that
auto-activates a streak freeze for a Pro user who held an active streak but
logged nothing on their fully-elapsed local "yesterday".

- Mirrors SlipAlertSchedulerService / HabitDueDateAdvancementService: poll
  interval, conservative UTC pre-filter, authoritative per-user TimeZoneHelper
  local-yesterday guard, single SaveChanges per tick.
- Pro-only (matches ActivateStreakFreezeCommand). Spends one freeze per missed
  day, bounded by MaxStreakFreezesAccumulated (inventory) and
  MaxStreakFreezesPerMonth (monthly).
- Presence-based: inserting a StreakFreeze row for the missed date preserves
  the streak on the next on-read RecalculateAsync; no direct streak mutation.
- Idempotent: new SentStreakFreezeAlert guard entity (unique UserId+FrozenDate)
  plus the existing StreakFreeze unique index, both re-checked before spending.
- Notifies via in-app Notification + push (IPushNotificationService); localized
  copy via LocaleHelper, mirroring GoalDeadlineNotificationService.
- EF migration AddSentStreakFreezeAlert; account reset purges the guard table;
  registered in ServiceCollectionExtensions and BackgroundServiceHealthCheck.

Unit tests cover the eligibility predicate, local-yesterday computation,
interval default, notification copy, and the guard entity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One correctness bug: LoadRecentCompletionsAsync omits !h.IsDeleted from the habit filter, but UserStreakService.LoadStreakDataAsync (the source of truth for streak computation) requires both !h.IsDeleted && !h.IsBadHabit. A user who logged a now-deleted habit on the missed date will be treated as 'active' by this service, preventing the auto-freeze — yet UserStreakService won't count that log, so the streak breaks anyway. One-character fix: add && !h.IsDeleted to the WHERE predicate on line 108. Everything else looks solid: idempotency design, monthly cap, Pro guard, migration, health-check registration, and unit test coverage are all correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The second commit removed the manual ActivateStreakFreezeCommand cleanly, but the one correctness bug I flagged in my prior review was not addressed: LoadRecentCompletionsAsync still omits !h.IsDeleted from the habit WHERE predicate (line 108). One-character fix — see inline comment. Everything else is solid.

…ck (#108)

Aligns StreakFreezeAutoActivationService.LoadRecentCompletionsAsync with
UserStreakService.LoadStreakDataAsync by filtering out soft-deleted habits when
computing recent completions, so a deleted habit's log can no longer count as
activity for a date and suppress the auto-freeze. Adds DB-backed tests locking
the LoadRecentCompletionsAsync contract for soft-deleted and live habit logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The third commit cleanly resolves the correctness bug I flagged in both prior reviews: LoadRecentCompletionsAsync now filters !h.IsDeleted && !h.IsBadHabit on the habit predicate (line 108), matching the UserStreakService source of truth, and two DB-backed unit tests lock that contract against regression. The rest of the implementation holds up on re-read: idempotency is solid (double-guard via StreakFreeze unique index + SentStreakFreezeAlert), the monthly cap window is correct, Pro-only guard is enforced in memory after the conservative UTC pre-filter, SentStreakFreezeAlerts is purged in AccountResetRepository, the health check entry uses a 3× staleness threshold, and the migration/EF config is consistent with the other guard-entity pattern in the codebase. Good to merge.

@thomasluizon thomasluizon reopened this Jun 4, 2026
@thomasluizon
thomasluizon merged commit 166bbc5 into main Jun 4, 2026
5 checks passed
@thomasluizon
thomasluizon deleted the issue-108 branch June 4, 2026 23:57
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.

1 participant