Files
jarvis/Jarvis/Models/Models.swift
Kutesir afec870919
All checks were successful
CI / Build · Debug (push) Successful in 22s
CI / Build · Release (push) Successful in 22s
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>
2026-07-12 01:51:35 +03:00

273 lines
6.7 KiB
Swift

// Models.swift
// Jarvis all Codable structs matching the API contract
import Foundation
import SwiftData
// MARK: - Signal Score
struct SignalScore: Codable, Hashable {
let total: Int
let sourceAuthority: Int
let freshness: Int
let localRelevance: Int
let crossSourceConfirmation: Int
let topicImportance: Int
enum CodingKeys: String, CodingKey {
case total = "signalScore"
case sourceAuthority, freshness, localRelevance
case crossSourceConfirmation, topicImportance
}
}
// MARK: - Source
struct StorySource: Codable, Hashable, Identifiable {
let id: String
let name: String
let url: String
let publishedAt: Date
let isBreaking: Bool
}
// MARK: - Timeline Entry
struct TimelineEntry: Codable, Hashable, Identifiable {
let articleId: String
let source: String
let headline: String
let publishedAt: Date
let isBreaking: Bool
var id: String { articleId }
}
// MARK: - Story Summary (list view)
struct StorySummary: Codable, Identifiable, Hashable {
let id: String
let headline: String
let summary: String
let topic: String
/// Canonical category slugs from the backend. Optional so decoding tolerates
/// the transition while the backend rolls out `tags[]`; falls back to `topic`.
let tags: [String]?
let signalScore: Int
let scoreBreakdown: ScoreBreakdown
let sourceCount: Int
let sources: [StorySource]
let consensus: String?
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 {
let sourceAuthority: Int
let freshness: Int
let localRelevance: Int
let crossSourceConfirmation: Int
let topicImportance: Int
}
// MARK: - Story Detail (full, with timeline)
struct StoryDetail: Codable, Identifiable {
let id: String
let headline: String
let summary: String
let topic: String
let signalScore: Int
let scoreBreakdown: ScoreBreakdown
let sourceCount: Int
let consensus: String?
let conflict: String?
let timeline: [TimelineEntry]
let updatedAt: Date
let createdAt: Date
}
// MARK: - Article (full content, for offline cache)
struct Article: Codable, Identifiable {
let id: String
let storyId: String
let source: String
let sourceUrl: String
let headline: String
let body: String
let imageUrl: String?
let author: String?
let publishedAt: Date
}
// MARK: - Feed
struct Feed: Codable, Identifiable {
let id: String
let name: String
let url: String
let health: FeedHealth
let pollIntervalSeconds: Int
let failureCount: Int
let lastFetchedAt: Date?
let articleCountToday: Int
}
enum FeedHealth: String, Codable {
case active, failing, dead
}
// MARK: - Server Health
struct ServerHealth: Codable {
let status: String
let version: String
let storiesCount: Int
let feedsCount: Int
let uptime: Int
}
// MARK: - Paginated Response
struct PaginatedStories: Codable {
let data: [StorySummary]
let nextCursor: String?
let hasMore: Bool
let total: Int
let unreadCount: Int?
let readSuppressedCount: Int?
let lastReadSyncAt: Date?
let activePill: String?
}
// MARK: - WebSocket Events
enum WSEventType: String, Codable {
case storyUpdated = "story.updated"
case storyCreated = "story.created"
case storyStale = "story.stale"
case feedHealth = "feed.health"
case ping, pong
}
struct WSEvent: Codable {
let type: WSEventType
let storyId: String?
let feedId: String?
let signalScore: Int?
let sourceCount: Int?
let headline: String?
let topic: String?
let health: FeedHealth?
let failureCount: Int?
let updatedAt: Date?
let createdAt: Date?
}
// MARK: - SwiftData cached models
@Model
final class CachedStory {
@Attribute(.unique) var id: String
var headline: String
var summary: String
var topic: String
/// Canonical backend tags, so offline pill filtering stays accurate.
/// Defaulted for lightweight migration of existing cached rows.
var tags: [String] = []
var signalScore: Int
var sourceCount: Int
var consensus: String?
var conflict: String?
var updatedAt: Date
/// 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) {
self.id = summary.id
self.headline = summary.headline
self.summary = summary.summary
self.topic = summary.topic
self.tags = summary.tags ?? []
self.signalScore = summary.signalScore
self.sourceCount = summary.sourceCount
self.consensus = summary.consensus
self.conflict = summary.conflict
self.updatedAt = summary.updatedAt
self.createdAt = summary.createdAt
self.firstSeenAt = summary.firstSeenAt ?? summary.updatedAt
self.cachedAt = Date()
}
}
@Model
final class ReadStory {
@Attribute(.unique) var id: String
var readAt: Date
init(id: String) {
self.id = id
self.readAt = Date()
}
}
@Model
final class SavedStory {
@Attribute(.unique) var id: String
var savedAt: Date
init(id: String) {
self.id = id
self.savedAt = Date()
}
}
@Model
final class SeenStory {
@Attribute(.unique) var id: String
var seenAt: Date
init(id: String) {
self.id = id
self.seenAt = Date()
}
}
@Model
final class CachedArticle {
@Attribute(.unique) var id: String
var storyId: String
var source: String
var headline: String
var body: String
var imageUrl: String?
var author: String?
var publishedAt: Date
var cachedAt: Date
init(from article: Article) {
self.id = article.id
self.storyId = article.storyId
self.source = article.source
self.headline = article.headline
self.body = article.body
self.imageUrl = article.imageUrl
self.author = article.author
self.publishedAt = article.publishedAt
self.cachedAt = Date()
}
}