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:
- Upsert the
UserStreakrecord (creates one with zeros if none exists) - Evaluate streak status via
evaluateStreakStatus(streak, now, timezone): - If 2+ weeks have passed since the last workout → break streak (a full week with 0 workouts exceeds the 3-day limit)
- 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. - If same week → count days off from
weekStreakActiveSincethrough yesterday. If >3 → break streak. - Apply updates: Break streak (reset to 0), reset weekly data, update
weekStreakActiveSinceandweekTimezoneas needed - Return
StreakDatawith computedweeklyDaysOffUsed,weeklyDaysOffRemaining, anddaysOffPerWeek
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:
workoutDuration is computed as (timeWorkoutEnd - timeWorkoutStart) in minutes.
Logic:
-
If no
UserStreakexists, create one withcurrentDailyStreak: 1, the workout day added toweeklyWorkoutDays,weeklyMinutesset to the duration,weekStreakActiveSinceset to the current day-of-week, andweekTimezoneset to the user's timezone. If duration alone meets the 45-min target,currentWeeklyStreakstarts at 1. -
For existing streaks:
- Evaluate first: Call
evaluateStreakStatusto handle any pending week boundary or days-off break (can't rely ongetStreaksbeing called first). - Day-of-week: Compute using
getUserLocalDayOfWeek(now, timezone). Add toweeklyWorkoutDaysif not already present. - Weekly minutes: Add
workoutDurationtoweeklyMinutes. - Weekly streak: Increment
currentWeeklyStreakby 1 only when this workout causesweeklyMinutesto cross from below 45 to at or above 45 (one-time threshold crossing per week). - Daily streak:
- Same day as last workout (timezone-aware): no change
- Different day, streak was broken by evaluation: reset to 1, set
weekStreakActiveSinceto current day - Different day, streak survived: increment by 1
- If
weekStreakActiveSinceis null after evaluation, set it to the current day (new streak starting). - 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:
-
Week transition guard: When
weeksDiff === 1butweeklyWorkoutDaysis empty andweekStreakActiveSinceisMONDAY_DOW, the week transition was already processed by a priorgetStreakscall. The function skips re-evaluation to prevent double-processing that could incorrectly break streaks. -
Current-week overflow: During the
weeksDiff === 1path, after determining that the previous week's streak survives, the function also checks whether rest days in the current (new) week already exceedMAX_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: Usesstreak.weekTimezonefor previous-week evaluation and the current timezone for current-week calculations.getStreaks: FetchesuserLastLocalTimeZonefromUserInfo, passes it toevaluateStreakStatus.updateStreaksOnWorkoutComplete: Receives timezone as a parameter, passes it toevaluateStreakStatusand 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 keystreak.restDaysLeft - Web:
"{count}/{total} rest days remaining"— translation keypatientDashboard.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. |