Skip to content

Streaks

Overview

The streak system tracks patient workout consistency with two metrics: daily streaks (workout days in the current active streak) and weekly streaks (weeks meeting a 45-minute target). Users are allowed up to 3 rest days per calendar week (Mon–Sun) without breaking their streak. A 4th missed day in a week breaks the streak. The mobile and web dashboards display these alongside a 7-day workout calendar showing which days of the current week had completed workouts, and how many rest days remain.

Key Concepts

Concept Description
Daily Streak Count of workout days in the current active streak, allowing up to 3 rest days per week. Only increments on days when a workout is completed — rest days don't increment it, they just don't break it.
Weekly Streak Count of weeks where total workout minutes reached the 45-minute target
Rest Days Days within an active streak week where no workout was done. Up to 3 per week are allowed without breaking the streak.
Days Off Per Week Constant: 3. Returned by the API as daysOffPerWeek so frontends don't hardcode it.
Weekly Workout Days Array of JS day-of-week numbers (0=Sun, 6=Sat) tracking which days this week had workouts
Weekly Minutes Accumulated workout minutes for the current Monday–Sunday week
Week Boundary Monday-based weeks, computed timezone-aware via getStartOfWeekInTimezone
Week Streak Active Since Day of week (0=Sun..6=Sat) from which rest days count this week. null means no active streak. Used for mid-week streak starts (e.g., starting on Wednesday means Mon/Tue are ignored).
Lazy Break Timing A streak break is enforced on the user's next interaction after the 4th miss occurs. Days off are only counted through yesterday — today still has the chance of a workout.

Data Model

Prisma schema (OktaPT-API/prisma/schema.prisma):

model UserStreak {
  id                    Int       @id @default(autoincrement())
  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId                Int       @unique
  currentDailyStreak    Int       @default(0)
  currentWeeklyStreak   Int       @default(0)
  lastWorkoutDate       DateTime?
  weeklyMinutes         Int       @default(0)
  weeklyWorkoutDays     Int[]     // Days of week (0-6) when workouts were done this week
  weekStreakActiveSince Int?      // Day of week (0=Sun..6=Sat) from which rest days count this week
  weekTimezone          String?   // Timezone used when weekly data was last set/reset
  updatedAt             DateTime  @updatedAt
  tenantId              Int       @default(1)
  tenant                Tenant    @relation(fields: [tenantId], references: [id])
}

New fields added for the days-off feature: - weekStreakActiveSince: null = no active streak. 1 (Monday) = streak active from start of week. Other values = streak started mid-week on that day. - weekTimezone: Anchors the week boundary to a specific timezone, preventing drift if a user's timezone changes mid-week. Set whenever weekly data is reset or weekStreakActiveSince changes.

TypeScript interface (OktaPT-API/src/routes/streaks/types.ts, OktaPT-FE/lib/types/streakData.ts, Okta-Mobile/services/api.ts):

interface StreakData {
  id: number;
  userId: number;
  currentDailyStreak: number;
  currentWeeklyStreak: number;
  lastWorkoutDate: Date | null;
  weeklyMinutes: number;
  weeklyWorkoutDays: number[];
  updatedAt: Date;
  weekStreakActiveSince: number | null;  // stored in DB
  weeklyDaysOffUsed: number;            // computed per request
  weeklyDaysOffRemaining: number;       // computed per request
  daysOffPerWeek: number;               // constant (3)
}

One UserStreak per user (enforced by userId @unique). Cascade-deletes when the user is deleted.

API Endpoints

Endpoint Method Auth Purpose
/v2/streaks GET Required Fetch current streak data (with auto-reset logic)
/v2/workouts/:id/complete POST Required Complete a workout (triggers streak update internally)
/v2/user/auto-update-timezone POST Required Sync device timezone to UserInfo.userLastLocalTimeZone

GET /v2/streaks — getStreaks

File: OktaPT-API/src/routes/streaks/controllers.ts

