213 lines
7.3 KiB
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")
|
|
}
|
|
}
|