All skills
rshankras avatar

/streak-tracker

@32566aa

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.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/streak-tracker

This session only. Nothing lands on disk.

templates.md

≈6.9k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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
        }
    }
}

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    The skill is a code generator for a streak tracking system in Swift. It follows standard development practices, uses local templates, and performs basic environment checks without any malicious behavior detected.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/streak-tracker