fix: display story age from firstSeenAt, not updatedAt
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user