Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Generates a streak tracking system with timezone-aware day boundaries, streak freeze protection, and streak-at-risk push notifications. Use when user wants daily/weekly engagement streaks, consecutive day tracking, or habit tracking.
.claude/skills/rshankras-streak-tracker/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-08 | ✗→✓ | ▲ Improved | 228% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 151% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 89% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 63% | 0% |
| case-17 | ✗→✓ | ▲ Improved | 161% | 0% |
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.
Use this skill when the user:
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:
Determine if generating for iOS (UNUserNotificationCenter) or macOS (UNUserNotificationCenter available on macOS 11+) or both.
Ask user via AskUserQuestion:
Read templates.md for production Swift code.
Generate these files:
StreakRecord.swift — SwiftData @Model for activity recordsStreakManager.swift — @Observable class: record activity, calculate streaks, manage freezesStreakError.swift — Error types for streak operationsStreakCalendarView.swift — Grid showing days with/without activityStreakBadgeView.swift — Compact badge with streak count and animationBased on configuration:
StreakFreeze.swift — If streak freeze selectedStreakNotificationScheduler.swift — If notifications selectedCheck project structure:
Sources/ exists -> Sources/StreakTracking/App/ exists -> App/StreakTracking/StreakTracking/After generation, provide:
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)Set up the model container (SwiftData):
swift@main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: [StreakRecord.self, StreakFreeze.self]) } }
Record activity on user action:
swiftstruct 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:
swiftstruct 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:
swiftstruct 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:
swiftButton("Use Streak Freeze") { if streakManager.useStreakFreeze() { // Freeze applied — streak preserved } else { // No freezes available } }
swiftimport 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 }
swift// 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 safe
swiftlet 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.hasActivityToday
swift// 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 fill
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.
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.
Users can change their device clock to fake streak activity. Mitigations:
createdAt timestamps alongside activityDate and flag anomalies (e.g., createdAt is before a previously recorded createdAt)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.
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.
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.
monetization/external-purchases); everywhere else the rule stands.app-store/originality-check before submitting.generators/push-notifications — Push notification setup for streak remindersgenerators/milestone-celebration — Celebrate streak milestones with animationsOther measured skills in the registry, with their headline benchmark lift.