From fd2be632a1d1c8443f022fff66440d0db25c4453 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sat, 20 Jun 2026 14:25:39 +0300 Subject: [PATCH] Add cache cleanup: age-based auto-eviction + manual Clear cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheMaintenance prunes the SwiftData caches so they don't grow forever: seen markers >30d, read >90d, cached articles >30d, cached stories >14d — always protecting saved bookmarks. Runs on launch. Settings gains a "Clear offline cache" action (keeps saved) with a count + confirmation. Co-Authored-By: Claude Opus 4.8 --- Jarvis/Store/CacheMaintenance.swift | 61 ++++++++++++++++++++++++ Jarvis/Views/Home/SignalFeedView.swift | 1 + Jarvis/Views/Settings/SettingsView.swift | 27 +++++++++-- 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 Jarvis/Store/CacheMaintenance.swift diff --git a/Jarvis/Store/CacheMaintenance.swift b/Jarvis/Store/CacheMaintenance.swift new file mode 100644 index 0000000..5fa884d --- /dev/null +++ b/Jarvis/Store/CacheMaintenance.swift @@ -0,0 +1,61 @@ +// CacheMaintenance.swift +// Jarvis — bounds the on-device SwiftData caches so they don't grow forever. +// Saved (bookmarked) stories are always protected. + +import Foundation +import SwiftData + +@MainActor +enum CacheMaintenance { + private static let day: TimeInterval = 86_400 + + // Retention windows. + private static let seenDays = 30.0 // "seen" markers: only useful while a story is live + private static let readDays = 90.0 // read history: small, keep longer + private static let articleDays = 30.0 // offline article bodies + private static let storyDays = 14.0 // offline story list + + /// Age-based eviction. Run on launch. Cheap; protects saved stories. + static func prune(_ context: ModelContext) { + let now = Date() + let seenCutoff = now.addingTimeInterval(-seenDays * day) + let readCutoff = now.addingTimeInterval(-readDays * day) + let articleCutoff = now.addingTimeInterval(-articleDays * day) + let storyCutoff = now.addingTimeInterval(-storyDays * day) + + // Markers: simple age batch deletes. + try? context.delete(model: SeenStory.self, where: #Predicate { $0.seenAt < seenCutoff }) + try? context.delete(model: ReadStory.self, where: #Predicate { $0.readAt < readCutoff }) + + // Offline content: drop old entries that aren't saved. + let saved = savedIDs(context) + if let arts = try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.cachedAt < articleCutoff })) { + for a in arts where !saved.contains(a.storyId) { context.delete(a) } + } + if let stories = try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.cachedAt < storyCutoff })) { + for s in stories where !saved.contains(s.id) { context.delete(s) } + } + try? context.save() + } + + /// Manual "Clear cache" — wipes offline stories/articles + seen/read state, + /// keeps your saved bookmarks (and their articles). + static func clear(_ context: ModelContext) { + try? context.delete(model: SeenStory.self) + try? context.delete(model: ReadStory.self) + let saved = savedIDs(context) + if let arts = try? context.fetch(FetchDescriptor()) { + for a in arts where !saved.contains(a.storyId) { context.delete(a) } + } + if let stories = try? context.fetch(FetchDescriptor()) { + for s in stories where !saved.contains(s.id) { context.delete(s) } + } + try? context.save() + } + + private static func savedIDs(_ context: ModelContext) -> Set { + Set((try? context.fetch(FetchDescriptor()))?.map(\.id) ?? []) + } +} diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 659de7d..f5de141 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -120,6 +120,7 @@ struct SignalFeedView: View { .onChange(of: store.stories) { _, newValue in cacheStories(newValue) } + .task { CacheMaintenance.prune(modelContext) } // bound the caches on launch .task(id: selectedPill) { // A filter active but nothing matched in the loaded set → pull a few // more pages so sparse pills still populate. diff --git a/Jarvis/Views/Settings/SettingsView.swift b/Jarvis/Views/Settings/SettingsView.swift index f42ccff..b5a1896 100644 --- a/Jarvis/Views/Settings/SettingsView.swift +++ b/Jarvis/Views/Settings/SettingsView.swift @@ -2,10 +2,15 @@ // Jarvis — settings hub: Notifications, Connectivity, and server info. import SwiftUI +import SwiftData struct SettingsView: View { @EnvironmentObject var settings: ServerSettings @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query private var cachedStories: [CachedStory] + @Query private var cachedArticles: [CachedArticle] + @State private var showClearConfirm = false var body: some View { NavigationStack { @@ -31,6 +36,14 @@ struct SettingsView: View { row(icon: "server.rack", title: settings.host ?? "Not configured", subtitle: "Connected server", chevron: false) } + + group("STORAGE") { + Button { showClearConfirm = true } label: { + row(icon: "trash", title: "Clear offline cache", + subtitle: "\(cachedStories.count) stories · \(cachedArticles.count) articles · keeps saved", + chevron: false, tint: Color(hex: "C25555")) + } + } } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -40,6 +53,12 @@ struct SettingsView: View { } .preferredColorScheme(.dark) .presentationDragIndicator(.visible) + .confirmationDialog("Clear offline cache?", isPresented: $showClearConfirm, titleVisibility: .visible) { + Button("Clear cache", role: .destructive) { CacheMaintenance.clear(modelContext) } + Button("Cancel", role: .cancel) {} + } message: { + Text("Removes cached stories, articles, and read/seen history. Your saved stories are kept.") + } } private var header: some View { @@ -64,11 +83,13 @@ struct SettingsView: View { } } - private func row(icon: String, title: String, subtitle: String, chevron: Bool = true) -> some View { + private func row(icon: String, title: String, subtitle: String, + chevron: Bool = true, tint: Color = .white) -> some View { HStack(spacing: 14) { - Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Color(hex: "999999")).frame(width: 26) + Image(systemName: icon).font(.system(size: 18)) + .foregroundStyle(tint == .white ? Color(hex: "999999") : tint).frame(width: 26) VStack(alignment: .leading, spacing: 2) { - Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(.white).lineLimit(1) + Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(tint).lineLimit(1) Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888")).lineLimit(1) } Spacer()