Skip to content

fix(gamification): show streak to free users (read from profile, not Pro-gated endpoint) - #248

Merged
thomasluizon merged 4 commits into
mainfrom
fix/streak-free-tier
Jun 22, 2026
Merged

thomasluizon merged 4 commits into
mainfrom
fix/streak-free-tier

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Jun 22, 2026 •

Copy link
Copy Markdown
Owner

What

A free user with a real streak saw 0 in the Today header flame, the streak page's "Maior" (longest), and the Profile page. Root cause: those surfaces read the streak from useStreakInfo → GET /api/gamification/streak, which is Pro-gated (if (!user.HasProAccess) → 403) and client-disabled for free users (enabled: hasProAccess), so they fell back to ?? 0. "Atual" was already correct because it reads the ungated /api/profile.

Fix (web + mobile, parity)

  • Today header + Profile page read profile.currentStreak; removed the Pro-gated useStreakInfo.
  • Streak page "Maior" reads profile.longestStreak.
  • Added longestStreak to the shared profileSchema + test factory.
  • Updated web profile/streak/profile-page tests (incl. a free-user regression test).

Why

Per #186 / #196, streak (current + longest) is a free feature; only the freeze system stays Pro. This is the "streak numbers free" slice — the broader freeze/XP rebalance + upgrade matrix remain in #186/#196.

Tests

shared 990 · web 92 (streak/today/profile/profile-page) · mobile 20 (today screen) · type-check clean.

