From afec870919f185907a4adbb91f7a74f89ed68d55 Mon Sep 17 00:00:00 2001 From: Kutesir Date: Sun, 12 Jul 2026 01:51:35 +0300 Subject: [PATCH] fix: display story age from firstSeenAt, not updatedAt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old stories were reading "1 hr ago" because updatedAt bumps every time the backend attaches another article to a cluster, however loosely related. firstSeenAt is when the cluster actually first appeared and is already in the API response — just wasn't decoded. Verified live: story_1a37c858235b (F1 Austria GP recap) has firstSeenAt 2026-06-16 but updatedAt 2026-07-11, freshnessScore 0.96 — looked brand new despite being 25 days old. Also confirmed the API's isStale field is unusable as a stopgap (always false regardless of age). Ranking still surfaces old stories at the top — that's server-side (signalScore/freshnessScore) and out of scope for the client per the architecture rule against re-ranking. Logged in BACKLOG #15. Co-Authored-By: Claude Sonnet 4.6 --- Jarvis/Models/Models.swift | 10 ++++++++++ Jarvis/Store/StoryStore.swift | 3 ++- Jarvis/Views/Home/SignalFeedView.swift | 5 +++-- Jarvis/Views/Home/StoryRowView.swift | 9 +++++++-- docs/BACKLOG.md | 11 +++++++++-- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift index 8b7ce32..de589dc 100644 --- a/Jarvis/Models/Models.swift +++ b/Jarvis/Models/Models.swift @@ -61,6 +61,11 @@ struct StorySummary: Codable, Identifiable, Hashable { let conflict: String? let updatedAt: Date let createdAt: Date + /// When the cluster first appeared. `updatedAt` bumps on every article the + /// backend attaches to the cluster (even a loosely-related one), so it can + /// read "1 hr ago" on stories that are actually weeks old — `firstSeenAt` + /// is the honest age. Optional so decoding tolerates older API responses. + let firstSeenAt: Date? } struct ScoreBreakdown: Codable, Hashable { @@ -185,6 +190,10 @@ final class CachedStory { /// Original publish timestamp. Defaults to updatedAt for rows cached before /// this field was added — SwiftData handles the automatic migration. var createdAt: Date = Date() + /// When the cluster first appeared — the honest age, unlike `updatedAt` which + /// bumps whenever the backend attaches another article. Defaults to `updatedAt` + /// for rows cached before this field was added. + var firstSeenAt: Date = Date() var cachedAt: Date init(from summary: StorySummary) { @@ -199,6 +208,7 @@ final class CachedStory { self.conflict = summary.conflict self.updatedAt = summary.updatedAt self.createdAt = summary.createdAt + self.firstSeenAt = summary.firstSeenAt ?? summary.updatedAt self.cachedAt = Date() } } diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift index dca2725..0e9b74a 100644 --- a/Jarvis/Store/StoryStore.swift +++ b/Jarvis/Store/StoryStore.swift @@ -312,7 +312,8 @@ final class StoryStore: ObservableObject { consensus: old.consensus, conflict: old.conflict, updatedAt: old.updatedAt, - createdAt: old.createdAt + createdAt: old.createdAt, + firstSeenAt: old.firstSeenAt ) stories[idx] = updated stories.sort(by: StorySummary.feedOrder) diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index c837cd6..8947529 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -481,7 +481,7 @@ struct SignalFeedView: View { if !story.sources.isEmpty { SourceChips(names: story.sources.map(\.name), total: story.sourceCount) } - Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago") + Text("\(story.sourceCount) sources · \((story.firstSeenAt ?? story.updatedAt).timeAgoShort()) ago") .font(.system(size: 11, weight: .regular, design: .monospaced)) .foregroundStyle(Palette.tertiaryText) } @@ -768,7 +768,8 @@ extension StorySummary { consensus: cached.consensus, conflict: cached.conflict, updatedAt: cached.updatedAt, - createdAt: cached.createdAt + createdAt: cached.createdAt, + firstSeenAt: cached.firstSeenAt ) } } diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift index 1cf2e9d..a14951f 100644 --- a/Jarvis/Views/Home/StoryRowView.swift +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -99,7 +99,11 @@ struct StoryRowView: View { private var metaLine: String { let sources = "\(story.sourceCount) source\(story.sourceCount == 1 ? "" : "s")" - return "\(sources) · \(Topic.label(story.topic)) · \(story.updatedAt.timeAgoShort()) ago" + // firstSeenAt is when the cluster appeared — updatedAt bumps on every + // article the backend attaches, so it can read "just now" on stories + // that are actually weeks old. + let age = (story.firstSeenAt ?? story.updatedAt).timeAgoShort() + return "\(sources) · \(Topic.label(story.topic)) · \(age) ago" } } @@ -117,7 +121,8 @@ struct StoryRowView: View { id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points", summary: "", topic: "finance", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown, sourceCount: 7, sources: sources, consensus: nil, conflict: nil, - updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()), + updatedAt: Date().addingTimeInterval(-3600), createdAt: Date(), + firstSeenAt: Date().addingTimeInterval(-3600)), isCached: score == 64 ) } diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index b883403..aeb723d 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -42,8 +42,15 @@ Status: 🔴 open · 🟡 mitigated client-side (real fix still pending) · 🟢 Bafana exit" for the *same* `story_id`). **Fix: decay freshness from `firstSeenAt` (or the newest article whose relevance to the cluster core is verified), not from "last touched."** Tightening the TF-IDF threshold in #2 should also reduce how often - unrelated articles keep a stale cluster's clock alive. *No client fix possible — the - story object's freshness field itself is wrong before it reaches the app.* + unrelated articles keep a stale cluster's clock alive. Also confirmed the API's own + `isStale` field is unusable as a stopgap — it reads `false` on every story checked + regardless of actual age, so it isn't computed from a real staleness threshold. + *Client mitigation shipped:* age display (row meta line, hero card) now reads from + `firstSeenAt` instead of `updatedAt`, so the UI no longer shows "1 hr ago" on stories + that are actually weeks old. *Ranking is still wrong* — old stories still surface at + the top of the feed, because `signalScore`/`freshnessScore` (used for ordering) are + computed server-side and the client must not re-rank (architecture rule). The + real fix is still backend-side. ## Backend — features