- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections - Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized - StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch - sectionSupplement background fetch populates sparse regional sections in All digest - SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5) - Theme: removed standalone Uganda pill, wired new sub-section layouts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
6.0 KiB
Swift
259 lines
6.0 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
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|