
Streak Tracker
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates a streak tracking system with timezone-aware day boundaries, streak freeze protection, and streak-at-risk push notifications.
About
Generates a streak tracking system that records consecutive activity days, computes current and longest streaks, handles timezone-aware boundaries, supports streak freezes, and schedules streak-at-risk notifications. A developer uses it to add daily/weekly engagement or habit streaks.
- Timezone-aware day boundaries and streak freeze passes
- Streak-at-risk local notifications
Streak Tracker by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill streak-trackerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates a streak tracking system with timezone-aware day boundaries, streak freeze protection, and streak-at-risk push notifications.
Files
Streak Tracker Generator
Generate a production streak tracking system that records consecutive days of user activity, calculates current and longest streaks, handles timezone-aware day boundaries, supports streak freeze/protection passes, and schedules streak-at-risk local notifications.
When This Skill Activates
Use this skill when the user:
- Asks to "add streaks" or "daily streak" tracking
- Wants "streak tracking" or "consecutive days" counting
- Mentions "engagement streaks" or "habit tracking"
- Asks about "streak freeze" or "streak protection"
- Wants "streak-at-risk" notifications or reminders
- Mentions "login streak" or "activity streak"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 17+ / macOS 14+ for @Observable and SwiftData)
- [ ] Check for SwiftData availability and existing model container setup
- [ ] Identify source file locations
2. Conflict Detection
Search for existing streak or habit tracking:
Glob: **/*Streak*.swift, **/*Habit*.swift, **/*DailyTrack*.swift
Grep: "streak" or "consecutiveDays" or "habitTrack" or "dailyStreak"If existing streak/habit system found:
- Ask if user wants to replace or extend it
- If extending, generate only the missing components
3. Platform Detection
Determine if generating for iOS (UNUserNotificationCenter) or macOS (UNUserNotificationCenter available on macOS 11+) or both.
Configuration Questions
Ask user via AskUserQuestion:
1. Streak type?
- Daily (consecutive calendar days) -- recommended
- Weekly (at least one activity per calendar week)
- Custom interval (every N hours/days)
2. Storage backend?
- SwiftData (recommended for iOS 17+ / macOS 14+)
- UserDefaults (lightweight, no model container needed)
3. Include streak freeze/protection?
- Yes — users get limited freeze passes to preserve streaks on missed days
- No — strict consecutive tracking only
4. Streak-at-risk notifications?
- Yes — schedule a local notification (e.g., 8 PM) if no activity recorded today
- No — no notifications
5. Additional features? (multi-select)
- Calendar heat map view (visual grid of activity days)
- Streak badge view (compact count with animation)
- Milestone celebrations (7-day, 30-day, 100-day, etc.)
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. StreakRecord.swift — SwiftData @Model for activity records 2. StreakManager.swift — @Observable class: record activity, calculate streaks, manage freezes 3. StreakError.swift — Error types for streak operations
Step 3: Create UI Files
4. StreakCalendarView.swift — Grid showing days with/without activity 5. StreakBadgeView.swift — Compact badge with streak count and animation
Step 4: Create Optional Files
Based on configuration:
StreakFreeze.swift— If streak freeze selectedStreakNotificationScheduler.swift— If notifications selected
Step 5: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/StreakTracking/ - If
App/exists ->App/StreakTracking/ - Otherwise ->
StreakTracking/
Output Format
After generation, provide:
Files Created
StreakTracking/
├── StreakRecord.swift # SwiftData model for activity records
├── StreakManager.swift # Core streak calculation engine
├── StreakError.swift # Error types
├── StreakCalendarView.swift # Calendar heat map view
├── StreakBadgeView.swift # Compact animated badge
├── StreakFreeze.swift # Freeze/protection passes (optional)
└── StreakNotificationScheduler.swift # Streak-at-risk reminders (optional)Integration Steps
Set up the model container (SwiftData):
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [StreakRecord.self, StreakFreeze.self])
}
}Record activity on user action:
struct LessonCompleteView: View {
@Environment(\.modelContext) private var modelContext
@State private var streakManager: StreakManager?
var body: some View {
Button("Complete Lesson") {
Task {
try await streakManager?.recordActivity(type: "lesson")
}
}
.onAppear {
streakManager = StreakManager(modelContext: modelContext)
}
}
}Display the current streak:
struct ProfileView: View {
@Environment(\.modelContext) private var modelContext
@State private var streakManager: StreakManager?
var body: some View {
VStack {
if let manager = streakManager {
StreakBadgeView(streak: manager.currentStreak)
Text("Longest: \(manager.longestStreak) days")
.foregroundStyle(.secondary)
}
}
.onAppear {
streakManager = StreakManager(modelContext: modelContext)
Task { await streakManager?.refresh() }
}
}
}Show the calendar heat map:
struct StatsView: View {
@Environment(\.modelContext) private var modelContext
@State private var streakManager: StreakManager?
var body: some View {
if let manager = streakManager {
StreakCalendarView(manager: manager)
}
}
}Use a streak freeze:
Button("Use Streak Freeze") {
if streakManager.useStreakFreeze() {
// Freeze applied — streak preserved
} else {
// No freezes available
}
}Testing
import Testing
import SwiftData
@Test
func recordingActivityIncrementsStreak() async throws {
let container = try ModelContainer(
for: StreakRecord.self, StreakFreeze.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = ModelContext(container)
let manager = StreakManager(modelContext: context)
// Record activity for today
try await manager.recordActivity(type: "workout")
await manager.refresh()
#expect(manager.currentStreak == 1)
}
@Test
func consecutiveDaysBuildStreak() async throws {
let container = try ModelContainer(
for: StreakRecord.self, StreakFreeze.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = ModelContext(container)
let manager = StreakManager(modelContext: context)
// Simulate 3 consecutive days
let calendar = Calendar.current
for daysAgo in (0...2).reversed() {
let date = calendar.date(byAdding: .day, value: -daysAgo, to: .now)!
try await manager.recordActivity(type: "workout", date: date)
}
await manager.refresh()
#expect(manager.currentStreak == 3)
#expect(manager.longestStreak == 3)
}
@Test
func missedDayBreaksStreak() async throws {
let container = try ModelContainer(
for: StreakRecord.self, StreakFreeze.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = ModelContext(container)
let manager = StreakManager(modelContext: context)
let calendar = Calendar.current
// Day 3 ago and Day 2 ago (streak of 2), then skip Day 1, record today
let threeDaysAgo = calendar.date(byAdding: .day, value: -3, to: .now)!
let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: .now)!
try await manager.recordActivity(type: "workout", date: threeDaysAgo)
try await manager.recordActivity(type: "workout", date: twoDaysAgo)
try await manager.recordActivity(type: "workout", date: .now)
await manager.refresh()
#expect(manager.currentStreak == 1) // Gap broke the streak
#expect(manager.longestStreak == 2) // Previous streak preserved
}
@Test
func streakFreezePreservesStreak() async throws {
let container = try ModelContainer(
for: StreakRecord.self, StreakFreeze.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = ModelContext(container)
let manager = StreakManager(modelContext: context)
let calendar = Calendar.current
// Record 2 days ago and today — gap of 1 day
let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: .now)!
try await manager.recordActivity(type: "workout", date: twoDaysAgo)
manager.addStreakFreeze(count: 1)
let used = manager.useStreakFreeze()
try await manager.recordActivity(type: "workout", date: .now)
await manager.refresh()
#expect(used == true)
#expect(manager.currentStreak == 3) // 2 days ago + freeze + today
}Common Patterns
Check In for Today
// Record a single check-in per day (idempotent)
try await streakManager.recordActivity(type: "daily-check-in")
// StreakManager deduplicates — calling twice on the same day is safeQuery Streak State
let current = streakManager.currentStreak // Consecutive days up to today
let longest = streakManager.longestStreak // All-time best
let atRisk = streakManager.isStreakAtRisk // No activity today yet
let hasActivity = streakManager.hasActivityTodayStreak Freeze
// Grant freeze passes (e.g., as a reward or purchase)
streakManager.addStreakFreeze(count: 2)
// Use a freeze to cover a missed day
let success = streakManager.useStreakFreeze()
// Returns false if no freezes remaining or no gap to fillGotchas & Edge Cases
Timezone and Day Boundary Handling
Always use Calendar.current.startOfDay(for:) to normalize dates. Never compare raw Date values for "same day" checks — a user active at 11:59 PM and 12:01 AM has activity on two different calendar days but should not get a gap. The StreakManager uses the user's local calendar for all day boundary calculations.
Midnight Edge Cases
If a user completes an activity exactly at midnight, startOfDay(for:) may place it on the new day. The templates handle this by recording the timestamp as well as the normalized date, so the raw data is preserved for debugging.
Device Clock Manipulation
Users can change their device clock to fake streak activity. Mitigations:
- Store
createdAttimestamps alongsideactivityDateand flag anomalies (e.g.,createdAtis before a previously recordedcreatedAt) - For server-synced apps, validate streaks server-side
- For local-only apps, accept that determined users can game it — focus on honest users
Calendar vs Gregorian Day Changes
Different calendars (Islamic, Hebrew, Japanese) have different day boundaries and week structures. The templates use Calendar.current which respects the user's locale. If your app requires a fixed calendar (e.g., Gregorian for global leaderboards), pass an explicit Calendar(identifier: .gregorian) to StreakManager.
Duplicate Activity on Same Day
StreakManager.recordActivity is idempotent per calendar day per activity type. Calling it multiple times on the same day creates only one StreakRecord. This prevents accidental double-counting.
App Reinstall / Data Loss
SwiftData stores persist across app updates but are lost on reinstall. For critical streak data, consider syncing to CloudKit or a backend. The templates include a lastSyncDate hook in StreakManager for this purpose.
References
- templates.md — All production Swift templates for streak tracking
- Related:
generators/push-notifications— Push notification setup for streak reminders - Related:
generators/milestone-celebration— Celebrate streak milestones with animations
Streak Tracker Code Templates
Production-ready Swift templates for a streak tracking system. All code targets iOS 17+ / macOS 14+ and uses @Observable, SwiftData, and modern Swift concurrency.
StreakRecord.swift
import SwiftData
import Foundation
/// A single recorded activity for streak tracking.
///
/// Each record represents one day of activity for a given type.
/// Dates are normalized to start-of-day for consistent comparison.
@Model
final class StreakRecord {
/// The calendar day this activity belongs to (normalized to start of day).
var activityDate: Date
/// The type of activity (e.g., "workout", "lesson", "daily-check-in").
/// Allows tracking multiple independent streaks.
var activityType: String
/// The exact timestamp when the activity was recorded.
/// Preserved for debugging and clock-manipulation detection.
var createdAt: Date
/// Unique constraint: one record per (activityDate, activityType).
#Unique<StreakRecord>([\.activityDate, \.activityType])
init(activityType: String, date: Date = .now, calendar: Calendar = .current) {
self.activityDate = calendar.startOfDay(for: date)
self.activityType = activityType
self.createdAt = .now
}
/// Check if this record is for the same calendar day as the given date.
func isSameDay(as date: Date, calendar: Calendar = .current) -> Bool {
calendar.isDate(activityDate, inSameDayAs: date)
}
/// Number of calendar days between this record's date and another date.
func daysBetween(_ date: Date, calendar: Calendar = .current) -> Int {
let startOfOther = calendar.startOfDay(for: date)
let components = calendar.dateComponents([.day], from: activityDate, to: startOfOther)
return abs(components.day ?? 0)
}
}StreakError.swift
import Foundation
/// Errors that can occur during streak operations.
enum StreakError: Error, LocalizedError {
/// Failed to save the activity record.
case saveFailed(underlying: Error)
/// Activity for this type already recorded today (not a real error — idempotent).
case alreadyRecordedToday(activityType: String)
/// No streak freezes available.
case noFreezesAvailable
/// No gap to apply a freeze to (streak is unbroken).
case noGapToFreeze
/// The model context is unavailable.
case modelContextUnavailable
var errorDescription: String? {
switch self {
case .saveFailed(let underlying):
return "Failed to save streak record: \(underlying.localizedDescription)"
case .alreadyRecordedToday(let type):
return "Activity '\(type)' already recorded for today"
case .noFreezesAvailable:
return "No streak freeze passes available"
case .noGapToFreeze:
return "No gap in the streak to apply a freeze to"
case .modelContextUnavailable:
return "Model context is not available"
}
}
}StreakManager.swift
import SwiftData
import Foundation
import Observation
/// Core streak calculation engine.
///
/// Tracks consecutive days of activity, manages streak freezes,
/// and determines streak-at-risk status. Uses `Calendar.current` for
/// timezone-aware day boundary calculations.
///
/// Usage:
/// ```swift
/// let manager = StreakManager(modelContext: context)
/// try await manager.recordActivity(type: "workout")
/// print(manager.currentStreak) // 1
/// ```
@Observable
final class StreakManager {
// MARK: - Public State
/// Number of consecutive days in the current streak (including today if active).
private(set) var currentStreak: Int = 0
/// All-time longest streak.
private(set) var longestStreak: Int = 0
/// True if the user has not recorded activity today and has an active streak.
private(set) var isStreakAtRisk: Bool = false
/// True if activity has been recorded today.
private(set) var hasActivityToday: Bool = false
/// Number of available streak freeze passes.
private(set) var availableFreezes: Int = 0
/// Timestamp of the last data refresh.
private(set) var lastRefreshed: Date?
// MARK: - Private
private let modelContext: ModelContext
private let calendar: Calendar
// MARK: - Init
init(modelContext: ModelContext, calendar: Calendar = .current) {
self.modelContext = modelContext
self.calendar = calendar
}
// MARK: - Record Activity
/// Record an activity for the given type and date.
///
/// Idempotent: calling multiple times on the same calendar day
/// for the same type creates only one record.
///
/// - Parameters:
/// - type: The activity type identifier (default: "default").
/// - date: The date to record activity for (default: now).
@discardableResult
func recordActivity(type: String = "default", date: Date = .now) async throws -> StreakRecord {
let startOfDay = calendar.startOfDay(for: date)
// Check for existing record on this day for this type
let descriptor = FetchDescriptor<StreakRecord>(
predicate: #Predicate<StreakRecord> { record in
record.activityDate == startOfDay && record.activityType == type
}
)
let existing = try modelContext.fetch(descriptor)
if let existingRecord = existing.first {
return existingRecord
}
// Create new record
let record = StreakRecord(activityType: type, date: date, calendar: calendar)
modelContext.insert(record)
do {
try modelContext.save()
} catch {
throw StreakError.saveFailed(underlying: error)
}
await refresh()
return record
}
// MARK: - Refresh / Calculate
/// Recalculate all streak values from stored records.
func refresh(for activityType: String = "default") async {
let descriptor = FetchDescriptor<StreakRecord>(
predicate: #Predicate<StreakRecord> { record in
record.activityType == activityType
},
sortBy: [SortDescriptor(\.activityDate, order: .reverse)]
)
guard let records = try? modelContext.fetch(descriptor) else {
currentStreak = 0
longestStreak = 0
isStreakAtRisk = false
hasActivityToday = false
lastRefreshed = .now
return
}
// Get unique days sorted descending
let uniqueDays = uniqueSortedDays(from: records)
// Load freezes
let freezeDescriptor = FetchDescriptor<StreakFreeze>(
predicate: #Predicate<StreakFreeze> { freeze in
freeze.isUsed == true
}
)
let usedFreezes = (try? modelContext.fetch(freezeDescriptor)) ?? []
let frozenDays = Set(usedFreezes.map { calendar.startOfDay(for: $0.appliedDate ?? .distantPast) })
// Calculate current streak
currentStreak = calculateCurrentStreak(uniqueDays: uniqueDays, frozenDays: frozenDays)
// Calculate longest streak
longestStreak = calculateLongestStreak(uniqueDays: uniqueDays, frozenDays: frozenDays)
// Check today's activity
let today = calendar.startOfDay(for: .now)
hasActivityToday = uniqueDays.contains(today)
isStreakAtRisk = !hasActivityToday && currentStreak > 0
// Update available freezes
let availableDescriptor = FetchDescriptor<StreakFreeze>(
predicate: #Predicate<StreakFreeze> { freeze in
freeze.isUsed == false
}
)
availableFreezes = (try? modelContext.fetchCount(availableDescriptor)) ?? 0
lastRefreshed = .now
}
// MARK: - Streak Freeze
/// Add streak freeze passes.
func addStreakFreeze(count: Int = 1) {
for _ in 0..<count {
let freeze = StreakFreeze()
modelContext.insert(freeze)
}
try? modelContext.save()
availableFreezes += count
}
/// Use a streak freeze to cover yesterday's gap.
///
/// - Returns: `true` if the freeze was successfully applied, `false` otherwise.
@discardableResult
func useStreakFreeze() -> Bool {
// Find an unused freeze
let descriptor = FetchDescriptor<StreakFreeze>(
predicate: #Predicate<StreakFreeze> { freeze in
freeze.isUsed == false
},
sortBy: [SortDescriptor(\.grantedAt, order: .forward)]
)
guard let freezes = try? modelContext.fetch(descriptor),
let freeze = freezes.first else {
return false
}
// Apply the freeze to yesterday
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: .now))!
freeze.use(for: yesterday)
do {
try modelContext.save()
availableFreezes = max(0, availableFreezes - 1)
return true
} catch {
return false
}
}
// MARK: - Query Helpers
/// Get all activity dates for a given month.
func activityDates(in month: Date, for activityType: String = "default") -> Set<Date> {
guard let range = calendar.range(of: .day, in: .month, for: month),
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)),
let endOfMonth = calendar.date(byAdding: .month, value: 1, to: startOfMonth) else {
return []
}
let descriptor = FetchDescriptor<StreakRecord>(
predicate: #Predicate<StreakRecord> { record in
record.activityType == activityType &&
record.activityDate >= startOfMonth &&
record.activityDate < endOfMonth
}
)
guard let records = try? modelContext.fetch(descriptor) else { return [] }
return Set(records.map { calendar.startOfDay(for: $0.activityDate) })
}
// MARK: - Private Calculations
private func uniqueSortedDays(from records: [StreakRecord]) -> [Date] {
let days = Set(records.map { calendar.startOfDay(for: $0.activityDate) })
return days.sorted(by: >)
}
private func calculateCurrentStreak(uniqueDays: [Date], frozenDays: Set<Date>) -> Int {
guard !uniqueDays.isEmpty else { return 0 }
let today = calendar.startOfDay(for: .now)
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
// Current streak must include today or yesterday
guard uniqueDays.first == today || uniqueDays.first == yesterday ||
frozenDays.contains(today) || frozenDays.contains(yesterday) else {
// If most recent activity is older than yesterday, streak is broken
return 0
}
var streak = 0
var checkDate = today
// Walk backwards day by day
while true {
if uniqueDays.contains(checkDate) {
streak += 1
} else if frozenDays.contains(checkDate) {
streak += 1 // Frozen day counts toward streak
} else if checkDate == today {
// Today hasn't been recorded yet — skip but continue checking
// (streak is "at risk" but not broken until end of day)
} else {
break // Gap found — streak ends
}
guard let previousDay = calendar.date(byAdding: .day, value: -1, to: checkDate) else {
break
}
checkDate = previousDay
}
return streak
}
private func calculateLongestStreak(uniqueDays: [Date], frozenDays: Set<Date>) -> Int {
guard !uniqueDays.isEmpty else { return 0 }
// Combine activity days and frozen days, sorted ascending
let allDays = uniqueDays.union(frozenDays).sorted()
var longest = 1
var current = 1
for i in 1..<allDays.count {
let daysBetween = calendar.dateComponents([.day], from: allDays[i - 1], to: allDays[i]).day ?? 0
if daysBetween == 1 {
current += 1
longest = max(longest, current)
} else if daysBetween > 1 {
current = 1
}
// daysBetween == 0: duplicate day (frozen + activity), skip
}
return longest
}
}
// MARK: - Set Extension for Convenience
private extension Array where Element == Date {
func union(_ other: Set<Date>) -> Set<Date> {
var result = Set(self)
result.formUnion(other)
return result
}
func contains(_ date: Date) -> Bool {
self.contains(where: { $0 == date })
}
}StreakFreeze.swift
import SwiftData
import Foundation
/// A streak freeze pass that can preserve a streak across a missed day.
///
/// Users receive freeze passes as rewards or purchases. Each freeze
/// covers one missed calendar day, preventing the streak from breaking.
@Model
final class StreakFreeze {
/// When this freeze pass was granted.
var grantedAt: Date
/// Whether this freeze has been used.
var isUsed: Bool
/// The date this freeze was applied to (nil if unused).
var appliedDate: Date?
/// When this freeze was consumed.
var usedAt: Date?
/// Optional cooldown: earliest date this freeze can be used.
/// Prevents stacking multiple freezes back-to-back.
var availableAfter: Date?
init(availableAfter: Date? = nil) {
self.grantedAt = .now
self.isUsed = false
self.appliedDate = nil
self.usedAt = nil
self.availableAfter = availableAfter
}
/// Mark this freeze as used for the given date.
func use(for date: Date) {
isUsed = true
appliedDate = Calendar.current.startOfDay(for: date)
usedAt = .now
}
/// Whether this freeze is currently available for use.
var isAvailable: Bool {
guard !isUsed else { return false }
if let availableAfter {
return Date.now >= availableAfter
}
return true
}
}StreakCalendarView.swift
import SwiftUI
/// A calendar heat map showing days with and without activity.
///
/// Displays the current month with colored cells for active days,
/// the current streak range highlighted, and navigation between months.
///
/// Usage:
/// ```swift
/// StreakCalendarView(manager: streakManager)
/// ```
struct StreakCalendarView: View {
let manager: StreakManager
let activityType: String
@State private var displayedMonth: Date = .now
private let calendar = Calendar.current
private let columns = Array(repeating: GridItem(.flexible()), count: 7)
private let daySymbols = Calendar.current.shortWeekdaySymbols
init(manager: StreakManager, activityType: String = "default") {
self.manager = manager
self.activityType = activityType
}
var body: some View {
VStack(spacing: 16) {
monthHeader
weekdayHeader
daysGrid
}
.padding()
}
// MARK: - Month Header
private var monthHeader: some View {
HStack {
Button {
withAnimation {
displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth
}
} label: {
Image(systemName: "chevron.left")
}
.buttonStyle(.plain)
Spacer()
Text(displayedMonth, format: .dateTime.month(.wide).year())
.font(.headline)
Spacer()
Button {
withAnimation {
displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth
}
} label: {
Image(systemName: "chevron.right")
}
.buttonStyle(.plain)
.disabled(calendar.isDate(displayedMonth, equalTo: .now, toGranularity: .month))
}
}
// MARK: - Weekday Header
private var weekdayHeader: some View {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(daySymbols, id: \.self) { symbol in
Text(symbol)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Days Grid
private var daysGrid: some View {
let activityDates = manager.activityDates(in: displayedMonth, for: activityType)
let days = daysInMonth()
let today = calendar.startOfDay(for: .now)
return LazyVGrid(columns: columns, spacing: 8) {
// Leading empty cells for offset
ForEach(0..<leadingEmptyCells(), id: \.self) { _ in
Color.clear
.frame(height: 36)
}
// Day cells
ForEach(days, id: \.self) { date in
let isActive = activityDates.contains(calendar.startOfDay(for: date))
let isToday = calendar.isDate(date, inSameDayAs: today)
let isFuture = date > today
DayCellView(
day: calendar.component(.day, from: date),
isActive: isActive,
isToday: isToday,
isFuture: isFuture
)
}
}
}
// MARK: - Helpers
private func daysInMonth() -> [Date] {
guard let range = calendar.range(of: .day, in: .month, for: displayedMonth),
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: displayedMonth)) else {
return []
}
return range.compactMap { day in
calendar.date(byAdding: .day, value: day - 1, to: startOfMonth)
}
}
private func leadingEmptyCells() -> Int {
guard let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: displayedMonth)) else {
return 0
}
let weekday = calendar.component(.weekday, from: startOfMonth)
return weekday - calendar.firstWeekday
}
}
// MARK: - Day Cell
private struct DayCellView: View {
let day: Int
let isActive: Bool
let isToday: Bool
let isFuture: Bool
var body: some View {
ZStack {
if isActive {
RoundedRectangle(cornerRadius: 8)
.fill(.green.opacity(0.7))
} else if isToday {
RoundedRectangle(cornerRadius: 8)
.stroke(.primary.opacity(0.3), lineWidth: 1)
}
Text("\(day)")
.font(.callout)
.foregroundStyle(foregroundColor)
}
.frame(height: 36)
}
private var foregroundColor: Color {
if isFuture {
return .secondary.opacity(0.3)
} else if isActive {
return .white
} else {
return .primary
}
}
}StreakBadgeView.swift
import SwiftUI
/// A compact badge showing the current streak count with animation.
///
/// Displays a fire icon with the streak number. Animates with a scale
/// bounce when the streak increments.
///
/// Usage:
/// ```swift
/// StreakBadgeView(streak: manager.currentStreak)
/// StreakBadgeView(streak: manager.currentStreak, style: .expanded)
/// ```
struct StreakBadgeView: View {
let streak: Int
var style: BadgeStyle = .compact
@State private var animateScale: Bool = false
enum BadgeStyle {
/// Icon and number only.
case compact
/// Icon, number, and "day streak" label.
case expanded
}
var body: some View {
Group {
switch style {
case .compact:
compactBadge
case .expanded:
expandedBadge
}
}
.scaleEffect(animateScale ? 1.2 : 1.0)
.onChange(of: streak) { oldValue, newValue in
guard newValue > oldValue else { return }
withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
animateScale = true
}
withAnimation(.spring(response: 0.3, dampingFraction: 0.5).delay(0.2)) {
animateScale = false
}
}
}
// MARK: - Compact
private var compactBadge: some View {
HStack(spacing: 4) {
Image(systemName: streakIcon)
.foregroundStyle(streakColor)
Text("\(streak)")
.font(.headline.monospacedDigit())
.contentTransition(.numericText())
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background {
Capsule()
.fill(streakColor.opacity(0.15))
}
}
// MARK: - Expanded
private var expandedBadge: some View {
VStack(spacing: 4) {
Image(systemName: streakIcon)
.font(.title)
.foregroundStyle(streakColor)
Text("\(streak)")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
.contentTransition(.numericText())
Text(streak == 1 ? "day" : "days")
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 16)
.fill(streakColor.opacity(0.1))
}
}
// MARK: - Helpers
private var streakIcon: String {
if streak >= 100 {
return "flame.fill"
} else if streak >= 7 {
return "flame"
} else {
return "flame"
}
}
private var streakColor: Color {
if streak >= 100 {
return .red
} else if streak >= 30 {
return .orange
} else if streak >= 7 {
return .yellow
} else {
return .orange
}
}
}StreakNotificationScheduler.swift
import UserNotifications
import Foundation
/// Schedules local notifications for streak-at-risk reminders.
///
/// If the user has not recorded activity by the configured reminder time,
/// a notification fires to encourage them before the day ends.
///
/// Usage:
/// ```swift
/// let scheduler = StreakNotificationScheduler()
/// await scheduler.scheduleStreakReminder(currentStreak: 15)
/// ```
struct StreakNotificationScheduler: Sendable {
/// The hour (in 24h format) to send the streak-at-risk reminder.
let reminderHour: Int
/// The minute to send the reminder.
let reminderMinute: Int
private let notificationCenter = UNUserNotificationCenter.current()
private static let streakReminderIdentifier = "streak-at-risk-reminder"
init(reminderHour: Int = 20, reminderMinute: Int = 0) {
self.reminderHour = reminderHour
self.reminderMinute = reminderMinute
}
// MARK: - Authorization
/// Request notification permission if not already granted.
func requestAuthorization() async throws -> Bool {
try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
}
// MARK: - Schedule
/// Schedule a streak-at-risk notification for today.
///
/// Call this when the app launches or after refreshing streak state.
/// If the user has already recorded activity today, call `cancelStreakReminder()` instead.
///
/// - Parameter currentStreak: The user's current streak count (used in the message).
func scheduleStreakReminder(currentStreak: Int) async {
// Remove any existing reminder first
cancelStreakReminder()
let content = UNMutableNotificationContent()
content.title = "Your streak is at risk!"
content.body = streakMessage(for: currentStreak)
content.sound = .default
content.interruptionLevel = .timeSensitive
var dateComponents = DateComponents()
dateComponents.hour = reminderHour
dateComponents.minute = reminderMinute
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
let request = UNNotificationRequest(
identifier: Self.streakReminderIdentifier,
content: content,
trigger: trigger
)
try? await notificationCenter.add(request)
}
/// Cancel any pending streak reminder.
///
/// Call this after the user records activity for today.
func cancelStreakReminder() {
notificationCenter.removePendingNotificationRequests(
withIdentifiers: [Self.streakReminderIdentifier]
)
}
// MARK: - Message Templates
private func streakMessage(for streak: Int) -> String {
switch streak {
case 0:
return "Start a new streak today! Every journey begins with day one."
case 1...6:
return "You have a \(streak)-day streak going. Don't lose it — check in before midnight!"
case 7...29:
return "Impressive! \(streak) days in a row. Keep the momentum going today!"
case 30...99:
return "Amazing \(streak)-day streak! You're in the top tier. Don't break it now!"
default:
return "Incredible \(streak)-day streak! You're on fire. One quick check-in keeps it alive!"
}
}
}Environment Integration
import SwiftUI
/// Environment key for injecting StreakManager in SwiftUI previews and tests.
private struct StreakManagerKey: EnvironmentKey {
static let defaultValue: StreakManager? = nil
}
extension EnvironmentValues {
var streakManager: StreakManager? {
get { self[StreakManagerKey.self] }
set { self[StreakManagerKey.self] = newValue }
}
}App Setup Example
import SwiftUI
import SwiftData
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [StreakRecord.self, StreakFreeze.self])
}
}
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@State private var streakManager: StreakManager?
var body: some View {
NavigationStack {
if let manager = streakManager {
VStack(spacing: 24) {
StreakBadgeView(streak: manager.currentStreak, style: .expanded)
if manager.isStreakAtRisk {
Label("Check in today to keep your streak!", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
Button("Record Activity") {
Task {
try? await manager.recordActivity()
}
}
.buttonStyle(.borderedProminent)
StreakCalendarView(manager: manager)
}
.padding()
.navigationTitle("My Streaks")
} else {
ProgressView()
}
}
.task {
let manager = StreakManager(modelContext: modelContext)
await manager.refresh()
streakManager = manager
}
}
}