Notes

  • Depends on the paired API PR exposing ProfileResponse.LongestStreak (deploy API first; client falls back to 0 gracefully — clients don't Zod-parse the profile, they JSON.parse … as T).
  • Known limitation (deferred to Moderate free-tier entitlement rebalance #186): a free user's stored streak refreshes on habit-log but not on a passive missed day (the on-read recompute is still Pro), so it can read stale until the next log — still far better than always-0.

Also bundled — CI review tooling (implements #226)

Separate commits, unrelated to the streak fix:

  • WORKFLOW.md: add /execute as the driven shortcut for the single-slice path (mirrors /feature).
  • claude-review.yml is now a thin decisive wrapper around the /pr-review skill — it runs the skill (single source of truth) and posts exactly one --approve / --request-changes, never a bare comment. The duplicated inline rubric is retired.
  • /pr-review skill hardened: a new Signal gate in rubric.md (high-signal only — no style nits; deterministic NEEDS WORK iff any Critical/High), referenced in SKILL.md Phase 3, with decisive + caller-aware posting in Phase 7 (CI posts the decision; local self-review falls back to --comment since GitHub blocks self-approval).

Paired PR

Backend: thomasluizon/orbit-api#213

🤖 Generated with Claude Code

…Pro-gated endpoint)

The home header flame chip and the streak page's "Maior" (longest) sourced
the streak from useStreakInfo -> GET /api/gamification/streak, which is
Pro-gated (and client-disabled for free users). Free users always saw 0,
even with a real streak. Current streak was already free via /api/profile;
longest was not exposed there.

- Header reads profile.currentStreak; remove the Pro-gated useStreakInfo
  from the Today screen (web + mobile).
- Streak page "Maior" reads profile.longestStreak (web + mobile).
- Add longestStreak to the shared profile schema + test factory.
- Update web profile/streak tests for the new field + source.

Partial pull-forward of #186/#196 (streak is free per the upgrade matrix);
the broader freeze/XP rebalance remains in those issues.

Paired with orbit-api fix/streak-free-tier (ProfileResponse.LongestStreak).

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

vercel Bot commented Jun 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jun 22, 2026 3:44pm

Request Review

@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.

Code Review: PR #248

Scope: PR #248 — fix(gamification): show streak to free users (read from profile, not Pro-gated endpoint)
Recommendation: NEEDS WORK

Summary

This PR correctly fixes the root cause: both the Today header and the Streak page's "Maior" (longest) card were reading from the Pro-gated GET /api/gamification/streak endpoint, causing free users to see 0. The fix moves both reads to profile.currentStreak / profile.longestStreak (sourced from the ungated GET /api/profile), which is the right architectural decision. The shared type addition and test updates are clean. However, the web Profile page still reads its streak display from the Pro-gated useStreakInfo hook — the exact same bug just fixed on the Today screen — and the new longestStreak field is required/non-nullable in the Zod schema against an API whose paired PR may not be deployed yet.


Findings

High

[HIGH] Web Profile page still reads currentStreak from the Pro-gated hook — same bug, missed surface

  • dimension: Correctness (#1) + Parity (#9)
  • location: apps/web/app/(app)/profile/page.tsx:43-44
  • issue: profile/page.tsx still has const { data: streakInfo } = useStreakInfo(profile?.hasProAccess ?? false) and const streak = streakInfo?.currentStreak ?? 0. A free user's streak shows as 0 in the Profile header bar and stat tiles — the same regression this PR fixes on Today and Streak. Mobile's apps/mobile/app/(tabs)/profile.tsx:207 already reads const streak = profile?.currentStreak ?? 0 (no Pro gate), so web is behind mobile.
  • risk: Free users continue to see 0 on the Profile screen despite the fix. The PR's stated goal is only partially achieved. Mobile profile shows the correct value; web profile shows 0 for free users.
  • fix: In apps/web/app/(app)/profile/page.tsx, remove the useStreakInfo call and replace streak with profile?.currentStreak ?? 0, mirroring mobile line 207.
  • reference: CLAUDE.md rule 1 (root cause over workarounds), CLAUDE.md cross-platform parity (MANDATORY)

[HIGH] longestStreak added as required (non-nullable) before the API deploy is confirmed

  • dimension: Contract drift + backward-compat guard (#11)
  • location: packages/shared/src/types/profile.ts:47
  • issue: longestStreak: z.number() is required and non-nullable. The PR body says the client "falls back to 0 gracefully until then", but that is not what happens. Zod parse of ProfileResponse will throw (ZodError) if longestStreak is absent from the server response. The ?? 0 at call sites only runs when profile itself is nullish — not when the Zod parse fails. Any useProfile call hitting a pre-orbit-api#213 API build will fail to parse the profile response entirely — not just show 0 for streak.
  • risk: Until orbit-api#213 is live, all platform clients lose their profile data on every page that calls useProfile. This is a silent breakage window between frontend and API deploy.
  • fix: Use longestStreak: z.number().optional().default(0) to make the Zod parse tolerant until the API ships the field, then tighten it once orbit-api#213 is confirmed deployed.
  • reference: CLAUDE.md "Backward compatibility (append-only + deploy-order)"

Medium

[MEDIUM] useStreakInfo export on web has reduced caller surface — candidate for un-export

  • dimension: Dead / stale code (#2)
  • location: apps/web/hooks/use-gamification.ts:90
  • issue: After applying the High fix to profile/page.tsx, the only remaining caller of the exported useStreakInfo will be inside useStreakFreeze internally. The export would no longer be needed from outside the file.
  • fix: After the High fix, grep for direct useStreakInfo imports outside use-gamification.ts; if none, remove the export keyword and delete the now-orphaned test imports.
  • reference: CLAUDE.md rule 2 (delete unused code immediately)

[MEDIUM] No test coverage for the free-user streak case on the web Profile page

  • dimension: Correctness (#1)
  • location: apps/web/__tests__/app/profile-page.test.tsx:46
  • issue: The profile-page test mocks useStreakInfo but never asserts that a free user (hasProAccess: false) sees a real streak value. The regression went undetected here.
  • fix: Add a test case: render with profile.hasProAccess = false, profile.currentStreak = 5 and assert the streak tile shows 5.
  • reference: CLAUDE.md "Every new feature needs tests. Tests asserting behavior, not implementation details."

Low / Info

[INFO] longestStreak field naming and type convention match the two existing sibling fields in gamification types — consistent with gamificationProfileSchema and streakInfoSchema. No action required.

[INFO] Deploy ordering intent is correctly documented in the PR body — the concern raised in the High finding is about Zod strictness, not missing awareness.


Subagents

Agent Verdict
parity-checker PARTIAL — Today + Streak screens are PAIRED; web Profile page is behind mobile
i18n-syncer IN SYNC — no new user-facing strings; all keys present in both locales
contract-aligner MATCH (conditional) — Zod convention aligns with established pattern; contingent on orbit-api#213 delivering public int LongestStreak { get; init; } on ProfileResponse
security-reviewer N/A — no orbit-api changes in this diff

Validation

Check Result Details
Lint N/A PR already merged; CI green per PR body
Type check N/A PR body: "type-check clean"
Tests N/A PR body: shared 990 · web 90 · mobile 20 — all passing
Build (api) N/A No orbit-api changes in this diff

What's good

  • Root cause correctly identified and fixed at the right level — removing the Pro gate rather than patching around it.
  • Today header and Streak page updated symmetrically on both platforms.
  • longestStreak added to the right schema (profileSchema) so it flows through the existing useProfile path with no new hook.
  • Test factory and inline test fixtures updated everywhere the profile object is constructed, keeping tests green.
  • PR body is honest about the deploy dependency and the known stale-streak limitation for free users.
  • Mobile profile was already reading from profile.currentStreak directly — the PR validates that pattern.

Recommendation

Two items before this fix is complete:

  1. Fix the web Profile page (apps/web/app/(app)/profile/page.tsx:43-44): replace useStreakInfo with profile?.currentStreak ?? 0 and add a free-user test case.
  2. Make longestStreak parse-tolerant (z.number().optional().default(0)) until orbit-api#213 is confirmed deployed, or confirm the API was deployed first and the breakage window has already closed.

🤖 Generated with Claude Code

thomasluizon and others added 2 commits June 22, 2026 12:19
…o claude-review.yml

- WORKFLOW.md: add /execute as the driven shortcut for the single-slice path
  (mirrors how /feature is presented for the medium/large path).
- claude-review.yml: extend the contract category into a backward-compat guard
  that flags removed/renamed/newly-required fields breaking already-shipped
  mobile clients (mirrors /pr-review Phase 5).

Relates to #226.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Missed surface in the streak free-tier fix: apps/web/app/(app)/profile/page.tsx
still read the streak from the Pro-gated useStreakInfo, so free users saw 0 on
the Profile screen (mobile already reads profile.currentStreak). Source it from
profile.currentStreak to match, plus a free-user regression test.

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

Copy link
Copy Markdown
Owner Author

Thanks — addressed in b3ebce6:

  • [HIGH] Web Profile page still Pro-gated — ✅ fixed. apps/web/app/(app)/profile/page.tsx now reads profile.currentStreak (mirrors mobile profile.tsx:207); removed the useStreakInfo import. Parity restored.
  • [MEDIUM] No free-user test on the Profile page — ✅ added a regression test (profile-page.test.tsx): a free user with currentStreak: 13 now renders 13 (would have shown 0 before the fix).
  • [HIGH] longestStreak required → Zod parse throws — respectfully, false positive. The clients don't Zod-parse the profile response: web fetchJson does JSON.parse(text) as T (api-fetch.ts:150) and mobile apiClient has no .parse for it. A missing field is undefined → ?? 0 at the call sites. No ZodError, no breakage window. Keeping z.number() for consistency with its sibling currentStreak (also required).
  • [MEDIUM] Un-export useStreakInfo — keeping it: it's still used internally by useStreakFreeze (freeze data, Pro) and has direct hook tests, so the export is load-bearing, not dead.

Side note: this review posted as a comment rather than approve/request-changes because it ran via the /pr-review skill (which posts with gh pr review --comment) instead of claude-review.yml's inline decisive-outcome prompt — that's the wiring tracked in #226.

…ps nits (#226)

- claude-review.yml: replace the duplicated inline rubric with a thin wrapper
  that runs the /pr-review skill (single source of truth) and posts exactly one
  decisive review -- --approve or --request-changes, never a bare comment. Skips
  Phase 6 /validate and the cross-repo checks that need the un-checked-out
  orbit-api.
- rubric.md: add a "Signal gate" -- high-signal only (Critical/High/actionable
  Medium); never post style nits; outcome is deterministic (NEEDS WORK iff any
  Critical/High survives).
- SKILL.md: reference the gate in Phase 3; Phase 7 posting is now decisive and
  caller-aware (CI wrapper posts the decision; local self-review falls back to
  --comment since GitHub blocks self-approval).

Implements #226.

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

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit aa75ff5 into main Jun 22, 2026
8 checks passed
@thomasluizon
thomasluizon deleted the fix/streak-free-tier branch June 22, 2026 16:14
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