Update Neon colors and show color circles in theme picker

- Update NeonMoodTint to use synthwave colors matching Neon voting style
  (cyan, lime, yellow, orange, magenta)
- Replace text label with 5 color circles in theme preview Colors row
- Remove unused textColor customization code and picker views
- Add .id(moodTint) to Month/Year views for color refresh
- Clean up various unused color-related code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-30 00:08:01 -06:00
parent 51c5777c03
commit bea2d3bbc9
58 changed files with 1142 additions and 967 deletions

View File

@@ -67,4 +67,21 @@ final class DataController: ObservableObject {
Self.logger.error("Failed to save context: \(error.localizedDescription)")
}
}
/// Refresh data from disk to pick up changes made by extensions (widget/watch).
/// Call this when app becomes active.
func refreshFromDisk() {
// SwiftData doesn't have a direct "refresh from disk" API.
// We achieve this by:
// 1. Rolling back any unsaved changes (ensures clean state)
// 2. Triggering listeners to re-fetch data (which will read from disk)
modelContext.rollback()
// Notify listeners to re-fetch their data
for closure in editedDataClosure {
closure()
}
Self.logger.debug("Refreshed data from disk")
}
}

View File

@@ -10,9 +10,12 @@ import Foundation
extension DataController {
func add(mood: Mood, forDate date: Date, entryType: EntryType) {
// Delete existing entry for this date if present
if let existing = getEntry(byDate: date) {
modelContext.delete(existing)
// Delete ALL existing entries for this date (handles duplicates)
let existing = getAllEntries(byDate: date)
for entry in existing {
modelContext.delete(entry)
}
if !existing.isEmpty {
try? modelContext.save()
}
@@ -50,15 +53,23 @@ extension DataController {
let missing = Array(Set(allDates).subtracting(existingDates)).sorted(by: >)
guard !missing.isEmpty else { return }
// Batch insert all missing dates without triggering listeners
for date in missing {
// Add 12 hours to avoid UTC offset issues
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 12, to: date)!
add(mood: .missing, forDate: adjustedDate, entryType: .filledInMissing)
let entry = MoodEntryModel(
forDate: adjustedDate,
mood: .missing,
entryType: .filledInMissing
)
modelContext.insert(entry)
}
if !missing.isEmpty {
EventLogger.log(event: "filled_in_missing_entries", withData: ["count": missing.count])
}
// Single save and listener notification at the end
saveAndRunDataListeners()
EventLogger.log(event: "filled_in_missing_entries", withData: ["count": missing.count])
}
func fixWrongWeekdays() {

View File

@@ -38,4 +38,70 @@ extension DataController {
}
save()
}
/// Get ALL entries for a specific date (not just the first one)
func getAllEntries(byDate date: Date) -> [MoodEntryModel] {
let startDate = Calendar.current.startOfDay(for: date)
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
let descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate <= endDate
},
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Delete ALL entries for a specific date
func deleteAllEntries(forDate date: Date) {
let entries = getAllEntries(byDate: date)
for entry in entries {
modelContext.delete(entry)
}
save()
}
/// Find and remove duplicate entries, keeping only the most recent for each date
/// Returns the number of duplicates removed
@discardableResult
func removeDuplicates() -> Int {
let allEntries = getData(startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [])
// Group by day
var entriesByDay = [Date: [MoodEntryModel]]()
let calendar = Calendar.current
for entry in allEntries {
let dayStart = calendar.startOfDay(for: entry.forDate)
entriesByDay[dayStart, default: []].append(entry)
}
var duplicatesRemoved = 0
for (_, dayEntries) in entriesByDay where dayEntries.count > 1 {
// Sort by timestamp (most recent first), preferring non-missing moods
let sorted = dayEntries.sorted { a, b in
// Prefer non-missing moods
if a.mood != .missing && b.mood == .missing { return true }
if a.mood == .missing && b.mood != .missing { return false }
// Then by timestamp (most recent first)
return a.timestamp > b.timestamp
}
// Keep the first (best) entry, delete the rest
for entry in sorted.dropFirst() {
modelContext.delete(entry)
duplicatesRemoved += 1
}
}
if duplicatesRemoved > 0 {
saveAndRunDataListeners()
AppLogger.general.info("Removed \(duplicatesRemoved) duplicate entries")
}
return duplicatesRemoved
}
}

View File

@@ -39,45 +39,77 @@ extension DataController {
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Calculate the current mood streak efficiently using a single batch query.
/// Returns a tuple of (streak count, last logged mood if today has entry).
func calculateStreak(from votingDate: Date) -> (streak: Int, todaysMood: Mood?) {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: votingDate)
// Fetch last 365 days of entries in a single query
guard let yearAgo = calendar.date(byAdding: .day, value: -365, to: dayStart) else {
return (0, nil)
}
let entries = getData(startDate: yearAgo, endDate: votingDate, includedDays: [])
.filter { $0.mood != .missing && $0.mood != .placeholder }
guard !entries.isEmpty else { return (0, nil) }
// Build a Set of dates that have valid entries for O(1) lookup
let datesWithEntries = Set(entries.map { calendar.startOfDay(for: $0.forDate) })
// Check for today's entry
let todaysEntry = entries.first { calendar.isDate($0.forDate, inSameDayAs: votingDate) }
let todaysMood = todaysEntry?.mood
// Calculate streak walking backwards
var streak = 0
var checkDate = votingDate
// If no entry for current voting date, start counting from previous day
if !datesWithEntries.contains(dayStart) {
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
}
while true {
let checkDayStart = calendar.startOfDay(for: checkDate)
if datesWithEntries.contains(checkDayStart) {
streak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
} else {
break
}
}
return (streak, todaysMood)
}
func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] {
// Single query to fetch all data - avoid N*12 queries
let data = getData(
startDate: Date(timeIntervalSince1970: 0),
endDate: Date(),
includedDays: includedDays
).sorted { $0.forDate < $1.forDate }
)
guard let earliest = data.first,
let latest = data.last else { return [:] }
guard !data.isEmpty else { return [:] }
let calendar = Calendar.current
let earliestYear = calendar.component(.year, from: earliest.forDate)
let latestYear = calendar.component(.year, from: latest.forDate)
// Group entries by year and month in memory (single pass)
var result = [Int: [Int: [MoodEntryModel]]]()
for year in earliestYear...latestYear {
var monthData = [Int: [MoodEntryModel]]()
for entry in data {
let year = calendar.component(.year, from: entry.forDate)
let month = calendar.component(.month, from: entry.forDate)
for month in 1...12 {
var components = DateComponents()
components.year = year
components.month = month
components.day = 1
guard let startOfMonth = calendar.date(from: components) else { continue }
let items = getData(
startDate: startOfMonth,
endDate: startOfMonth.endOfMonth,
includedDays: [1, 2, 3, 4, 5, 6, 7]
)
if !items.isEmpty {
monthData[month] = items
}
if result[year] == nil {
result[year] = [:]
}
result[year] = monthData
if result[year]![month] == nil {
result[year]![month] = []
}
result[year]![month]!.append(entry)
}
return result

View File

@@ -174,9 +174,12 @@ final class ExtensionDataProvider {
/// - date: The date for the entry
/// - entryType: The source of the entry (widget, watch, etc.)
func add(mood: Mood, forDate date: Date, entryType: EntryType) {
// Delete existing entry for this date if present
if let existing = getEntry(byDate: date) {
modelContext.delete(existing)
// Delete ALL existing entries for this date (handles duplicates)
let existing = getAllEntries(byDate: date)
for entry in existing {
modelContext.delete(entry)
}
if !existing.isEmpty {
try? modelContext.save()
}
@@ -199,6 +202,21 @@ final class ExtensionDataProvider {
}
}
/// Get ALL entries for a specific date (not just the first one)
func getAllEntries(byDate date: Date) -> [MoodEntryModel] {
let startDate = Calendar.current.startOfDay(for: date)
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
let descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate <= endDate
},
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Invalidate cached container (call when data might have changed)
func invalidateCache() {
_container = nil