// 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 } 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 } // 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() 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.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() } }