pommedoro/Sources/Pommedoro/WorkLog.swift

213 lines
7.3 KiB
Swift

import Foundation
// MARK: - Suggestion Feedback
enum SuggestionFeedback: String, Codable {
case didIt // "Success!" then any reflection
case skipped // "Next Time"
case dismissed // "Don't Suggest" (removed from pool)
}
enum WellnessReflection: String, Codable {
case great // "Feeling Great"
case same // "About the Same"
case notReally // "Not Really"
}
// MARK: - Work Log Entry
struct WorkLogEntry: Codable, Identifiable {
let id: UUID
let date: Date
let durationSeconds: Int
let intent: String?
let completed: Bool
let reflection: String?
let additionalNotes: String?
let suggestion: String?
let suggestionFeedback: SuggestionFeedback?
let wellnessReflection: WellnessReflection?
init(
id: UUID = UUID(),
date: Date,
durationSeconds: Int,
intent: String?,
completed: Bool,
reflection: String?,
additionalNotes: String?,
suggestion: String? = nil,
suggestionFeedback: SuggestionFeedback? = nil,
wellnessReflection: WellnessReflection? = nil
) {
self.id = id
self.date = date
self.durationSeconds = durationSeconds
self.intent = intent
self.completed = completed
self.reflection = reflection
self.additionalNotes = additionalNotes
self.suggestion = suggestion
self.suggestionFeedback = suggestionFeedback
self.wellnessReflection = wellnessReflection
}
}
// MARK: - Work Log Store
class WorkLogStore {
static let shared = WorkLogStore()
private let key = "pommedoro.workLog"
private let defaults = UserDefaults.standard
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var lastLoggedDay: String?
private(set) var entries: [WorkLogEntry] = []
private static var workLogFileURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Documents", isDirectory: true)
.appendingPathComponent("pommedoro", isDirectory: true)
.appendingPathComponent("worklog.log", isDirectory: false)
}
private init() {
load()
clearOldEntries()
}
func addEntry(_ entry: WorkLogEntry) {
entries.append(entry)
save()
appendEntryToFile(entry)
}
func todayEntries() -> [WorkLogEntry] {
let calendar = Calendar.current
return entries.filter { calendar.isDateInToday($0.date) }
}
func lastTodayIntent() -> String? {
return todayEntries().last(where: { $0.intent != nil && !$0.intent!.isEmpty })?.intent
}
func todaySummary() -> (intervals: Int, totalSeconds: Int) {
let today = todayEntries()
let total = today.reduce(0) { $0 + $1.durationSeconds }
return (intervals: today.count, totalSeconds: total)
}
func todayLogAsText() -> String {
formatEntriesAsText(todayEntries(), includeDaySeparator: false)
}
private func save() {
guard let data = try? encoder.encode(entries) else { return }
defaults.set(data, forKey: key)
}
private func load() {
guard let data = defaults.data(forKey: key),
let decoded = try? decoder.decode([WorkLogEntry].self, from: data) else {
entries = []
return
}
entries = decoded
}
func clearOldEntries() {
let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
let before = entries.count
entries.removeAll { $0.date < cutoff }
if entries.count != before { save() }
}
private func ensureWorkLogDirectory() {
let dir = Self.workLogFileURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
private static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
f.timeZone = TimeZone.current
return f
}()
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone.current
return f
}()
private func appendEntryToFile(_ entry: WorkLogEntry) {
ensureWorkLogDirectory()
let dayStr = Self.dayFormatter.string(from: entry.date)
var lines: [String] = []
if lastLoggedDay != dayStr {
if lastLoggedDay != nil { lines.append("") }
lines.append("--- \(dayStr) ---")
lastLoggedDay = dayStr
}
let iso = Self.isoFormatter.string(from: entry.date)
let durMins = entry.durationSeconds / 60
let status = entry.completed ? "completed" : "partial"
let intentPart = entry.intent.map { " intent: \(flattenForLog($0))" } ?? ""
let reflPart = entry.reflection.map { " reflection: \(flattenForLog($0))" } ?? ""
let suggPart = entry.suggestion.map { " suggestion: \(flattenForLog($0))" } ?? ""
let fbPart = entry.suggestionFeedback.map { " feedback: \($0.rawValue)" } ?? ""
let wellPart = entry.wellnessReflection.map { " wellness: \($0.rawValue)" } ?? ""
lines.append("\(iso) \(durMins)m \(status)\(intentPart)\(reflPart)\(suggPart)\(fbPart)\(wellPart)")
guard let data = (lines.joined(separator: "\n") + "\n").data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: Self.workLogFileURL) {
handle.seekToEndOfFile()
handle.write(data)
try? handle.close()
} else {
try? data.write(to: Self.workLogFileURL)
}
}
private func flattenForLog(_ s: String) -> String {
s.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\t", with: " ")
.trimmingCharacters(in: .whitespaces)
}
private func formatEntriesAsText(_ entries: [WorkLogEntry], includeDaySeparator: Bool) -> String {
guard !entries.isEmpty else {
return "No intervals logged yet today."
}
var parts: [String] = []
var currentDay: String?
for entry in entries {
let dayStr = Self.dayFormatter.string(from: entry.date)
if includeDaySeparator, currentDay != dayStr {
if currentDay != nil { parts.append("") }
parts.append("--- \(dayStr) ---")
currentDay = dayStr
} else if !includeDaySeparator {
currentDay = dayStr
}
let iso = Self.isoFormatter.string(from: entry.date)
let durMins = entry.durationSeconds / 60
let status = entry.completed ? "completed" : "partial"
var line = "\(iso) \(durMins)m \(status)"
if let i = entry.intent, !i.isEmpty { line += " intent: \(flattenForLog(i))" }
if let r = entry.reflection, !r.isEmpty { line += " reflection: \(flattenForLog(r))" }
if let s = entry.suggestion, !s.isEmpty { line += " suggestion: \(flattenForLog(s))" }
if let fb = entry.suggestionFeedback { line += " feedback: \(fb.rawValue)" }
if let w = entry.wellnessReflection { line += " wellness: \(w.rawValue)" }
parts.append(line)
}
return parts.joined(separator: "\n")
}
}