diff --git a/FastPath/Models/FastingGoal.swift b/FastPath/Models/FastingGoal.swift index 428e7d8..b22f67b 100644 --- a/FastPath/Models/FastingGoal.swift +++ b/FastPath/Models/FastingGoal.swift @@ -6,19 +6,22 @@ // import Foundation +import GRDB +import StructuredQueries /// Represents a fasting goal with a target duration -struct FastingGoal: Equatable, Codable { +@Table("fastingGoal") +class FastingGoal: Equatable, Codable, TableRecord, FetchableRecord, PersistableRecord { /// Duration in seconds var targetDuration: TimeInterval - /// Optional name for the goal (e.g., "16:8 Intermittent Fasting") - var name: String? + /// Name for the goal (e.g., "16:8 Intermittent Fasting"), serves as primary key. + var name: String /// Optional description for the goal var description: String? - init(targetDuration: TimeInterval, name: String? = nil, description: String? = nil) { + init(targetDuration: TimeInterval, name: String, description: String? = nil) { self.targetDuration = targetDuration self.name = name self.description = description @@ -47,4 +50,35 @@ struct FastingGoal: Equatable, Codable { description: "An extended daily fast." ) ] + + // Required for TableRecord conformance + // GRDB can use this to initialize instances from database rows. + // We've defined 'name' as the primary key in the table schema. + required init(row: Row) throws { + name = row["name"] + targetDuration = row["targetDuration"] + description = row["description"] + } + + // Required for PersistableRecord conformance + // This tells GRDB how to persist the record to the database. + func encode(to container: inout PersistenceContainer) throws { + // 'name' is part of the primary key and should be included. + container["name"] = name + container["targetDuration"] = targetDuration + container["description"] = description + } + + // Equatable conformance + static func == (lhs: FastingGoal, rhs: FastingGoal) -> Bool { + return lhs.name == rhs.name && + lhs.targetDuration == rhs.targetDuration && + lhs.description == rhs.description + } + + // If 'name' is the primary key, it should be used for Hashable conformance as well. + // Codable conformance might require custom implementation if not all stored properties are Codable + // or if there's specific encoding/decoding logic needed (GRDB handles its own persistence). + // For simple Codable conformance for other uses (like JSON serialization), ensure all properties are Codable. + // TimeInterval (Double), String, and String? are all Codable. } diff --git a/FastPath/Models/FastingRecord.swift b/FastPath/Models/FastingRecord.swift index 1beeb48..a9e0cee 100644 --- a/FastPath/Models/FastingRecord.swift +++ b/FastPath/Models/FastingRecord.swift @@ -6,9 +6,12 @@ // import Foundation +import GRDB +import StructuredQueries /// Represents a single fasting record with start and end times -struct FastingRecord: Identifiable, Equatable, Codable { +@Table("fastingRecord") +class FastingRecord: Identifiable, Equatable, Codable, TableRecord, FetchableRecord, PersistableRecord { var id: UUID var startTime: Date var endTime: Date? @@ -22,11 +25,34 @@ struct FastingRecord: Identifiable, Equatable, Codable { return endTime == nil } + // Default initializer init(id: UUID = UUID(), startTime: Date = Date(), endTime: Date? = nil) { self.id = id self.startTime = startTime self.endTime = endTime } + + // Required for TableRecord conformance if no other initializers are suitable + // GRDB can often synthesize this if properties are straightforward + required init(row: Row) throws { + id = row["id"] + startTime = row["startTime"] + endTime = row["endTime"] + } + + // Required for PersistableRecord conformance + func encode(to container: inout PersistenceContainer) throws { + container["id"] = id + container["startTime"] = startTime + container["endTime"] = endTime + } + + // Equatable conformance + static func == (lhs: FastingRecord, rhs: FastingRecord) -> Bool { + return lhs.id == rhs.id && + lhs.startTime == rhs.startTime && + lhs.endTime == rhs.endTime + } } diff --git a/FastPath/Services/DatabaseService.swift b/FastPath/Services/DatabaseService.swift index 3d86b47..661f5f6 100644 --- a/FastPath/Services/DatabaseService.swift +++ b/FastPath/Services/DatabaseService.swift @@ -6,79 +6,178 @@ // import Foundation +import GRDB +import StructuredQueries // Though not directly used in this file, good to have if extending queries -/// Service for handling database operations for fasting records class DatabaseService { - private let userDefaults = UserDefaults.standard - private let fastingRecordsKey = "fastingRecords" - private let fastingGoalKey = "fastingGoal" + // MARK: - Properties - init() {} + /// A DatabaseQueue to connect to the database. + /// + /// Application Support Directory: + /// We use the Application Support directory to store the database file. + /// This directory is backed up by iCloud (if enabled) and is not deleted when the app is updated. + private var dbQueue: DatabaseQueue // Singleton instance static let shared = DatabaseService() + // Internal shared instance for testing + static var sharedForTesting: DatabaseService! + + // MARK: - Initialization + + /// Initializes the DatabaseService for production use. + /// Sets up the database connection to a file in Application Support and creates tables if they don't exist. + private init() { + do { + let fileManager = FileManager.default + let appSupportURL = try fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let dbURL = appSupportURL.appendingPathComponent("fastpath.sqlite") + + self.dbQueue = try DatabaseQueue(path: dbURL.path) + + try createTablesIfNeeded() + + // Preload default goals if the goals table is empty + Task { + if await getGoal() == nil { + for goal in FastingGoal.predefinedGoals { + try await saveGoal(goal) + } + } + } + + } catch { + // TODO: Handle this error more gracefully in a production app + fatalError("Failed to initialize database: \(error)") + } + } + + /// Initializes the DatabaseService with a specific DatabaseQueue (for testing). + /// Also creates tables if they don't exist. + internal init(dbQueue: DatabaseQueue) throws { + self.dbQueue = dbQueue + try createTablesIfNeeded() + // Preload default goals for testing consistency if needed, or handle in test setup + Task { + if await getGoal() == nil { + for goal in FastingGoal.predefinedGoals { + try await saveGoal(goal) + } + } + } + } + + /// Creates the necessary database tables if they don't already exist. + private func createTablesIfNeeded() throws { + try dbQueue.write { db in + // FastingRecord table + try db.create(table: FastingRecord.databaseTableName, ifNotExists: true) { t in + t.primaryKey("id", .text).notNull() // UUID is stored as TEXT + t.column("startTime", .datetime).notNull() + t.column("endTime", .datetime) + } + + // FastingGoal table + try db.create(table: FastingGoal.databaseTableName, ifNotExists: true) { t in + t.primaryKey("name", .text).notNull() + t.column("targetDuration", .double).notNull() // TimeInterval is stored as DOUBLE + t.column("description", .text) + } + } + } + + // MARK: - Fasting Records - /// Save a fasting record to the database + /// Save a fasting record to the database. func save(_ record: FastingRecord) async throws { - var records = await getAllRecords() - records.append(record) - saveAllRecords(records) + try await dbQueue.write { db in + try record.insert(db) + } } - /// Update an existing fasting record + /// Update an existing fasting record. func update(_ record: FastingRecord) async throws { - var records = await getAllRecords() - if let index = records.firstIndex(where: { $0.id == record.id }) { - records[index] = record - saveAllRecords(records) + try await dbQueue.write { db in + try record.update(db) } } - /// Get all fasting records, ordered by start time (most recent first) + /// Get all fasting records, ordered by start time (most recent first). func getAllRecords() async -> [FastingRecord] { - guard let data = userDefaults.data(forKey: fastingRecordsKey), - let records = try? JSONDecoder().decode([FastingRecord].self, from: data) else { + do { + return try await dbQueue.read { db in + try FastingRecord.all().orderBy(Column("startTime").desc).fetchAll(db) + } + } catch { + // TODO: Log error or handle more gracefully + print("Error fetching all records: \(error)") return [] } - return records.sorted(by: { $0.startTime > $1.startTime }) } - /// Get the most recent active fasting record (if any) + /// Get the most recent active fasting record (if any). func getActiveRecord() async -> FastingRecord? { - let records = await getAllRecords() - return records.first(where: { $0.endTime == nil }) - } - - /// Delete a fasting record by ID - func deleteRecord(withId id: UUID) async { - var records = await getAllRecords() - records.removeAll(where: { $0.id == id }) - saveAllRecords(records) + do { + return try await dbQueue.read { db in + try FastingRecord.filter(Column("endTime") == nil).fetchOne(db) + } + } catch { + // TODO: Log error or handle more gracefully + print("Error fetching active record: \(error)") + return nil + } } - /// Private helper to save all records - private func saveAllRecords(_ records: [FastingRecord]) { - if let data = try? JSONEncoder().encode(records) { - userDefaults.set(data, forKey: fastingRecordsKey) + /// Delete a fasting record by ID. + func deleteRecord(withId id: UUID) async throws { + try await dbQueue.write { db in + _ = try FastingRecord.deleteOne(db, key: id) } } - /// Save a fasting goal - func saveGoal(_ goal: FastingGoal?) async { - if let goal = goal, let data = try? JSONEncoder().encode(goal) { - userDefaults.set(data, forKey: fastingGoalKey) + // MARK: - Fasting Goals + + /// Save or update a fasting goal. If the goal with the same name exists, it's updated. Otherwise, it's inserted. + /// If goal is nil, it deletes all existing goals. + func saveGoal(_ goal: FastingGoal?) async throws { + if let goalToSave = goal { + try await dbQueue.write { db in + try goalToSave.save(db) // save() handles insert or update + } } else { - // If goal is nil, remove the saved goal - userDefaults.removeObject(forKey: fastingGoalKey) + // If goal is nil, remove all saved goals + try await dbQueue.write { db in + _ = try FastingGoal.deleteAll(db) + } } } - /// Get the saved fasting goal (if any) + /// Get the saved fasting goal. + /// For simplicity, this returns the first goal found. + /// If specific goal identification is needed (e.g., by name, or a concept of "active" goal), + /// this logic should be updated. func getGoal() async -> FastingGoal? { - guard let data = userDefaults.data(forKey: fastingGoalKey), - let goal = try? JSONDecoder().decode(FastingGoal.self, from: data) else { + do { + return try await dbQueue.read { db in + try FastingGoal.fetchAll(db).first + } + } catch { + // TODO: Log error or handle more gracefully + print("Error fetching goal: \(error)") + return nil + } + } + + /// Fetches a specific goal by its name. + func getGoal(named name: String) async -> FastingGoal? { + do { + return try await dbQueue.read { db in + try FastingGoal.filter(Column("name") == name).fetchOne(db) + } + } catch { + print("Error fetching goal named \(name): \(error)") return nil } - return goal } } diff --git a/FastPath/Views/StartTimeEditorView.swift b/FastPath/Views/StartTimeEditorView.swift new file mode 100644 index 0000000..9a6985d --- /dev/null +++ b/FastPath/Views/StartTimeEditorView.swift @@ -0,0 +1,78 @@ +// +// StartTimeEditorView.swift +// FastPath +// +// Created on 5/6/25. +// + +import SwiftUI +import ComposableArchitecture + +struct StartTimeEditorView: View { + let store: StoreOf + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + NavigationView { + VStack(spacing: 20) { + Text("Adjust Start Time") + .font(.headline) + .padding(.top) + + Text("Select a new start time for your current fast") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Spacer() + + if let editingTime = viewStore.editingStartTime { + DatePicker( + "Start Time", + selection: viewStore.binding( + get: { _ in editingTime }, + send: { .updateStartTime($0) } + ), + in: ...Date(), + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.wheel) + .labelsHidden() + .padding() + } + + Spacer() + + Text("Note: The new start time cannot be in the future") + .font(.caption) + .foregroundColor(.secondary) + .padding(.bottom) + } + .navigationBarTitle("Edit Start Time", displayMode: .inline) + .navigationBarItems( + leading: Button("Cancel") { + viewStore.send(.startTimeEditorDismissed) + }, + trailing: Button("Save") { + if let time = viewStore.editingStartTime { + viewStore.send(.updateStartTime(time)) + } + } + .disabled(viewStore.editingStartTime == nil) + ) + } + } + } +} + +#Preview { + StartTimeEditorView( + store: Store( + initialState: FastingFeature.State( + editingStartTime: Date().addingTimeInterval(-3600) + ), + reducer: { FastingFeature() } + ) + ) +} diff --git a/FastPathTests/FastPathTests.swift b/FastPathTests/FastPathTests.swift index 44d70b8..475a310 100644 --- a/FastPathTests/FastPathTests.swift +++ b/FastPathTests/FastPathTests.swift @@ -6,11 +6,219 @@ // import Testing +import GRDB +@testable import FastPath // Import FastPath to access its types, including DatabaseService and models +@Suite("DatabaseService Tests") struct FastPathTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. + var dbQueue: DatabaseQueue! + var sut: DatabaseService! // System Under Test + + @Test("Initial setup") func setup() async throws { + dbQueue = try DatabaseQueue(configuration: .init(path: ":memory:")) + sut = try DatabaseService(dbQueue: dbQueue) // This also calls createTablesIfNeeded and preloads goals + + // Verify predefined goals are loaded + let predefinedGoals = FastingGoal.predefinedGoals + let allGoalsInDb = try await sut.dbQueue.read { db in try FastingGoal.fetchAll(db) } + #expect(allGoalsInDb.count == predefinedGoals.count, "Predefined goals should be loaded on initial setup") + } + + // MARK: - FastingRecord Tests + + @Test("Save and Get Record") + func testSaveAndGetRecord() async throws { + try await setup() // Ensure fresh DB for each test logic block for now + let record = FastingRecord(startTime: Date(), endTime: Date().addingTimeInterval(3600)) + try await sut.save(record) + + let fetchedRecord = try await sut.dbQueue.read { db in + try FastingRecord.fetchOne(db, key: record.id) + } + #expect(fetchedRecord != nil, "Record should be fetched") + #expect(fetchedRecord?.id == record.id, "Fetched record ID should match saved record ID") + #expect(fetchedRecord?.startTime == record.startTime, "Fetched record startTime should match") + // Comparing dates directly can be tricky due to precision. For GRDB, it should be fine. + if let fetchedEndTime = fetchedRecord?.endTime, let recordEndTime = record.endTime { + #expect(abs(fetchedEndTime.timeIntervalSince(recordEndTime)) < 0.001, "Fetched record endTime should match") + } else if fetchedRecord?.endTime != nil || record.endTime != nil { + Issue.record("EndTime mismatch - one is nil, the other is not") + } + } + + @Test("Update Record") + func testUpdateRecord() async throws { + try await setup() + var record = FastingRecord(startTime: Date()) + try await sut.save(record) + + let updatedEndTime = Date().addingTimeInterval(7200) + record.endTime = updatedEndTime + try await sut.update(record) + + let fetchedRecord = try await sut.dbQueue.read { db in + try FastingRecord.fetchOne(db, key: record.id) + } + #expect(fetchedRecord != nil, "Record should exist after update") + #expect(fetchedRecord?.endTime != nil, "Updated record endTime should not be nil") + if let fetchedEndTime = fetchedRecord?.endTime { + #expect(abs(fetchedEndTime.timeIntervalSince(updatedEndTime)) < 0.001, "Fetched record endTime should match updated endTime") + } + } + + @Test("Get All Records - Ordering") + func testGetAllRecords_Ordering() async throws { + try await setup() + let now = Date() + let record1 = FastingRecord(startTime: now.addingTimeInterval(-3600)) // Older + let record2 = FastingRecord(startTime: now) // Newer + try await sut.save(record1) + try await sut.save(record2) + + let allRecords = await sut.getAllRecords() + #expect(allRecords.count == 2, "Should fetch 2 records") + #expect(allRecords.first?.id == record2.id, "First record should be the newest (record2)") + #expect(allRecords.last?.id == record1.id, "Last record should be the oldest (record1)") + } + + @Test("Get All Records - Empty") + func testGetAllRecords_Empty() async throws { + try await setup() + // Clear any predefined goals that might affect other tests if not careful with setup logic + try await sut.dbQueue.write { db in _ = try FastingRecord.deleteAll(db) } + + let allRecords = await sut.getAllRecords() + #expect(allRecords.isEmpty, "getAllRecords should return empty when no records exist") + } + + @Test("Get Active Record - Exists") + func testGetActiveRecord_Exists() async throws { + try await setup() + let activeRecord = FastingRecord(startTime: Date(), endTime: nil) + let completedRecord = FastingRecord(startTime: Date().addingTimeInterval(-7200), endTime: Date().addingTimeInterval(-3600)) + try await sut.save(activeRecord) + try await sut.save(completedRecord) + + let fetchedActiveRecord = await sut.getActiveRecord() + #expect(fetchedActiveRecord != nil, "Active record should be found") + #expect(fetchedActiveRecord?.id == activeRecord.id, "Fetched active record ID should match") + #expect(fetchedActiveRecord?.isActive == true, "Fetched record should be active") + } + + @Test("Get Active Record - None Exists") + func testGetActiveRecord_NoneExists() async throws { + try await setup() + let completedRecord = FastingRecord(startTime: Date().addingTimeInterval(-7200), endTime: Date().addingTimeInterval(-3600)) + try await sut.save(completedRecord) + + let fetchedActiveRecord = await sut.getActiveRecord() + #expect(fetchedActiveRecord == nil, "No active record should be found") + } + + @Test("Delete Record") + func testDeleteRecord() async throws { + try await setup() + let record = FastingRecord(startTime: Date()) + try await sut.save(record) + + try await sut.deleteRecord(withId: record.id) + + let fetchedRecord = try await sut.dbQueue.read { db in + try FastingRecord.fetchOne(db, key: record.id) + } + #expect(fetchedRecord == nil, "Record should be deleted") + } + + // MARK: - FastingGoal Tests + + @Test("Save and Get Goal") + func testSaveAndGetGoal() async throws { + try await setup() + // Clear predefined goals to test saving a specific one in isolation for getGoal() + try await sut.dbQueue.write { db in _ = try FastingGoal.deleteAll(db) } + + let goal = FastingGoal(targetDuration: 10 * 3600, name: "10-Hour Test Fast", description: "Test description") + try await sut.saveGoal(goal) + + let fetchedGoal = await sut.getGoal() // Relies on getGoal fetching the first/only one + #expect(fetchedGoal != nil, "Goal should be fetched") + #expect(fetchedGoal?.name == goal.name, "Fetched goal name should match") + #expect(fetchedGoal?.targetDuration == goal.targetDuration, "Fetched goal duration should match") + } + + @Test("Save Goal - Upsert") + func testSaveGoal_Upsert() async throws { + try await setup() + let initialGoal = FastingGoal(targetDuration: 12 * 3600, name: "Upsert Test", description: "Initial") + try await sut.saveGoal(initialGoal) + + let updatedGoal = FastingGoal(targetDuration: 13 * 3600, name: "Upsert Test", description: "Updated") + try await sut.saveGoal(updatedGoal) + + let fetchedGoal = await sut.getGoal(named: "Upsert Test") + #expect(fetchedGoal != nil, "Goal should exist") + #expect(fetchedGoal?.targetDuration == 13 * 3600, "Goal duration should be updated") + #expect(fetchedGoal?.description == "Updated", "Goal description should be updated") + + let allGoals = try await sut.dbQueue.read { db in try FastingGoal.fetchAll(db) } + let upsertTestGoals = allGoals.filter { $0.name == "Upsert Test" } + #expect(upsertTestGoals.count == 1, "There should be only one goal named 'Upsert Test' after upsert") + } + + @Test("Save Nil Goal - Deletes All Goals") + func testSaveNilGoal_DeletesAllGoals() async throws { + try await setup() // Setup loads predefined goals + let initialGoalsCount = FastingGoal.predefinedGoals.count + #expect(initialGoalsCount > 0, "Test assumes predefined goals exist") + + var goalsInDb = await sut.dbQueue.read { db in try FastingGoal.fetchAll(db) } + #expect(goalsInDb.count == initialGoalsCount, "Predefined goals should be loaded initially") + + try await sut.saveGoal(nil) // Delete all goals + + goalsInDb = await sut.dbQueue.read { db in try FastingGoal.fetchAll(db) } + #expect(goalsInDb.isEmpty, "All goals should be deleted after saving nil") + } + + @Test("Get Goal Named - Exists") + func testGetGoal_Named_Exists() async throws { + try await setup() // Predefined goals are loaded + let targetGoalName = FastingGoal.predefinedGoals[0].name + + let fetchedGoal = await sut.getGoal(named: targetGoalName) + #expect(fetchedGoal != nil, "Specific predefined goal should be fetched by name") + #expect(fetchedGoal?.name == targetGoalName, "Fetched goal name should match requested name") + } + + @Test("Get Goal Named - Not Exists") + func testGetGoal_Named_NotExists() async throws { + try await setup() + let fetchedGoal = await sut.getGoal(named: "NonExistentGoalName") + #expect(fetchedGoal == nil, "Fetching a non-existent goal by name should return nil") } + + @Test("Test Predefined Goals are Loaded") + func testPredefinedGoals_AreLoaded() async throws { + try await setup() // Setup method already calls the initializer that loads goals + let predefinedGoals = FastingGoal.predefinedGoals + #expect(!predefinedGoals.isEmpty, "Test requires predefined goals to exist for validation.") + for goal in predefinedGoals { + let fetchedGoal = await sut.getGoal(named: goal.name) + #expect(fetchedGoal != nil, "Predefined goal '\(goal.name)' should be loaded.") + #expect(fetchedGoal?.targetDuration == goal.targetDuration, "Predefined goal '\(goal.name)' duration should match.") + } + + let allGoalsInDb = try await sut.dbQueue.read { db in try FastingGoal.fetchAll(db) } + #expect(allGoalsInDb.count == predefinedGoals.count, "The number of goals in DB should match the number of predefined goals.") + } +} + +// Helper to make Date comparison more robust if needed, though GRDB's precision is usually good. +// For this exercise, direct comparison or timeIntervalSince comparison with a small epsilon is used. +extension Date { + func isClose(to otherDate: Date, precision: TimeInterval = 0.001) -> Bool { + return abs(self.timeIntervalSince(otherDate)) < precision + } } diff --git a/Package.swift b/Package.swift index 4ef89a8..60e56e2 100644 --- a/Package.swift +++ b/Package.swift @@ -8,14 +8,18 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.19.1"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.2.0") + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.2.0"), + .package(url: "https://github.com/groue/GRDB.swift", from: "7.5.0"), + .package(url: "https://github.com/pointfreeco/sharing-grdb", from: "0.2.2") ], targets: [ .target( name: "FastPath", dependencies: [ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - .product(name: "StructuredQueries", package: "swift-structured-queries") + .product(name: "StructuredQueries", package: "swift-structured-queries"), + .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "SharingGRDB", package: "sharing-grdb") ] ) ] diff --git a/ai_specs/ModifyStartTime.md b/ai_specs/ModifyStartTime.md new file mode 100644 index 0000000..a5a9cf1 --- /dev/null +++ b/ai_specs/ModifyStartTime.md @@ -0,0 +1,72 @@ +# Functional Requirements - Edit Active Fast Start Time Feature + +**Project:** Intermittent Fasting Tracker (iOS) +**Feature Version:** 1.0 +**Date:** May 6, 2025 +**Author:** [Your Name/Company Name] + +**1. Overview** + +This document outlines the specific functional requirements for allowing users to edit the start time of an *currently active* fasting period. This feature addresses scenarios where a user forgets to start the timer immediately or makes a small adjustment to their actual fast initiation time (e.g., had a late snack). + +**2. Functional Requirements** + +* **EST-001: Access to Edit Start Time** + * **Description:** Provide a clear way for the user to initiate the start time editing process for an ongoing fast. + * **Details:** + * When a fast is currently active (FR-003), an "Edit Start Time" button or option shall be accessible on the main timer screen. + * This option should be clearly labeled (e.g., "Adjust Start Time," "Edit Start"). + +* **EST-002: Start Time Modification Interface** + * **Description:** Allow the user to select a new start date and time. + * **Details:** + * Upon activating the "Edit Start Time" option, the application shall present a date and time picker interface. + * The picker should default to the currently recorded start time of the active fast. + +* **EST-003: New Start Time Validation** + * **Description:** Implement rules to ensure the new start time is valid. + * **Details:** + * The selected new start time cannot be in the future. + * The selected new start time cannot be later than the current system time (or a very brief moment before, e.g., current time - 1 minute, to avoid issues). + * An appropriate error message or feedback should be provided to the user if an invalid time is selected. + +* **EST-004: Confirmation of Change** + * **Description:** Require user confirmation before applying the new start time. + * **Details:** + * After selecting a new valid start time, the user must explicitly confirm the change (e.g., via a "Save," "Confirm," or "Update" button). + * An option to "Cancel" the edit and revert to the original start time must also be available. + +* **EST-005: Timer Recalculation and Display Update** + * **Description:** Ensure all relevant timers and displays reflect the adjusted start time. + * **Details:** + * Upon confirmation of the new start time: + * The elapsed time for the active fast (FR-003) must immediately recalculate and update based on the new start time. + * If a Daily Fast Goal is set and a countdown timer is active (DFG-004), it must also immediately recalculate and update. + * The main screen display must refresh to show the corrected timer(s). + +* **EST-006: Data Persistence of New Start Time** + * **Description:** The adjusted start time for the active fast must be saved. + * **Details:** + * The new, confirmed start time for the currently active fast must be saved to local storage, overwriting the previous start time for this specific fast instance. + +* **EST-007: Impact on Dynamic Island / Live Activity** + * **Description:** Ensure any active Live Activity reflects the change. + * **Details:** + * If a Live Activity for the current fast is active in the Dynamic Island (DI-001), its displayed remaining time (and any other relevant data) must update to reflect the new start time and recalculated goal completion. + +**3. Non-Functional Requirements (Related)** + +* **NFR-UI-EST:** The editing interface should be intuitive, consistent with iOS date/time selection patterns, and provide clear feedback. +* **NFR-Perf-EST:** The recalculation and display updates should be instantaneous from the user's perspective. +* **NFR-Data-EST:** The update to the locally stored start time must be atomic and reliable. + +**4. Scope Considerations (Out of Scope for this Version)** + +* Editing the start time of *completed* fasts (i.e., fasts in the history view). This is a separate feature. +* Editing the *end time* of an active or completed fast. + +**5. Dependencies** + +* This feature modifies an active fast, so it depends on the core fasting timer functionality (FR-001, FR-003). +* Interaction with the countdown timer depends on the "Daily Fast Goal" feature (DFG-001). +* Interaction with the Dynamic Island depends on the "Dynamic Island Fast Timer Feature" (DI-001). diff --git a/ai_specs/StreaksFeature.md b/ai_specs/StreaksFeature.md new file mode 100644 index 0000000..5e81427 --- /dev/null +++ b/ai_specs/StreaksFeature.md @@ -0,0 +1,63 @@ +# Functional Requirements - Streaks Feature + +**Project:** Intermittent Fasting Tracker (iOS) +**Feature Version:** 1.0 +**Date:** May 6, 2025 +**Author:** [Your Name/Company Name] + +**1. Overview** + +This document outlines the specific functional requirements for implementing a "Streaks" feature within the Intermittent Fasting Tracker iOS application. This feature aims to motivate users by tracking and displaying their consistency in meeting their set daily fasting goals. This feature depends on the "Daily Fast Goal" feature (DFG-001) being implemented and a goal being actively used. + +**2. Functional Requirements** + +* **STR-001: Define Streak Criteria (Meeting Daily Goal)** + * **Description:** Establish what constitutes a "streak." + * **Details:** + * A streak is defined as the number of consecutive days a user successfully completes a fast that meets or exceeds their currently set "Daily Fast Goal" duration (as per DFG-001). + * A day is considered part of the streak if at least one fast completed on that calendar day meets the user's daily goal. + * The determination of "completed on that calendar day" will be based on the fast's end time. + +* **STR-002: Streak Calculation** + * **Description:** Logic for calculating and updating the user's current streak. + * **Details:** + * When a fast is stopped (FR-002) and its duration meets or exceeds the user's set Daily Fast Goal for the day it ended: + * If the previous day was also part of the streak, the current streak count increments by 1. + * If the previous day was *not* part of the streak (or there was no fast meeting the goal on the previous day), the streak starts at 1. + * If a calendar day passes where the user does *not* complete a fast that meets their Daily Fast Goal, the current streak is reset to 0. This check should ideally occur daily (e.g., at midnight or when the app is next opened after a day has passed). + +* **STR-003: Streak Display** + * **Description:** Show the user's current streak within the application. + * **Details:** + * The user's current streak count (e.g., "Current Streak: 5 days") shall be displayed in a prominent, easily visible location within the app (e.g., on the main dashboard/timer screen or a dedicated progress/stats area). + * If the current streak is 0, it can either display "Current Streak: 0 days" or a motivational message (e.g., "Start a new streak today!"). + +* **STR-004: Streak Data Persistence** + * **Description:** Store streak-related data locally and reliably. + * **Details:** + * The current streak count must be stored locally on the user's device. + * The date of the last fast that contributed to the streak must also be stored to correctly calculate consecutive days. + * This data must persist across application launches, device restarts, and app updates. + +* **STR-005: Handling Changes to Daily Fast Goal** + * **Description:** Define how changing the Daily Fast Goal affects an ongoing streak. + * **Details:** + * If a user changes their Daily Fast Goal, the streak calculation (STR-002) for *future* fasts will use the *new* goal. + * The existing streak, built upon the previous goal, is maintained as long as subsequent fasts meet the *new* goal. Changing the goal mid-streak does not automatically reset the streak, but the new target must be met to continue it. + +* **STR-006: Longest Streak (Optional - Future Enhancement)** + * **Description:** Track and display the user's longest achieved streak. + * **Details:** This is an optional enhancement for a future version. If implemented, the system would also store and display the user's "Longest Streak: X days". + +**3. Non-Functional Requirements (Related)** + +* **NFR-UI-Streak:** The streak display should be visually clear, motivating, and integrated naturally into the app's UI. +* **NFR-Data-Streak:** Streak data must be stored entirely locally on the device. +* **NFR-Perf-Streak:** Streak calculation logic should be efficient and not noticeably impact app performance, especially during app launch or when completing a fast. + +**4. Dependencies** + +* This feature relies on the implementation of: + * User-settable "Daily Fast Goal" (DFG-001 and related requirements). + * Core fasting start/stop and history logging functionality (FR-001, FR-002, FR-005). +