TimeTrackingService

Purpose

High-level abstraction over FileStorageService providing business-logic operations for time tracking. Simplifies common workflows like starting/stopping timers and managing tags.

Protocol

public protocol TimeTrackingServiceProtocol: Sendable {
    func getAllTags() async throws -> [Tag]
    func createTag(name: String, color: String) async throws -> Tag
    func updateTag(_ tag: Tag) async throws
    func deleteTag(_ tag: Tag) async throws
    func archiveTag(_ tag: Tag) async throws
    func unarchiveTag(_ tag: Tag) async throws
    func startTimer(tagId: UUID?, comment: String?) async throws -> TimeRecord
    func stopTimer(_ record: TimeRecord) async throws -> TimeRecord
    func getRunningTimers() async throws -> [TimeRecord]
    func getRecords(from startDate: Date, to endDate: Date) async throws -> [TimeRecord]
    func getTodayRecords() async throws -> [TimeRecord]
    func updateRecord(_ record: TimeRecord) async throws
    func deleteRecord(_ record: TimeRecord) async throws
    func createManualRecord(startTime: Date, endTime: Date?, tagId: UUID?, comment: String?) async throws -> TimeRecord

    // Image operations
    func addImage(_ data: Data, to record: TimeRecord, ext: String) async throws -> TimeRecord
    func removeImage(at index: Int, from record: TimeRecord) async throws -> TimeRecord
}

Implementation

public actor TimeTrackingService: TimeTrackingServiceProtocol {
    private let storage: FileStorageService

    public init(storage: FileStorageService)
}

Uses Swift actor model for thread-safe concurrent access.

Tag Operations

MethodDescription
getAllTags()Retrieve all tags from storage
createTag(name:color:)Create new tag with name and hex color
updateTag(_:)Update existing tag properties
deleteTag(_:)Remove tag from storage
archiveTag(_:)Archive tag (hidden from selection UI)
unarchiveTag(_:)Unarchive tag (restore to active)

Timer Operations

MethodDescription
startTimer(tagId:comment:)Start new timer, returns TimeRecord with nil endTime
stopTimer(_:)Stop running timer, sets endTime to current time
getRunningTimers()Get all records with nil endTime

Record Operations

MethodDescription
getRecords(from:to:)Query records within date range
getTodayRecords()Convenience method for today’s records
updateRecord(_:)Update existing record. Refused with AutomergeStorageError.recordMayBeInUnreadableMonth when no readable month holds the id while a month file is unreadable
restoreRecord(_:)Write back a record that storage does not hold; see below
deleteRecord(_:)Remove record from storage
createManualRecord(startTime:endTime:tagId:comment:)Create record with explicit times

restoreRecord

restoreRecord(_:) is an actor method, not part of TimeTrackingServiceProtocol. It validates the time range and stores the caller’s value as it is, id included, through storage.saveRecord. The app uses it for the undo of a delete (AppState.undoDelete) and for the records built by the in-app HEY import.

Use it instead of updateRecord whenever the record is not expected to be in storage. updateRecord edits an existing record and is refused while a month file is unreadable and no readable month holds the id; a restore has to work in that state too. Never use it to edit a record that is in storage: it does not look for the record in other months, so a changed start time would leave a duplicate behind. Storage side: AutomergeStorageService.

Image Operations

MethodDescription
addImage(_:to:ext:)Add image to record, returns updated record with new filename
removeImage(at:from:)Remove image at index from record

Images are stored via FileStorageService and referenced by filename in TimeRecord.images array.

Both keep the record and the files consistent when storage refuses the record update (for example recordMayBeInUnreadableMonth):

  • addImage writes the file, then updates the record. If the update throws, the file just written is removed again and the error is rethrown, so no orphan is left.
  • removeImage updates the record first and deletes the file after. If the update throws, the file is untouched and the record still lists it. A file that cannot be deleted after a successful update is logged and left as an orphan (reported by minuta doctor); the call still returns the updated record.

Models

Services

UI

Testing

  • MockTimeTrackingService for unit tests