From d8f0cf26be7d38ba6ceb69b0a0000655e2fd0075 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 14:50:38 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(gamification):=20tappable=20streak=20b?= =?UTF-8?q?adge=20=E2=86=92=20streak=20page=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the Today-header StreakBadge interactive on both platforms so a tap routes to the existing /streak page. - Web: render the badge as a ) } From c8acd32e5d83488c204919fe3c7fcabfee055581 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 16:10:42 -0300 Subject: [PATCH 2/5] feat(gamification): auto-freeze streak UI, remove manual activation (#108) Remove the manual streak-freeze activation flow across web and mobile and redesign the freeze section for the auto-activation model. - Delete useActivateStreakFreeze (web + mobile), the activate-freeze Server Action, and the streakFreeze endpoint constant. Keep gamificationKeys.streak() for refetching. - Redesign the freeze section (Linear-tactical): SectionLabel + AUTO chip, auto explainer, a banked charge gauge (frozen pips, mount fade/scale), Used-this-month and Next-freeze rows, and an auto-protected days list. - Gate the section behind Pro (profile.hasProAccess); free users get a quiet hairline Pro line linking to /upgrade. - Rewire StreakFreezeCelebration to fire on the isFrozenToday false->true transition (auto-applied freeze) instead of manual-activation success. - Replace manual i18n keys under streakDisplay.freeze with the auto-model copy in both en and pt-BR; update streak page + hook tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/hooks/use-gamification.test.tsx | 58 +--- apps/mobile/__tests__/lib/i18n.test.ts | 4 +- apps/mobile/app/streak-sections.tsx | 317 +++++++++++------- apps/mobile/app/streak.tsx | 108 ++---- apps/mobile/hooks/use-gamification.ts | 39 +-- .../__tests__/hooks/use-gamification.test.ts | 64 +--- apps/web/__tests__/pages/streak.test.tsx | 179 +++++----- .../streak/_components/streak-sections.tsx | 300 ++++++++--------- apps/web/app/(app)/streak/page.tsx | 113 +------ apps/web/app/(app)/streak/streak.css | 21 ++ apps/web/app/actions/gamification.ts | 9 - apps/web/hooks/use-gamification.ts | 37 +- .../shared/src/__tests__/endpoints.test.ts | 1 - packages/shared/src/api/endpoints.ts | 1 - packages/shared/src/i18n/en.json | 45 +-- packages/shared/src/i18n/pt-BR.json | 45 +-- 16 files changed, 545 insertions(+), 796 deletions(-) delete mode 100644 apps/web/app/actions/gamification.ts diff --git a/apps/mobile/__tests__/hooks/use-gamification.test.tsx b/apps/mobile/__tests__/hooks/use-gamification.test.tsx index 851c9691d..fd1e03425 100644 --- a/apps/mobile/__tests__/hooks/use-gamification.test.tsx +++ b/apps/mobile/__tests__/hooks/use-gamification.test.tsx @@ -1,12 +1,9 @@ import React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { API } from '@orbit/shared/api' import { createMockGamificationProfile } from '@orbit/shared/__tests__/factories' -import { gamificationKeys, profileKeys } from '@orbit/shared/query' -import type { StreakInfo, StreakFreezeResponse } from '@orbit/shared/types/gamification' +import type { StreakInfo } from '@orbit/shared/types/gamification' import { - useActivateStreakFreeze, useGamificationProfile, useStreakFreeze, useStreakInfo, @@ -30,15 +27,6 @@ const mocks = vi.hoisted(() => { const queryClient = { invalidateQueries: vi.fn(async () => {}), - setQueryData: vi.fn((queryKey: readonly unknown[], updater: StreakInfo | ((old: StreakInfo | undefined) => StreakInfo | undefined)) => { - if (JSON.stringify(queryKey) !== JSON.stringify(gamificationKeys.streak())) { - return - } - - state.streakInfo = typeof updater === 'function' - ? (updater(state.streakInfo) ?? state.streakInfo) - : updater - }), } return { @@ -63,18 +51,6 @@ const mocks = vi.hoisted(() => { } }), useQueryClient: vi.fn(() => queryClient), - useMutation: vi.fn((options: { - mutationFn: () => Promise - onSuccess?: (data: StreakFreezeResponse) => void - onSettled?: () => void - }) => ({ - mutateAsync: async () => { - const data = await options.mutationFn() - options.onSuccess?.(data) - options.onSettled?.() - return data - }, - })), apiClient: vi.fn(), } }) @@ -82,7 +58,6 @@ const mocks = vi.hoisted(() => { vi.mock('@tanstack/react-query', () => ({ useQuery: mocks.useQuery, useQueryClient: mocks.useQueryClient, - useMutation: mocks.useMutation, })) vi.mock('@/lib/api-client', () => ({ @@ -137,10 +112,8 @@ describe('mobile useGamificationProfile', () => { beforeEach(() => { mocks.state.gamificationProfile = createMockGamificationProfile() mocks.queryClient.invalidateQueries.mockClear() - mocks.queryClient.setQueryData.mockClear() mocks.useQuery.mockClear() mocks.useQueryClient.mockClear() - mocks.useMutation.mockClear() mocks.apiClient.mockClear() }) @@ -213,10 +186,8 @@ describe('mobile useStreakInfo and streak freeze', () => { canEarnMore: true, } mocks.queryClient.invalidateQueries.mockClear() - mocks.queryClient.setQueryData.mockClear() mocks.useQuery.mockClear() mocks.useQueryClient.mockClear() - mocks.useMutation.mockClear() mocks.apiClient.mockClear() }) @@ -236,31 +207,4 @@ describe('mobile useStreakInfo and streak freeze', () => { expect(hook.value.currentStreak).toBe(7) expect(hook.value.canFreeze).toBe(true) }) - - it('syncs the streak cache after activating a freeze', async () => { - mocks.apiClient.mockResolvedValue({ - freezesRemainingThisMonth: 1, - frozenDate: '2025-01-15', - currentStreak: 7, - }) - - const hook = await renderHookValue(() => useActivateStreakFreeze()) - - await hook.value.mutateAsync() - - expect(mocks.apiClient).toHaveBeenCalledWith( - API.gamification.streakFreeze, - expect.objectContaining({ method: 'POST' }), - ) - expect(mocks.queryClient.setQueryData).toHaveBeenCalledWith( - gamificationKeys.streak(), - expect.any(Function), - ) - expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: gamificationKeys.streak(), - }) - expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: profileKeys.all, - }) - }) }) diff --git a/apps/mobile/__tests__/lib/i18n.test.ts b/apps/mobile/__tests__/lib/i18n.test.ts index 267683ffe..87f30ac89 100644 --- a/apps/mobile/__tests__/lib/i18n.test.ts +++ b/apps/mobile/__tests__/lib/i18n.test.ts @@ -30,8 +30,8 @@ describe('mobile i18n interpolation', () => { expect(plural(i18n.t('goals.deadline.daysLeft', { n: 3 }), 3)).toBe('3 days left') expect(plural(i18n.t('streakDisplay.detail.daysUnit', { count: 1 }), 1)).toBe('day') expect(plural(i18n.t('streakDisplay.detail.daysUnit', { count: 4 }), 4)).toBe('days') - expect(plural(i18n.t('streakDisplay.freeze.available', { count: 0 }), 0)).toBe('No freezes available') - expect(plural(i18n.t('streakDisplay.freeze.available', { count: 2 }), 2)).toBe('2 freezes available') + expect(i18n.t('streakDisplay.freeze.nextFreeze.inDays', { days: 3 })).toBe('in 3 days') + expect(i18n.t('streakDisplay.freeze.auto.chip')).toBe('AUTO') expect(plural(i18n.t('habits.frequency.everyNWeeks', { n: 2 }), 2)).toBe('Every 2 weeks') expect(plural(i18n.t('habits.breakdown.createdSuccess', { n: 2 }), 2)).toBe('Created 2 habits successfully') }) diff --git a/apps/mobile/app/streak-sections.tsx b/apps/mobile/app/streak-sections.tsx index 38b1635ad..4d54edec0 100644 --- a/apps/mobile/app/streak-sections.tsx +++ b/apps/mobile/app/streak-sections.tsx @@ -1,7 +1,10 @@ -import { Pressable, StyleSheet, Text, View } from 'react-native' +import { useEffect, useRef } from 'react' +import { Animated, Pressable, StyleSheet, Text, View } from 'react-native' import Svg, { Circle, Line } from 'react-native-svg' import type { createTokensV2 } from '@/lib/theme' +import { SectionLabel } from '@/components/ui/section-label' import { SettingsGroup, SettingsGroupRow } from '@/components/ui/settings-group' +import { StatusDot } from '@/components/ui/status-dot' type Tokens = ReturnType type TranslationFn = (key: string, params?: Record) => string @@ -139,154 +142,217 @@ function LegendItem({ ) } +const STREAK_DAYS_PER_FREEZE = 7 + interface FreezeSectionProps { t: TranslationFn tokens: Tokens + isPro: boolean streak: number freezesUsedThisMonth: number maxFreezesPerMonth: number streakFreezesAccumulated: number maxStreakFreezesAccumulated: number - daysUntilNextFreeze: number isFrozenToday: boolean - hasCompletedToday: boolean - canFreeze: boolean - canEarnMore: boolean - hasReachedMonthlyLimit: boolean - freezeSuccess: boolean - errorMessage: string | null - onActivateFreeze: () => void + protectedDates: string[] + onUpgrade: () => void + displayDate: (value: string, options?: Intl.DateTimeFormatOptions) => string } /** - * Freeze section: hairline rows for Available / This month / progress. - * - When monthly limit is reached: shows "Available: 0" + italic limit message. - * - Otherwise: shows count + inline "Use" link (if `canFreeze`) + usage row. + * Auto-freeze status section: explainer, a charge gauge of banked freezes, + * monthly usage, next-freeze countdown, and the auto-protected days list. + * Free users see a quiet Pro gate instead of the gauge. */ export function FreezeSection({ t, tokens, + isPro, streak, freezesUsedThisMonth, maxFreezesPerMonth, streakFreezesAccumulated, maxStreakFreezesAccumulated, - daysUntilNextFreeze, isFrozenToday, - hasCompletedToday, - canFreeze, - canEarnMore, - hasReachedMonthlyLimit, - freezeSuccess, - errorMessage, - onActivateFreeze, + protectedDates, + onUpgrade, + displayDate, }: Readonly) { - const showActiveToday = isFrozenToday - const progressLabel = - daysUntilNextFreeze === 0 - ? t('streakDisplay.freeze.progressReady') - : t('streakDisplay.freeze.progressSubtitle', { - days: daysUntilNextFreeze, - count: daysUntilNextFreeze, - }) + if (!isPro) { + return ( + + + + + {t('common.upgrade')} + + + } + /> + + + ) + } - const helperText = (() => { - if (hasReachedMonthlyLimit) { - return t('streakDisplay.freeze.monthlyLimit', { max: maxFreezesPerMonth }) - } - if (!canEarnMore) { - return t('streakDisplay.freeze.maxAccumulated', { - max: maxStreakFreezesAccumulated, - }) - } - return progressLabel - })() + const isBankedFull = streakFreezesAccumulated >= maxStreakFreezesAccumulated + const nextFreezeDays = STREAK_DAYS_PER_FREEZE - (streak % STREAK_DAYS_PER_FREEZE) + const dates = protectedDates.slice(0, 5) return ( <> + + {t('streakDisplay.freeze.auto.explainer')} + - {hasReachedMonthlyLimit ? ( - 0 - } - /> - ) : ( - <> + + + + {`${streakFreezesAccumulated}/${maxStreakFreezesAccumulated}`} + + + } + /> + + {`${freezesUsedThisMonth}/${maxFreezesPerMonth}`} + + } + /> + + {isBankedFull + ? t('streakDisplay.freeze.nextFreeze.full') + : t('streakDisplay.freeze.nextFreeze.inDays', { + days: nextFreezeDays, + })} + + } + /> + + + + {t('streakDisplay.freeze.protected.label')} + + {isFrozenToday || dates.length > 0 ? ( + + {isFrozenToday ? ( } trailing={ - <> - - {streakFreezesAccumulated} - - {streak > 0 && !isFrozenToday && canFreeze ? ( - - - {t('streakDisplay.freeze.activate')} - - - ) : null} - + + {t('streakDisplay.freeze.protected.todayValue')} + } /> + ) : null} + {dates.map((date) => ( - {t('streakDisplay.freeze.monthlyUsage', { - used: freezesUsedThisMonth, - max: maxFreezesPerMonth, - })} - - } + icon={} /> - - )} - - - - - - {helperText} - - {showActiveToday ? ( - - {t('streakDisplay.freeze.activeToday')} - - ) : null} - {hasCompletedToday && !isFrozenToday && streak > 0 ? ( - - {t('streakDisplay.freeze.completedToday')} - - ) : null} - {freezeSuccess ? ( - - {t('streakDisplay.freeze.success')} + ))} + + ) : ( + + {t('streakDisplay.freeze.protected.empty')} - ) : null} - {errorMessage ? ( - - {errorMessage} - - ) : null} + )} ) } +interface ChargeGaugeProps { + banked: number + max: number + tokens: Tokens +} + +function ChargeGauge({ banked, max, tokens }: Readonly) { + return ( + + {Array.from({ length: max }, (_, index) => ( + + ))} + + ) +} + +interface ChargePipProps { + filled: boolean + delay: number + tokens: Tokens +} + +function ChargePip({ filled, delay, tokens }: Readonly) { + const progress = useRef(new Animated.Value(0)).current + + useEffect(() => { + const animation = Animated.timing(progress, { + toValue: 1, + duration: 180, + delay, + useNativeDriver: true, + }) + animation.start() + return () => animation.stop() + }, [progress, delay]) + + return ( + + ) +} + const styles = StyleSheet.create({ weekGrid: { flexDirection: 'row', @@ -352,29 +418,34 @@ const styles = StyleSheet.create({ groupWrap: { paddingHorizontal: 20, }, - helperBlock: { - paddingHorizontal: 20, - paddingTop: 10, - paddingBottom: 4, - gap: 6, - }, - italicText: { + explainer: { fontFamily: 'Geist', - fontSize: 13, - fontStyle: 'italic', + fontSize: 14, + lineHeight: 21, + marginBottom: 14, }, freezeCount: { fontFamily: 'GeistMono', - fontSize: 14, - fontWeight: '600', + fontSize: 13, + fontVariant: ['tabular-nums'], + }, + gaugeTrailing: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, }, - useLinkPress: { - marginLeft: 4, + gauge: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, }, - useLink: { + upgradeLink: { fontFamily: 'Geist', - fontSize: 13, - fontWeight: '500', - textDecorationLine: 'underline', + fontSize: 14, + }, + emptyText: { + fontFamily: 'Geist', + fontSize: 14, + lineHeight: 21, }, }) diff --git a/apps/mobile/app/streak.tsx b/apps/mobile/app/streak.tsx index 30cf84ef4..58a40a8b3 100644 --- a/apps/mobile/app/streak.tsx +++ b/apps/mobile/app/streak.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef } from 'react' +import { useMemo, useRef, useEffect } from 'react' import { View, Text, @@ -6,15 +6,13 @@ import { ScrollView, } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' import { useTranslation } from 'react-i18next' import { subDays, isToday, format, parseISO } from 'date-fns' import { createTokensV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' import { useProfile } from '@/hooks/use-profile' -import { - useStreakFreeze, - useActivateStreakFreeze, -} from '@/hooks/use-gamification' +import { useStreakFreeze } from '@/hooks/use-gamification' import { useDateFormat } from '@/hooks/use-date-format' import { StreakFreezeCelebration, @@ -24,7 +22,7 @@ import { plural } from '@/lib/plural' import { AppBar } from '@/components/ui/app-bar' import { SectionLabel } from '@/components/ui/section-label' import { SettingsGroup, SettingsGroupRow } from '@/components/ui/settings-group' -import { ConfirmDialogV2 } from '@/components/ui/confirm-dialog-v2' +import { buildUpgradeHref } from '@/lib/upgrade-route' import { StreakWeekTimeline, FreezeSection } from './streak-sections' import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' @@ -38,30 +36,30 @@ export default function StreakScreen() { [currentScheme, currentTheme], ) const goBackOrFallback = useGoBackOrFallback() + const router = useRouter() const { profile } = useProfile() const streak = profile?.currentStreak ?? 0 + const isPro = profile?.hasProAccess ?? false const { displayDate } = useDateFormat() const styles = useMemo(() => createStyles(tokens), [tokens]) const { streakQuery, streakInfo, - freezesAvailable, isFrozenToday, - hasCompletedToday, - canFreeze, streakFreezesAccumulated, maxStreakFreezesAccumulated, - daysUntilNextFreeze, freezesUsedThisMonth, maxFreezesPerMonth, - canEarnMore, - hasReachedMonthlyLimit, } = useStreakFreeze(profile) - const activateFreezeMutation = useActivateStreakFreeze() const freezeCelebrationRef = useRef(null) + const wasFrozenTodayRef = useRef(isFrozenToday) - const [showConfirm, setShowConfirm] = useState(false) - const [freezeSuccess, setFreezeSuccess] = useState(false) + useEffect(() => { + if (isFrozenToday && !wasFrozenTodayRef.current) { + freezeCelebrationRef.current?.show() + } + wasFrozenTodayRef.current = isFrozenToday + }, [isFrozenToday]) const encouragement = useMemo(() => { if (streak >= 365) return t('streakDisplay.profile.encouragement365') @@ -114,18 +112,6 @@ export default function StreakScreen() { }) }, [streakInfo, streak, isFrozenToday, displayDate]) - async function handleFreeze() { - setShowConfirm(false) - try { - await activateFreezeMutation.mutateAsync() - setFreezeSuccess(true) - setTimeout(() => setFreezeSuccess(false), 3000) - freezeCelebrationRef.current?.show() - } catch { - // Error handled by mutation - } - } - const heroEyebrow = isFrozenToday ? t('streakDisplay.freeze.activeToday') : t('streakDisplay.detail.currentStreak') @@ -250,69 +236,37 @@ export default function StreakScreen() { - {t('streakDisplay.freeze.title')} + + {t('streakDisplay.freeze.auto.chip')} + + } + > + {t('streakDisplay.freeze.title')} + setShowConfirm(true)} + protectedDates={streakInfo?.recentFreezeDates ?? []} + onUpgrade={() => router.push(buildUpgradeHref('/streak'))} + displayDate={displayDate} /> - {streakInfo?.recentFreezeDates && - streakInfo.recentFreezeDates.length > 0 ? ( - <> - - {t('streakDisplay.freeze.recentLabel')} - - - - {streakInfo.recentFreezeDates.slice(0, 5).map((date) => ( - - ))} - - - - ) : null} - )} - setShowConfirm(false)} - title={t('streakDisplay.freeze.confirmTitle')} - body={t('streakDisplay.freeze.confirmBody', { - streak, - remaining: freezesAvailable, - count: freezesAvailable, - })} - cancelLabel={t('common.cancel')} - actionLabel={t('streakDisplay.freeze.activate')} - onAction={() => { - void handleFreeze() - }} - /> ) @@ -377,5 +331,11 @@ function createStyles(_tokens: Tokens) { height: 8, borderRadius: 999, }, + autoChip: { + fontFamily: 'GeistMono', + fontSize: 11, + fontWeight: '500', + letterSpacing: 0.66, + }, }) } diff --git a/apps/mobile/hooks/use-gamification.ts b/apps/mobile/hooks/use-gamification.ts index e04306fc4..1d8643d31 100644 --- a/apps/mobile/hooks/use-gamification.ts +++ b/apps/mobile/hooks/use-gamification.ts @@ -1,16 +1,14 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useQuery, - useMutation, useQueryClient, } from '@tanstack/react-query' -import { gamificationKeys, profileKeys , QUERY_STALE_TIMES } from '@orbit/shared/query' +import { gamificationKeys, QUERY_STALE_TIMES } from '@orbit/shared/query' import { API } from '@orbit/shared/api' import type { GamificationProfile, StreakInfo, - StreakFreezeResponse, } from '@orbit/shared/types/gamification' import { deriveGamificationProfileState, @@ -93,41 +91,6 @@ export function useStreakInfo() { }) } -export function useActivateStreakFreeze() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: () => - apiClient(API.gamification.streakFreeze, { - method: 'POST', - }), - - onSuccess: (data) => { - queryClient.setQueryData(gamificationKeys.streak(), (old) => { - if (!old) return old - const nextAccumulated = Math.max(0, data.streakFreezesAccumulated ?? Math.max(0, old.streakFreezesAccumulated - 1)) - const nextUsedThisMonth = old.maxFreezesPerMonth - data.freezesRemainingThisMonth - return { - ...old, - isFrozenToday: true, - freezesAvailable: data.freezesRemainingThisMonth, - freezesUsedThisMonth: nextUsedThisMonth, - currentStreak: data.currentStreak, - recentFreezeDates: [...old.recentFreezeDates, data.frozenDate], - streakFreezesAccumulated: nextAccumulated, - freezesAvailableToUse: Math.min(nextAccumulated, Math.max(0, old.maxFreezesPerMonth - nextUsedThisMonth)), - canEarnMore: nextAccumulated < old.maxStreakFreezesAccumulated, - } - }) - }, - - onSettled: () => { - queryClient.invalidateQueries({ queryKey: gamificationKeys.streak() }) - queryClient.invalidateQueries({ queryKey: profileKeys.all }) - }, - }) -} - export function useStreakFreeze(profile?: { streakFreezesAvailable?: number; currentStreak?: number } | null) { const streakQuery = useStreakInfo() const streakInfo = streakQuery.data ?? null diff --git a/apps/web/__tests__/hooks/use-gamification.test.ts b/apps/web/__tests__/hooks/use-gamification.test.ts index 19147a1a0..06d90480b 100644 --- a/apps/web/__tests__/hooks/use-gamification.test.ts +++ b/apps/web/__tests__/hooks/use-gamification.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { renderHook, waitFor, act } from '@testing-library/react' +import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { useGamificationProfile, useStreakInfo, - useActivateStreakFreeze, useStreakFreeze, } from '@/hooks/use-gamification' import type { GamificationProfile, StreakInfo } from '@orbit/shared/types/gamification' @@ -24,19 +23,6 @@ vi.mock('@/lib/api-fetch', () => ({ ), })) -// Mock the gamification Server Action so activateStreakFreeze doesn't hit Next.js cookies() -// in the test environment. Delegates to mockFetch so test payloads still flow through. -vi.mock('@/app/actions/gamification', () => ({ - activateStreakFreeze: vi.fn(async () => { - const res = await fetch('/api/gamification/streak/freeze', { method: 'POST' }) - if (!res.ok) { - const body = await res.json().catch(() => null) - throw new Error(body?.error ?? `Request failed with status ${res.status}`) - } - return res.json() - }), -})) - function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { @@ -321,51 +307,3 @@ describe('useStreakFreeze', () => { }) }) -describe('useActivateStreakFreeze', () => { - beforeEach(() => { - mockFetch.mockReset() - }) - - it('posts to streak freeze endpoint', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - freezesRemainingThisMonth: 1, - frozenDate: '2025-01-15', - currentStreak: 7, - }), - }) - - const { result } = renderHook(() => useActivateStreakFreeze(), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.mutateAsync() - }) - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('streak/freeze'), - expect.objectContaining({ method: 'POST' }), - ) - }) - - it('throws on error response', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: () => Promise.resolve({ error: 'No freezes available' }), - }) - - const { result } = renderHook(() => useActivateStreakFreeze(), { - wrapper: createWrapper(), - }) - - await expect( - act(async () => { - await result.current.mutateAsync() - }), - ).rejects.toThrow('No freezes available') - }) -}) diff --git a/apps/web/__tests__/pages/streak.test.tsx b/apps/web/__tests__/pages/streak.test.tsx index 74129ed33..bdb9e3d63 100644 --- a/apps/web/__tests__/pages/streak.test.tsx +++ b/apps/web/__tests__/pages/streak.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen } from '@testing-library/react' // --------------------------------------------------------------------------- // Mocks -- must come before component import @@ -36,31 +36,21 @@ vi.mock('@/hooks/use-profile', () => ({ let mockStreakQuery = { isLoading: false, data: null } let mockStreakInfo: Record | null = null -let mockFreezesAvailable = 2 let mockIsFrozenToday = false -let mockHasCompletedToday = false -let mockCanFreeze = true +let mockStreakFreezesAccumulated = 2 +let mockMaxStreakFreezesAccumulated = 3 +let mockFreezesUsedThisMonth = 1 +let mockMaxFreezesPerMonth = 3 vi.mock('@/hooks/use-gamification', () => ({ useStreakFreeze: () => ({ streakQuery: mockStreakQuery, streakInfo: mockStreakInfo, - freezesAvailable: mockFreezesAvailable, isFrozenToday: mockIsFrozenToday, - hasCompletedToday: mockHasCompletedToday, - canFreeze: mockCanFreeze, - streakFreezesAccumulated: 0, - maxStreakFreezesAccumulated: 3, - daysUntilNextFreeze: 7, - freezesUsedThisMonth: 0, - maxFreezesPerMonth: 2, - canEarnMore: true, - hasReachedMonthlyLimit: false, - }), - useActivateStreakFreeze: () => ({ - mutateAsync: vi.fn().mockResolvedValue({}), - isPending: false, - error: null, + streakFreezesAccumulated: mockStreakFreezesAccumulated, + maxStreakFreezesAccumulated: mockMaxStreakFreezesAccumulated, + freezesUsedThisMonth: mockFreezesUsedThisMonth, + maxFreezesPerMonth: mockMaxFreezesPerMonth, }), })) @@ -68,17 +58,6 @@ vi.mock('@/components/gamification/streak-freeze-celebration', () => ({ StreakFreezeCelebration: vi.fn().mockReturnValue(null), })) -vi.mock('@/components/ui/app-overlay', () => ({ - AppOverlay: ({ open, children, title }: { open: boolean; children: React.ReactNode; title: string }) => { - if (!open) return null - return ( -
- {children} -
- ) - }, -})) - vi.mock('@/hooks/use-go-back-or-fallback', () => ({ useGoBackOrFallback: () => vi.fn(), })) @@ -95,20 +74,20 @@ import StreakPage from '@/app/(app)/streak/page' describe('StreakPage', () => { beforeEach(() => { - mockProfile = { currentStreak: 10, streakFreezesAvailable: 2 } + mockProfile = { currentStreak: 10, hasProAccess: true } mockStreakQuery = { isLoading: false, data: null } mockStreakInfo = { currentStreak: 10, longestStreak: 30, lastActiveDate: '2025-06-01', recentFreezeDates: [], - freezesAvailable: 2, isFrozenToday: false, } - mockFreezesAvailable = 2 mockIsFrozenToday = false - mockHasCompletedToday = false - mockCanFreeze = true + mockStreakFreezesAccumulated = 2 + mockMaxStreakFreezesAccumulated = 3 + mockFreezesUsedThisMonth = 1 + mockMaxFreezesPerMonth = 3 }) it('renders without crashing', () => { @@ -136,18 +115,11 @@ describe('StreakPage', () => { it('renders streak count in hero section', () => { const { container } = render() - // Streak count is rendered in the .streak-hero__count element const countEl = container.querySelector('.streak-hero__count') expect(countEl).toBeTruthy() expect(countEl?.textContent).toBe('10') }) - it('shows flame SVG when streak is greater than 0', () => { - const { container } = render() - const flameSvg = container.querySelector('svg') - expect(flameSvg).toBeInTheDocument() - }) - it('renders days unit text', () => { render() expect(document.body.textContent).toContain('streakDisplay.detail.daysUnit') @@ -158,14 +130,8 @@ describe('StreakPage', () => { expect(document.body.textContent).toContain('streakDisplay.profile.encouragement7') }) - it('renders encouragement message for streak >= 30', () => { - mockProfile = { currentStreak: 35 } - render() - expect(document.body.textContent).toContain('streakDisplay.profile.encouragement30') - }) - it('renders no encouragement for streak 0', () => { - mockProfile = { currentStreak: 0 } + mockProfile = { currentStreak: 0, hasProAccess: true } render() expect(document.body.textContent).not.toContain('streakDisplay.profile.encouragement1') expect(document.body.textContent).not.toContain('streakDisplay.profile.encouragement7') @@ -189,106 +155,119 @@ describe('StreakPage', () => { it('renders current and longest streak stats', () => { render() - // current label appears in hero eyebrow AND stats row expect(screen.getAllByText('streakDisplay.detail.currentStreak').length).toBeGreaterThan(0) expect(screen.getByText('streakDisplay.detail.longestStreak')).toBeInTheDocument() }) it('renders longest streak value from streakInfo', () => { render() - // The longest streak value (30) is rendered among multiple text nodes expect(document.body.textContent).toContain('30') }) - // ---- Freeze section ---- + // ---- Auto-freeze section ---- - it('renders freeze title and availability', () => { + it('renders freeze title with the AUTO eyebrow chip', () => { render() expect(screen.getByText('streakDisplay.freeze.title')).toBeInTheDocument() - expect(document.body.textContent).toContain('streakDisplay.freeze.accumulatedShort') + expect(screen.getByText('streakDisplay.freeze.auto.chip')).toBeInTheDocument() }) - it('renders activate button when canFreeze is true', () => { + it('renders the auto explainer copy', () => { render() - expect(screen.getByText('streakDisplay.freeze.activate')).toBeInTheDocument() + expect(screen.getByText('streakDisplay.freeze.auto.explainer')).toBeInTheDocument() }) - it('shows frozen today indicator when isFrozenToday', () => { - mockIsFrozenToday = true + it('renders the banked charge gauge with the banked count', () => { render() - // frozen indicator appears in hero eyebrow AND freeze status block - expect(screen.getAllByText('streakDisplay.freeze.activeToday').length).toBeGreaterThan(0) + expect(screen.getByText('streakDisplay.freeze.banked.label')).toBeInTheDocument() + // banked/max mono value + expect(document.body.textContent).toContain('2/3') }) - it('shows completed today hint', () => { - mockHasCompletedToday = true - mockIsFrozenToday = false + it('renders used-this-month and next-freeze rows', () => { + render() + expect(screen.getByText('streakDisplay.freeze.usedThisMonth.label')).toBeInTheDocument() + expect(screen.getByText('streakDisplay.freeze.nextFreeze.label')).toBeInTheDocument() + // streak 10 -> 7 - (10 % 7) = 4 days + expect(document.body.textContent).toContain('streakDisplay.freeze.nextFreeze.inDays') + expect(document.body.textContent).toContain('"days":4') + }) + + it('shows "Banked full" for next-freeze when banked is at max', () => { + mockStreakFreezesAccumulated = 3 render() - expect(screen.getByText('streakDisplay.freeze.completedToday')).toBeInTheDocument() + expect(screen.getByText('streakDisplay.freeze.nextFreeze.full')).toBeInTheDocument() }) - it('shows recent freeze dates when available', () => { + it('does NOT render any activate-freeze control', () => { + render() + expect(screen.queryByText('streakDisplay.freeze.activate')).not.toBeInTheDocument() + }) + + // ---- Protected days ---- + + it('renders the protected-days empty state when no freezes used', () => { + render() + expect(screen.getByText('streakDisplay.freeze.protected.label')).toBeInTheDocument() + expect(screen.getByText('streakDisplay.freeze.protected.empty')).toBeInTheDocument() + }) + + it('lists auto-used protected freeze dates', () => { mockStreakInfo = { currentStreak: 10, longestStreak: 30, lastActiveDate: '2025-06-01', recentFreezeDates: ['2025-05-28', '2025-05-25'], - freezesAvailable: 2, isFrozenToday: false, } render() - expect(screen.getByText('streakDisplay.freeze.recentLabel')).toBeInTheDocument() + expect(screen.queryByText('streakDisplay.freeze.protected.empty')).not.toBeInTheDocument() + // formatted dates render (May 28 / May 25) + expect(document.body.textContent).toContain('May') }) - // ---- Freeze confirmation overlay ---- - - it('opens confirmation overlay when activate button is clicked', () => { + it('prepends a Today row when frozen today', () => { + mockIsFrozenToday = true render() - const activateButton = screen.getByText('streakDisplay.freeze.activate') - fireEvent.click(activateButton) - expect(screen.getByTestId('overlay')).toBeInTheDocument() - expect(document.body.textContent).toContain('streakDisplay.freeze.confirmBody') + expect(screen.getByText('streakDisplay.freeze.protected.today')).toBeInTheDocument() + expect(screen.getByText('streakDisplay.freeze.protected.todayValue')).toBeInTheDocument() }) - it('closes confirmation overlay when cancel is clicked', () => { + it('shows the frozen eyebrow in the hero when frozen today', () => { + mockIsFrozenToday = true render() - fireEvent.click(screen.getByText('streakDisplay.freeze.activate')) - expect(screen.getByTestId('overlay')).toBeInTheDocument() - fireEvent.click(screen.getByText('common.cancel')) - expect(screen.queryByTestId('overlay')).not.toBeInTheDocument() + expect(screen.getAllByText('streakDisplay.freeze.activeToday').length).toBeGreaterThan(0) }) - // ---- Tier styling ---- + // ---- Pro gating ---- - it('applies normal tier class for low streaks', () => { - mockProfile = { currentStreak: 3 } - const { container } = render() - expect(container.querySelector('.streak-hero--normal')).toBeInTheDocument() + it('renders the Pro gate instead of the gauge for free users', () => { + mockProfile = { currentStreak: 10, hasProAccess: false } + render() + expect(screen.getByText('streakDisplay.freeze.pro.gate')).toBeInTheDocument() + expect(screen.getByText('common.upgrade')).toBeInTheDocument() + // gauge rows must not render in the free state + expect(screen.queryByText('streakDisplay.freeze.banked.label')).not.toBeInTheDocument() }) - it('applies strong tier class for streak >= 7', () => { - mockProfile = { currentStreak: 10 } - const { container } = render() - expect(container.querySelector('.streak-hero--strong')).toBeInTheDocument() + it('links the Pro upgrade affordance to the paywall route', () => { + mockProfile = { currentStreak: 10, hasProAccess: false } + render() + const upgradeLink = screen.getByText('common.upgrade').closest('a') + expect(upgradeLink).toHaveAttribute('href', '/upgrade') }) - it('applies intense tier class for streak >= 30', () => { - mockProfile = { currentStreak: 50 } + // ---- Tier styling ---- + + it('applies strong tier class for streak >= 7', () => { + mockProfile = { currentStreak: 10, hasProAccess: true } const { container } = render() - expect(container.querySelector('.streak-hero--intense')).toBeInTheDocument() + expect(container.querySelector('.streak-hero--strong')).toBeInTheDocument() }) it('applies legendary tier class for streak >= 100', () => { - mockProfile = { currentStreak: 150 } + mockProfile = { currentStreak: 150, hasProAccess: true } const { container } = render() expect(container.querySelector('.streak-hero--legendary')).toBeInTheDocument() }) - - it('disable activate button when canFreeze is false', () => { - mockCanFreeze = false - render() - const buttons = screen.getAllByRole('button') - const activateBtn = buttons.find((b) => b.textContent === 'streakDisplay.freeze.activate') - expect(activateBtn).toBeDisabled() - }) }) diff --git a/apps/web/app/(app)/streak/_components/streak-sections.tsx b/apps/web/app/(app)/streak/_components/streak-sections.tsx index 4ba893351..f959fe3e9 100644 --- a/apps/web/app/(app)/streak/_components/streak-sections.tsx +++ b/apps/web/app/(app)/streak/_components/streak-sections.tsx @@ -1,9 +1,7 @@ 'use client' -import { useMemo } from 'react' import { SectionLabel } from '@/components/ui/section-label' import { SettingsGroup, SettingsGroupRow } from '@/components/ui/settings-group' -import { plural } from '@/lib/plural' type StreakDayView = { dateStr: string @@ -152,65 +150,40 @@ function StreakDot({ status }: Readonly<{ status: StreakDayView['status'] }>) { return + } + > + {t('streakDisplay.freeze.title')} + - {recentFreezeDates.length > 0 && ( + {isPro ? ( <> - {t('streakDisplay.freeze.recentLabel')} -
- {recentFreezeDates.slice(0, 5).map((date) => ( - - {date} - - ))} +
+

+ {t('streakDisplay.freeze.auto.explainer')} +

+ + + + + + } + /> + + } + /> + + } + /> + +
+ + {t('streakDisplay.freeze.protected.label')} +
+ {isFrozenToday || protectedDates.length > 0 ? ( + + {isFrozenToday && ( + + )} + {protectedDates.map((date) => ( + + ))} + + ) : ( +

+ {t('streakDisplay.freeze.protected.empty')} +

+ )}
+ ) : ( +
+ + + {t('common.upgrade')} + + } + /> + +
)}
) } -function StatValue({ value }: Readonly<{ value: number | string }>) { +function ChargeGauge({ + banked, + max, +}: Readonly<{ banked: number; max: number }>) { return ( - - {value} + + {Array.from({ length: max }, (_, index) => { + const filled = index < banked + return ( + ) } -function HelperLine({ - color, - children, -}: Readonly<{ color: string; children: React.ReactNode }>) { +function ProtectedRow({ + label, + value, +}: Readonly<{ label: string; value?: string }>) { + return ( + + } + trailing={value ? : undefined} + /> + ) +} + +function StatValue({ value }: Readonly<{ value: number | string }>) { return ( - {children} + {value} ) } diff --git a/apps/web/app/(app)/streak/page.tsx b/apps/web/app/(app)/streak/page.tsx index 31bb8f67a..996cc0c37 100644 --- a/apps/web/app/(app)/streak/page.tsx +++ b/apps/web/app/(app)/streak/page.tsx @@ -1,16 +1,14 @@ 'use client' -import { useState, useMemo, useRef } from 'react' +import { useMemo, useRef, useEffect } from 'react' import { subDays, isToday, format, parseISO } from 'date-fns' -import { useTranslations, useLocale } from 'next-intl' -import { getErrorMessage } from '@orbit/shared/utils' +import { useTranslations } from 'next-intl' import { plural } from '@/lib/plural' import { useProfile } from '@/hooks/use-profile' -import { useActivateStreakFreeze, useStreakFreeze } from '@/hooks/use-gamification' +import { useStreakFreeze } from '@/hooks/use-gamification' import { useDateFormat } from '@/hooks/use-date-format' import { StreakFreezeCelebration, type StreakFreezeCelebrationHandle } from '@/components/gamification/streak-freeze-celebration' import { AppBar } from '@/components/ui/app-bar' -import { AppOverlay } from '@/components/ui/app-overlay' import { FreezeProgressCard, StreakTimelineCard } from './_components/streak-sections' import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' import './streak.css' @@ -18,30 +16,29 @@ import './streak.css' export default function StreakPage() { const t = useTranslations() const goBackOrFallback = useGoBackOrFallback() - const locale = useLocale() const { displayDate } = useDateFormat() const { profile } = useProfile() const streak = profile?.currentStreak ?? 0 + const isPro = profile?.hasProAccess ?? false const { streakQuery, streakInfo, - freezesAvailable, isFrozenToday, - hasCompletedToday, - canFreeze, streakFreezesAccumulated, maxStreakFreezesAccumulated, - daysUntilNextFreeze, freezesUsedThisMonth, maxFreezesPerMonth, - canEarnMore, - hasReachedMonthlyLimit, } = useStreakFreeze(profile) - const activateFreezeMutation = useActivateStreakFreeze() - const [showConfirm, setShowConfirm] = useState(false) - const [freezeSuccess, setFreezeSuccess] = useState(false) const freezeCelebrationRef = useRef(null) + const wasFrozenTodayRef = useRef(isFrozenToday) + + useEffect(() => { + if (isFrozenToday && !wasFrozenTodayRef.current) { + freezeCelebrationRef.current?.show() + } + wasFrozenTodayRef.current = isFrozenToday + }, [isFrozenToday]) const encouragement = useMemo(() => { if (streak >= 365) return t('streakDisplay.profile.encouragement365') @@ -93,18 +90,6 @@ export default function StreakPage() { }) }, [streakInfo, streak, isFrozenToday, displayDate]) - async function handleFreeze() { - setShowConfirm(false) - try { - await activateFreezeMutation.mutateAsync() - setFreezeSuccess(true) - setTimeout(() => setFreezeSuccess(false), 3000) - freezeCelebrationRef.current?.show() - } catch { - // Error surfaced via mutation.error - } - } - const isLoading = streakQuery.isLoading && !streakInfo return ( @@ -195,90 +180,20 @@ export default function StreakPage() { setShowConfirm(true)} + displayDate={displayDate} /> )} - -
-

- {plural( - t('streakDisplay.freeze.confirmBody', { - count: freezesAvailable, - remaining: freezesAvailable, - streak, - }), - freezesAvailable, - )} -

-
- - -
-
-
- ) diff --git a/apps/web/app/(app)/streak/streak.css b/apps/web/app/(app)/streak/streak.css index 01a5e7356..09a9e2090 100644 --- a/apps/web/app/(app)/streak/streak.css +++ b/apps/web/app/(app)/streak/streak.css @@ -14,3 +14,24 @@ .streak-hero--normal { /* No additional styling at this time; reserved hooks. */ } + +.streak-pip { + animation: streak-pip-in 180ms cubic-bezier(0.25, 1, 0.5, 1) both; +} + +@keyframes streak-pip-in { + from { + opacity: 0; + transform: scale(0.6); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .streak-pip { + animation: none; + } +} diff --git a/apps/web/app/actions/gamification.ts b/apps/web/app/actions/gamification.ts deleted file mode 100644 index 77f03bcaf..000000000 --- a/apps/web/app/actions/gamification.ts +++ /dev/null @@ -1,9 +0,0 @@ -'use server' - -import { API } from '@orbit/shared/api' -import type { StreakFreezeResponse } from '@orbit/shared/types/gamification' -import { serverAuthFetch } from '@/lib/server-fetch' - -export async function activateStreakFreeze(): Promise { - return serverAuthFetch(API.gamification.streakFreeze, { method: 'POST' }) -} diff --git a/apps/web/hooks/use-gamification.ts b/apps/web/hooks/use-gamification.ts index c36a1cdd4..e23fe84b9 100644 --- a/apps/web/hooks/use-gamification.ts +++ b/apps/web/hooks/use-gamification.ts @@ -3,15 +3,13 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useQuery, - useMutation, useQueryClient, } from '@tanstack/react-query' -import { gamificationKeys, profileKeys, QUERY_STALE_TIMES } from '@orbit/shared/query' +import { gamificationKeys, QUERY_STALE_TIMES } from '@orbit/shared/query' import { API } from '@orbit/shared/api' import type { GamificationProfile, StreakInfo, - StreakFreezeResponse, } from '@orbit/shared/types/gamification' import { deriveGamificationProfileState, @@ -19,7 +17,6 @@ import { deriveStreakFreezeState, } from '@orbit/shared/utils' import { fetchJson } from '@/lib/api-fetch' -import { activateStreakFreeze as activateStreakFreezeAction } from '@/app/actions/gamification' export function useGamificationProfile(enabled = true) { const queryClient = useQueryClient() @@ -102,38 +99,6 @@ export function useStreakInfo() { }) } -export function useActivateStreakFreeze() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (): Promise => activateStreakFreezeAction(), - - onSuccess: (data) => { - queryClient.setQueryData(gamificationKeys.streak(), (old) => { - if (!old) return old - const nextAccumulated = Math.max(0, data.streakFreezesAccumulated ?? Math.max(0, old.streakFreezesAccumulated - 1)) - const nextUsedThisMonth = old.maxFreezesPerMonth - data.freezesRemainingThisMonth - return { - ...old, - isFrozenToday: true, - freezesAvailable: data.freezesRemainingThisMonth, - freezesUsedThisMonth: nextUsedThisMonth, - currentStreak: data.currentStreak, - recentFreezeDates: [...old.recentFreezeDates, data.frozenDate], - streakFreezesAccumulated: nextAccumulated, - freezesAvailableToUse: Math.min(nextAccumulated, Math.max(0, old.maxFreezesPerMonth - nextUsedThisMonth)), - canEarnMore: nextAccumulated < old.maxStreakFreezesAccumulated, - } - }) - }, - - onSettled: () => { - queryClient.invalidateQueries({ queryKey: gamificationKeys.streak() }) - queryClient.invalidateQueries({ queryKey: profileKeys.all }) - }, - }) -} - export function useStreakFreeze(profile?: { streakFreezesAvailable?: number; currentStreak?: number } | null) { const streakQuery = useStreakInfo() const streakInfo = streakQuery.data ?? null diff --git a/packages/shared/src/__tests__/endpoints.test.ts b/packages/shared/src/__tests__/endpoints.test.ts index 3fe29dad2..38224abd3 100644 --- a/packages/shared/src/__tests__/endpoints.test.ts +++ b/packages/shared/src/__tests__/endpoints.test.ts @@ -130,7 +130,6 @@ describe('API endpoints', () => { expect(API.gamification.profile).toBe('/api/gamification/profile') expect(API.gamification.achievements).toBe('/api/gamification/achievements') expect(API.gamification.streak).toBe('/api/gamification/streak') - expect(API.gamification.streakFreeze).toBe('/api/gamification/streak/freeze') }) }) diff --git a/packages/shared/src/api/endpoints.ts b/packages/shared/src/api/endpoints.ts index 497fa0ea0..4ac517390 100644 --- a/packages/shared/src/api/endpoints.ts +++ b/packages/shared/src/api/endpoints.ts @@ -97,7 +97,6 @@ export const API = { profile: '/api/gamification/profile', achievements: '/api/gamification/achievements', streak: '/api/gamification/streak', - streakFreeze: '/api/gamification/streak/freeze', }, chat: { diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index d9bf6fe52..41d1122bf 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -1662,8 +1662,7 @@ "conflict": "This action conflicts with existing data", "tooManyRequests": "Too many requests. Please wait a moment.", "server": "Something went wrong. Please try again later.", - "unknown": "An unexpected error occurred", - "activateFreeze": "Failed to activate streak freeze" + "unknown": "An unexpected error occurred" } }, "streakDisplay": { @@ -1688,27 +1687,35 @@ "encouragement365": "A whole year! You are unstoppable!" }, "freeze": { - "title": "Streak Freeze", - "available": "No freezes available | {count} freeze available | {count} freezes available", - "activate": "Activate Freeze", - "confirmTitle": "Take a rest day?", - "confirmBody": "No worries! Use a streak freeze to keep your {streak}-day streak safe. You have {remaining} freeze left this month -- use it whenever you need! | No worries! Use a streak freeze to keep your {streak}-day streak safe. You have {remaining} freezes left this month -- use them whenever you need!", + "title": "Streak freeze", "activeToday": "Freeze active today", - "success": "Streak freeze activated!", - "recentLabel": "Recently used", - "completedToday": "You already completed a habit today. No freeze needed!", "celebrationTitle": "Streak saved!", "celebrationSubtitle": "Your streak holds.", "eyebrow": "Frozen · {date}", - "progressSubtitle": "1 more day of activity unlocks your next freeze | {days} more days of activity unlock your next freeze", - "progressReady": "Your next freeze is ready!", - "maxAccumulated": "You've hit the maximum of {max} freezes. Use one before earning more.", - "monthlyUsage": "{used} of {max} used this month", - "monthlyUsageLabel": "This month", - "monthlyLimit": "You've used all {max} freezes this month", - "accumulatedLabel": "Freezes banked", - "accumulatedShort": "{count}/{max}", - "noFreezesAvailable": "Keep going to earn your first freeze" + "auto": { + "chip": "AUTO", + "explainer": "Freezes activate automatically. Miss a day and a banked freeze keeps your streak alive. Nothing to tap." + }, + "banked": { + "label": "Banked" + }, + "usedThisMonth": { + "label": "Used this month" + }, + "nextFreeze": { + "label": "Next freeze", + "inDays": "in {days} days", + "full": "Banked full" + }, + "protected": { + "label": "Protected days", + "empty": "No freezes used yet.", + "today": "Today", + "todayValue": "Protected" + }, + "pro": { + "gate": "Streak freezes are part of Pro." + } }, "detail": { "title": "Streak Details", diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index e33e14e05..10f3de106 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -1662,8 +1662,7 @@ "conflict": "Esta ação conflita com dados existentes", "tooManyRequests": "Muitas requisições. Aguarde um momento.", "server": "Algo deu errado. Tente novamente mais tarde.", - "unknown": "Ocorreu um erro inesperado", - "activateFreeze": "Falha ao ativar o congelamento da sequência" + "unknown": "Ocorreu um erro inesperado" } }, "streakDisplay": { @@ -1688,27 +1687,35 @@ "encouragement365": "Um ano inteiro! Você é imparável!" }, "freeze": { - "title": "Congelamento de Sequência", - "available": "Nenhum congelamento disponível | {count} congelamento disponível | {count} congelamentos disponíveis", - "activate": "Ativar Congelamento", - "confirmTitle": "Tirar um dia de descanso?", - "confirmBody": "Sem estresse! Use um congelamento para manter sua sequência de {streak} dias segura. Você tem {remaining} congelamento disponível este mês -- use quando precisar! | Sem estresse! Use um congelamento para manter sua sequência de {streak} dias segura. Você tem {remaining} congelamentos disponíveis este mês -- use quando precisar!", + "title": "Congelamento de sequência", "activeToday": "Congelamento ativo hoje", - "success": "Congelamento de sequência ativado!", - "recentLabel": "Usados recentemente", - "completedToday": "Você já completou um hábito hoje. Não precisa congelar!", "celebrationTitle": "Sequência salva!", "celebrationSubtitle": "Sua sequência continua.", "eyebrow": "Congelado · {date}", - "progressSubtitle": "Mais 1 dia de atividade libera seu próximo congelamento | Mais {days} dias de atividade liberam seu próximo congelamento", - "progressReady": "Seu próximo congelamento está pronto!", - "maxAccumulated": "Você atingiu o máximo de {max} congelamentos. Use um antes de ganhar mais.", - "monthlyUsage": "{used} de {max} usados este mês", - "monthlyUsageLabel": "Este mês", - "monthlyLimit": "Você já usou os {max} congelamentos deste mês", - "accumulatedLabel": "Congelamentos guardados", - "accumulatedShort": "{count}/{max}", - "noFreezesAvailable": "Continue firme para ganhar seu primeiro congelamento" + "auto": { + "chip": "AUTO", + "explainer": "Os congelamentos ativam automaticamente. Se você perder um dia, um congelamento guardado mantém sua sequência viva. Nada para tocar." + }, + "banked": { + "label": "Guardados" + }, + "usedThisMonth": { + "label": "Usados este mês" + }, + "nextFreeze": { + "label": "Próximo congelamento", + "inDays": "em {days} dias", + "full": "Limite atingido" + }, + "protected": { + "label": "Dias protegidos", + "empty": "Nenhum congelamento usado ainda.", + "today": "Hoje", + "todayValue": "Protegido" + }, + "pro": { + "gate": "Os congelamentos de sequência fazem parte do Pro." + } }, "detail": { "title": "Detalhes da Sequência", From 2fb26e44fb64bfdf7207f7c0ecc1a977381412e1 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 16:41:02 -0300 Subject: [PATCH 3/5] chore(shared): remove dead StreakFreezeResponse type (#108) The manual streak-freeze activation hook and endpoint were removed in this PR, leaving streakFreezeResponseSchema and StreakFreezeResponse with zero references. Deletes both per the delete-unused-code standard. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/types/gamification.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/shared/src/types/gamification.ts b/packages/shared/src/types/gamification.ts index 311fe25d3..d3f352e22 100644 --- a/packages/shared/src/types/gamification.ts +++ b/packages/shared/src/types/gamification.ts @@ -77,12 +77,3 @@ export const streakInfoSchema = z.object({ }) export type StreakInfo = z.infer - -export const streakFreezeResponseSchema = z.object({ - freezesRemainingThisMonth: z.number(), - frozenDate: z.string(), - currentStreak: z.number(), - streakFreezesAccumulated: z.number().default(0), -}) - -export type StreakFreezeResponse = z.infer From e85db0cf545da9faf156ac119209b67bed14f3dd Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 19:03:06 -0300 Subject: [PATCH 4/5] fix(streak): remove cryptic AUTO chip, tighten auto-freeze explainer The floating AUTO eyebrow chip had no referent and read as a technical flag instead of communication. Removed it from the web and mobile streak freeze sections and deleted the now-dead auto.chip i18n key. Rewrote the explainer to a single sentence carrying the automatic behavior, dropping the confusing "Nothing to tap" line. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/__tests__/lib/i18n.test.ts | 1 - apps/mobile/app/streak.tsx | 18 +----------------- apps/web/__tests__/pages/streak.test.tsx | 3 +-- .../streak/_components/streak-sections.tsx | 10 +--------- packages/shared/src/i18n/en.json | 3 +-- packages/shared/src/i18n/pt-BR.json | 3 +-- 6 files changed, 5 insertions(+), 33 deletions(-) diff --git a/apps/mobile/__tests__/lib/i18n.test.ts b/apps/mobile/__tests__/lib/i18n.test.ts index 87f30ac89..825b4b921 100644 --- a/apps/mobile/__tests__/lib/i18n.test.ts +++ b/apps/mobile/__tests__/lib/i18n.test.ts @@ -31,7 +31,6 @@ describe('mobile i18n interpolation', () => { expect(plural(i18n.t('streakDisplay.detail.daysUnit', { count: 1 }), 1)).toBe('day') expect(plural(i18n.t('streakDisplay.detail.daysUnit', { count: 4 }), 4)).toBe('days') expect(i18n.t('streakDisplay.freeze.nextFreeze.inDays', { days: 3 })).toBe('in 3 days') - expect(i18n.t('streakDisplay.freeze.auto.chip')).toBe('AUTO') expect(plural(i18n.t('habits.frequency.everyNWeeks', { n: 2 }), 2)).toBe('Every 2 weeks') expect(plural(i18n.t('habits.breakdown.createdSuccess', { n: 2 }), 2)).toBe('Created 2 habits successfully') }) diff --git a/apps/mobile/app/streak.tsx b/apps/mobile/app/streak.tsx index 58a40a8b3..43d946f36 100644 --- a/apps/mobile/app/streak.tsx +++ b/apps/mobile/app/streak.tsx @@ -236,17 +236,7 @@ export default function StreakScreen() {
- - {t('streakDisplay.freeze.auto.chip')} - - } - > - {t('streakDisplay.freeze.title')} - + {t('streakDisplay.freeze.title')} { // ---- Auto-freeze section ---- - it('renders freeze title with the AUTO eyebrow chip', () => { + it('renders the freeze section title', () => { render() expect(screen.getByText('streakDisplay.freeze.title')).toBeInTheDocument() - expect(screen.getByText('streakDisplay.freeze.auto.chip')).toBeInTheDocument() }) it('renders the auto explainer copy', () => { diff --git a/apps/web/app/(app)/streak/_components/streak-sections.tsx b/apps/web/app/(app)/streak/_components/streak-sections.tsx index f959fe3e9..6b63a9711 100644 --- a/apps/web/app/(app)/streak/_components/streak-sections.tsx +++ b/apps/web/app/(app)/streak/_components/streak-sections.tsx @@ -201,15 +201,7 @@ export function FreezeProgressCard(props: Readonly) { - - {t('streakDisplay.freeze.auto.chip')} - - } - > - {t('streakDisplay.freeze.title')} - + {t('streakDisplay.freeze.title')} {isPro ? ( <> diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 41d1122bf..47fc80c6f 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -1693,8 +1693,7 @@ "celebrationSubtitle": "Your streak holds.", "eyebrow": "Frozen · {date}", "auto": { - "chip": "AUTO", - "explainer": "Freezes activate automatically. Miss a day and a banked freeze keeps your streak alive. Nothing to tap." + "explainer": "If you miss a day, a banked freeze is used automatically to keep your streak alive." }, "banked": { "label": "Banked" diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index 10f3de106..ec0de493e 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -1693,8 +1693,7 @@ "celebrationSubtitle": "Sua sequência continua.", "eyebrow": "Congelado · {date}", "auto": { - "chip": "AUTO", - "explainer": "Os congelamentos ativam automaticamente. Se você perder um dia, um congelamento guardado mantém sua sequência viva. Nada para tocar." + "explainer": "Se você perder um dia, um congelamento guardado é usado automaticamente para manter sua sequência viva." }, "banked": { "label": "Guardados" From eb4996a901aff90f444cd495a0d9328b0535e270 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 20:23:11 -0300 Subject: [PATCH 5/5] feat(streak): unify tier display across web + mobile Web rendered the streak tier only as an invisible hero-tint CSS class (overridden by inline styles), while mobile showed it as an explicit Stats row -- and the two threshold ladders had drifted apart. Add a shared getStreakTierLabelKey as the single source of truth, render the tier as an explicit Stats row on both platforms, and remove the dead web tint code. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/app/streak.tsx | 9 ++------- apps/web/__tests__/pages/streak.test.tsx | 14 +++++++------- .../(app)/streak/_components/streak-sections.tsx | 10 ++++++++++ apps/web/app/(app)/streak/page.tsx | 9 +-------- apps/web/app/(app)/streak/streak.css | 10 +--------- .../src/__tests__/gamification-selectors.test.ts | 14 ++++++++++++++ .../shared/src/utils/gamification-selectors.ts | 11 +++++++++++ packages/shared/src/utils/index.ts | 1 + 8 files changed, 47 insertions(+), 31 deletions(-) diff --git a/apps/mobile/app/streak.tsx b/apps/mobile/app/streak.tsx index 43d946f36..493f0570e 100644 --- a/apps/mobile/app/streak.tsx +++ b/apps/mobile/app/streak.tsx @@ -9,6 +9,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' import { useTranslation } from 'react-i18next' import { subDays, isToday, format, parseISO } from 'date-fns' +import { getStreakTierLabelKey } from '@orbit/shared/utils' import { createTokensV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' import { useProfile } from '@/hooks/use-profile' @@ -116,13 +117,7 @@ export default function StreakScreen() { ? t('streakDisplay.freeze.activeToday') : t('streakDisplay.detail.currentStreak') - const tier = useMemo(() => { - if (streak >= 365) return t('streakDisplay.profile.encouragement365') - if (streak >= 100) return t('streakDisplay.detail.tierLegendary') - if (streak >= 30) return t('streakDisplay.detail.tierStrong') - if (streak >= 7) return t('streakDisplay.detail.tierSteady') - return t('streakDisplay.detail.tierNormal') - }, [streak, t]) + const tier = t(getStreakTierLabelKey(streak)) return ( diff --git a/apps/web/__tests__/pages/streak.test.tsx b/apps/web/__tests__/pages/streak.test.tsx index 938c1d2be..0d3626140 100644 --- a/apps/web/__tests__/pages/streak.test.tsx +++ b/apps/web/__tests__/pages/streak.test.tsx @@ -256,17 +256,17 @@ describe('StreakPage', () => { expect(upgradeLink).toHaveAttribute('href', '/upgrade') }) - // ---- Tier styling ---- + // ---- Tier ---- - it('applies strong tier class for streak >= 7', () => { + it('shows the steady tier in stats for streak >= 7', () => { mockProfile = { currentStreak: 10, hasProAccess: true } - const { container } = render() - expect(container.querySelector('.streak-hero--strong')).toBeInTheDocument() + render() + expect(screen.getByText('streakDisplay.detail.tierSteady')).toBeInTheDocument() }) - it('applies legendary tier class for streak >= 100', () => { + it('shows the legendary tier in stats for streak >= 100', () => { mockProfile = { currentStreak: 150, hasProAccess: true } - const { container } = render() - expect(container.querySelector('.streak-hero--legendary')).toBeInTheDocument() + render() + expect(screen.getByText('streakDisplay.detail.tierLegendary')).toBeInTheDocument() }) }) diff --git a/apps/web/app/(app)/streak/_components/streak-sections.tsx b/apps/web/app/(app)/streak/_components/streak-sections.tsx index 6b63a9711..fb3942449 100644 --- a/apps/web/app/(app)/streak/_components/streak-sections.tsx +++ b/apps/web/app/(app)/streak/_components/streak-sections.tsx @@ -2,6 +2,7 @@ import { SectionLabel } from '@/components/ui/section-label' import { SettingsGroup, SettingsGroupRow } from '@/components/ui/settings-group' +import { getStreakTierLabelKey } from '@orbit/shared/utils' type StreakDayView = { dateStr: string @@ -198,6 +199,15 @@ export function FreezeProgressCard(props: Readonly) { label={t('streakDisplay.detail.longestStreak')} trailing={} /> + + } + /> diff --git a/apps/web/app/(app)/streak/page.tsx b/apps/web/app/(app)/streak/page.tsx index 996cc0c37..790bb03b9 100644 --- a/apps/web/app/(app)/streak/page.tsx +++ b/apps/web/app/(app)/streak/page.tsx @@ -50,13 +50,6 @@ export default function StreakPage() { return '' }, [streak, t]) - const tier = useMemo(() => { - if (streak >= 100) return 'legendary' - if (streak >= 30) return 'intense' - if (streak >= 7) return 'strong' - return 'normal' - }, [streak]) - const weekDays = useMemo(() => { const today = new Date() const freezeDates = new Set(streakInfo?.recentFreezeDates ?? []) @@ -110,7 +103,7 @@ export default function StreakPage() { ) : (
{ + it('maps streak length to the tier i18n key at each threshold', () => { + expect(getStreakTierLabelKey(0)).toBe('streakDisplay.detail.tierNormal') + expect(getStreakTierLabelKey(6)).toBe('streakDisplay.detail.tierNormal') + expect(getStreakTierLabelKey(7)).toBe('streakDisplay.detail.tierSteady') + expect(getStreakTierLabelKey(29)).toBe('streakDisplay.detail.tierSteady') + expect(getStreakTierLabelKey(30)).toBe('streakDisplay.detail.tierStrong') + expect(getStreakTierLabelKey(99)).toBe('streakDisplay.detail.tierStrong') + expect(getStreakTierLabelKey(100)).toBe('streakDisplay.detail.tierLegendary') + expect(getStreakTierLabelKey(365)).toBe('streakDisplay.detail.tierLegendary') + }) +}) + function makeProfile(overrides: Partial = {}): GamificationProfile { return { totalXp: 500, diff --git a/packages/shared/src/utils/gamification-selectors.ts b/packages/shared/src/utils/gamification-selectors.ts index 06e273509..9b2e72180 100644 --- a/packages/shared/src/utils/gamification-selectors.ts +++ b/packages/shared/src/utils/gamification-selectors.ts @@ -30,6 +30,17 @@ export interface StreakFreezeDerivedState { hasReachedMonthlyLimit: boolean } +/** + * Streak tier i18n key by current streak length: steady at 7 days, strong at + * 30, legendary at 100. Single source of truth so web and mobile stay aligned. + */ +export function getStreakTierLabelKey(currentStreak: number): string { + if (currentStreak >= 100) return 'streakDisplay.detail.tierLegendary' + if (currentStreak >= 30) return 'streakDisplay.detail.tierStrong' + if (currentStreak >= 7) return 'streakDisplay.detail.tierSteady' + return 'streakDisplay.detail.tierNormal' +} + export interface GamificationProfileDerivedState { xpProgress: number earnedAchievements: Achievement[] diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index b00a39b5c..5dd3d6362 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -242,6 +242,7 @@ export { getAchievementsByCategory, getEarnedAchievements, getLockedAchievements, + getStreakTierLabelKey, } from './gamification-selectors' export type { GamificationMilestoneState,