Called by the mobile and web dashboards on load. Performs lazy evaluation before returning data:

  1. Upsert the UserStreak record (creates one with zeros if none exists)
  2. Evaluate streak status via evaluateStreakStatus(streak, now, timezone):
  3. If 2+ weeks have passed since the last workout → break streak (a full week with 0 workouts exceeds the 3-day limit)
  4. If exactly 1 week boundary crossed → check previous week's days off using countDaysOffInRange. If >3 → break streak. If <=3 → streak survives, reset weekly data for new week.
  5. If same week → count days off from weekStreakActiveSince through yesterday. If >3 → break streak.
  6. Apply updates: Break streak (reset to 0), reset weekly data, update weekStreakActiveSince and weekTimezone as needed
  7. Return StreakData with computed weeklyDaysOffUsed, weeklyDaysOffRemaining, and daysOffPerWeek

Workout completion — updateStreaksOnWorkoutComplete

File: OktaPT-API/src/routes/streaks/controllers.ts

Called from OktaPT-API/src/routes/legacy.ts after a workout is marked complete. Signature:

updateStreaksOnWorkoutComplete(userId: number, workoutDuration: number, userTimezone: string)

workoutDuration is computed as (timeWorkoutEnd - timeWorkoutStart) in minutes.

Logic:

  1. If no UserStreak exists, create one with currentDailyStreak: 1, the workout day added to weeklyWorkoutDays, weeklyMinutes set to the duration, weekStreakActiveSince set to the current day-of-week, and weekTimezone set to the user's timezone. If duration alone meets the 45-min target, currentWeeklyStreak starts at 1.

  2. For existing streaks:

  3. Evaluate first: Call evaluateStreakStatus to handle any pending week boundary or days-off break (can't rely on getStreaks being called first).
  4. Day-of-week: Compute using getUserLocalDayOfWeek(now, timezone). Add to weeklyWorkoutDays if not already present.
  5. Weekly minutes: Add workoutDuration to weeklyMinutes.
  6. Weekly streak: Increment currentWeeklyStreak by 1 only when this workout causes weeklyMinutes to cross from below 45 to at or above 45 (one-time threshold crossing per week).
  7. Daily streak:
    • Same day as last workout (timezone-aware): no change
    • Different day, streak was broken by evaluation: reset to 1, set weekStreakActiveSince to current day
    • Different day, streak survived: increment by 1
  8. If weekStreakActiveSince is null after evaluation, set it to the current day (new streak starting).
  9. Last workout date: Always updated to new Date() (stored as UTC).

Constants: WEEKLY_MINUTES_TARGET = 45, MAX_DAYS_OFF_PER_WEEK = 3

Shared evaluation — evaluateStreakStatus

File: OktaPT-API/src/routes/streaks/controllers.ts

Pure evaluation function used by both getStreaks and updateStreaksOnWorkoutComplete:

function evaluateStreakStatus(
  streak: UserStreak, now: Date, timezone: string
): {
  shouldBreakStreak: boolean;
  newWeekStreakActiveSince: number | null | undefined;
  shouldResetWeeklyData: boolean;
  daysOffUsed: number;
  daysOffRemaining: number;
}

Uses streak.weekTimezone (if set) to evaluate the previous week's data, ensuring consistent timezone anchoring even if the user's timezone changed mid-week.

Guards:

  1. Week transition guard: When weeksDiff === 1 but weeklyWorkoutDays is empty and weekStreakActiveSince is MONDAY_DOW, the week transition was already processed by a prior getStreaks call. The function skips re-evaluation to prevent double-processing that could incorrectly break streaks.

  2. Current-week overflow: During the weeksDiff === 1 path, after determining that the previous week's streak survives, the function also checks whether rest days in the current (new) week already exceed MAX_DAYS_OFF_PER_WEEK. If so, the streak is broken immediately rather than waiting for the next evaluation cycle.

Timezone Handling

All streak date logic is timezone-aware. Without this, a user completing a workout at 10 PM EST would have it recorded as the next UTC day, causing the wrong day dot to light up.

How user timezone is stored

Both the mobile app and web app auto-report the device timezone on dashboard mount:

Mobile (expo-localization) → POST /v2/user/auto-update-timezone → UserInfo.userLastLocalTimeZone
Web (Intl.DateTimeFormat)  → POST /v2/user/auto-update-timezone → UserInfo.userLastLocalTimeZone

Mobile trigger: Okta-Mobile/components/patient/PatientDashboard.tsx and Okta-Mobile/components/doctor/DoctorDashboard.tsx — both call autoUpdateUserTimezone() which reads Localization.getCalendars()[0].timeZone and POSTs it.

Web trigger: OktaPT-FE/pages/patient/dashboard.tsx — fires Intl.DateTimeFormat().resolvedOptions().timeZone on mount (fire-and-forget, doesn't block rendering).

Backend handler: OktaPT-API/src/routes/user/controllers.ts — upserts userLastLocalTimeZone on the UserInfo record.

Timezone utility functions

File: OktaPT-API/src/utils/timezone.ts

All functions use Date.toLocaleDateString("en-CA", { timeZone }) which returns "YYYY-MM-DD" in the specified timezone. Falls back to "America/New_York" on invalid timezone.

Function Purpose
getUserLocalToday(timezone) Returns user's local "today" as a Date at midnight UTC
getUserLocalDayOfWeek(date, timezone) Returns JS day-of-week (0–6) for a UTC Date in user's timezone
isSameDayInTimezone(a, b, timezone) True if two UTC timestamps fall on the same local calendar day
calendarDaysDiffInTimezone(a, b, timezone) Signed calendar-day difference (positive when a is after b)
getStartOfWeekInTimezone(date, timezone) Returns Monday of the week containing date in the user's timezone, as a Date at midnight UTC
countDaysOffInRange(activeSinceDow, throughDow, workoutDays) Counts days NOT present in workoutDays from activeSinceDow through throughDow (inclusive), iterating in Monday order [1,2,3,4,5,6,0]

Where timezone is used in streak logic

  • evaluateStreakStatus: Uses streak.weekTimezone for previous-week evaluation and the current timezone for current-week calculations.
  • getStreaks: Fetches userLastLocalTimeZone from UserInfo, passes it to evaluateStreakStatus.
  • updateStreaksOnWorkoutComplete: Receives timezone as a parameter, passes it to evaluateStreakStatus and uses it for day-of-week and same-day checks.
  • Default fallback: "America/New_York" (consistent across the codebase).

Frontend Rendering

Mobile Components

Both mobile components determine "today" via new Date().getDay() (device local time). They display a Monday-through-Sunday week and check streakData.weeklyWorkoutDays.includes(dayNumber) for each day. Both also show a "rest days remaining" indicator below the weekly calendar.

StreakHeaderWidget.tsx (Okta-Mobile/components/navigation/StreakHeaderWidget.tsx) - Displayed in the dashboard tab header - Shows: fire icon + daily streak count + 7 date circles (Mon–Sun) with day-of-month numbers + rest days remaining text - Uses getWeekDates() to compute Monday-based week from the current date - Day circle colors are computed by iterating in Monday order, counting rest days, and classifying each as allowed (blue) or over-limit (red)

StreakCounter.tsx (Okta-Mobile/components/StreakCounter.tsx) - Full-screen card with animations - Shows: daily streak (green circle) + weekly streak (purple circle) + 7-day dot row + weekly minutes progress bar (0–45) + rest days remaining text

Web Component

dashboard.tsx (OktaPT-FE/pages/patient/dashboard.tsx) - Patient dashboard streak card - Shows: daily streak counter + weekly streak counter + 7-day dots (Mon–Sun) + weekly minutes progress bar + rest days remaining text - Auto-syncs timezone on mount via POST /v2/user/auto-update-timezone

Day Dot Color Scheme

All components classify each past non-workout day by iterating in Monday order and counting rest days. The first 3 non-workout days (up to daysOffPerWeek) are "allowed rest days" (blue); any beyond that are "missed" (red).

Color Web (Tailwind) Mobile (Hex) Meaning
Green bg-green-500 #10B981 Workout completed that day
Yellow bg-yellow-500 #F59E0B Today, not yet completed
Blue bg-blue-300 #DBEAFE bg / #93C5FD fill Allowed rest day (within the 3/week limit)
Red bg-red-400 #FEE2E2 bg / #FCA5A5 fill Missed day (exceeded the 3/week rest limit)
Gray bg-gray-200 #E5E7EB Future day or day before streak started this week

Rest Days Remaining Indicator

All three frontends display the remaining rest days below the weekly calendar:

  • Mobile: "{count}/{total} rest days left" — translation key streak.restDaysLeft
  • Web: "{count}/{total} rest days remaining" — translation key patientDashboard.streakCard.restDaysLeft

Values come from the API response (weeklyDaysOffRemaining and daysOffPerWeek), not hardcoded.

Week Day Mapping

Frontend components map array index to day-of-week for Mon–Sun display:

// index 0-5 → Monday(1) through Saturday(6)
// index 6   → Sunday(0)
const adjustedDay = i === 6 ? 0 : i + 1;
const isDone = streakData.weeklyWorkoutDays.includes(adjustedDay);

Days before weekStreakActiveSince in Monday order are shown as gray (not counted toward the streak).

Data Flow

User taps "Complete Workout"
        |
        v
POST /v2/workouts/:id/complete
        |
        v
Mark workout as completed, calculate duration (minutes)
        |
        v
Fetch user timezone from UserInfo.userLastLocalTimeZone
        |
        v
updateStreaksOnWorkoutComplete(patientId, duration, timezone)
  - evaluateStreakStatus() — check week boundary + days off
  - Apply any streak break or weekly reset
  - Compute local day-of-week
  - Update weeklyWorkoutDays, weeklyMinutes
  - Update daily/weekly streak counts
  - Update weekStreakActiveSince if starting new streak
  - Save to UserStreak table
        |
        v
Mobile/Web dashboard fetches GET /v2/streaks
  - evaluateStreakStatus() — lazy check for pending breaks
  - Auto-resets stale streaks (days off exceeded, week boundary)
  - Returns StreakData with computed daysOffUsed/Remaining
        |
        v
UI renders streak counters + weekly day dots + rest days indicator

Key Files

File Role
OktaPT-API/prisma/schema.prisma UserStreak data model (includes weekStreakActiveSince, weekTimezone)
OktaPT-API/src/routes/streaks/controllers.ts Core streak logic: evaluateStreakStatus, getStreaks, updateStreaksOnWorkoutComplete
OktaPT-API/src/routes/streaks/types.ts StreakData TypeScript interface
OktaPT-API/src/routes/streaks/index.ts Route registration (GET /)
OktaPT-API/src/utils/timezone.ts Timezone utilities including getStartOfWeekInTimezone, countDaysOffInRange
OktaPT-API/src/routes/legacy.ts Workout completion endpoint that triggers streak update
OktaPT-API/src/routes/user/controllers.ts Auto-update-timezone endpoint
Okta-Mobile/components/navigation/StreakHeaderWidget.tsx Mobile header streak widget with rest days indicator
Okta-Mobile/components/StreakCounter.tsx Mobile full streak card with rest days indicator
Okta-Mobile/services/api.ts Mobile StreakData type definition
Okta-Mobile/components/patient/PatientDashboard.tsx Mobile timezone auto-sync trigger (patient)
Okta-Mobile/components/doctor/DoctorDashboard.tsx Mobile timezone auto-sync trigger (doctor)
OktaPT-FE/pages/patient/dashboard.tsx Web dashboard streak display + timezone sync on mount
OktaPT-FE/lib/types/streakData.ts Frontend StreakData type
OktaPT-API/scripts/recalculateStreaks.ts Admin script: replays full workout history for all users to recompute streak values. Dry-run by default; pass --apply to write. Run via npm run recalculate-streaks.