fix: display story age from firstSeenAt, not updatedAt
All checks were successful
CI / Build · Debug (push) Successful in 22s
CI / Build · Release (push) Successful in 22s

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:
Kutesir
2026-07-12 01:51:35 +03:00
parent fcd4e2cce5
commit afec870919
5 changed files with 31 additions and 7 deletions

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
)
}
}

View File

@@ -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
)
}