Code generation pipeline
Live Activity models are auto-generated from backend Zod schemas. This ensures the iOS app and backend always agree on the shape of Live Activity payloads.
Never hand-edit LiveActivityModels.generated.swift. Changes will be overwritten on the next generation run. If the model needs to change, update the Zod schema in the backend and regenerate.
Running the generator
# From the backend project root
npm run generate:schemas
This reads the Zod schemas that define Live Activity payloads and outputs a Swift file with matching Codable structs.
Shared between targets
The generated file is included in both the main app target and the widget target (SightscreenWidget). This guarantees that the widget’s Live Activity rendering uses the exact same types as the main app.
Key model types
Schedule models
| Type | Purpose |
|---|
MatchItem | A single match with pre-formatted display fields (teams, score, status) |
LeagueGroup | A group of matches under a league header |
ScheduleResponse | Top-level response from the schedule API — array of LeagueGroup |
Live Activity models
| Type | Purpose |
|---|
LiveActivityDisplayState | The full display state for all Live Activity presentations |
LiveActivityCompact | Display data for compact leading and trailing views |
LiveActivityExpanded | Display data for the expanded Dynamic Island and Lock Screen |
LiveActivityAttributes | Static data set when the activity is created (match ID, team info) |
ContentState | Dynamic data updated via APNs pushes (scores, overs, events) |
LiveActivityAttributes is set once at creation time and cannot change. ContentState is updated with every push. Design your data split accordingly — anything that changes during a match must be in ContentState.
Auth models
| Type | Purpose |
|---|
AppUser | The authenticated user — includes isAdmin flag derived from JWT groups |
Core models
| Type | Purpose |
|---|
Team | Team identity — name, abbreviation, logo URL |
Match | Full match data from the API |
MatchDisplayState | Pre-formatted display state for a match (computed by the backend) |
Model conventions
All models follow these conventions:
| Convention | Detail |
|---|
Codable | All models conform to Codable for JSON serialization |
Hashable | All models conform to Hashable for use in SwiftUI lists and diffing |
Sendable | All models conform to Sendable for safe use across concurrency boundaries |
struct MatchItem: Codable, Hashable, Sendable {
let matchId: String
let league: String
let homeTeam: Team
let awayTeam: Team
let displayState: MatchDisplayState
let startTime: Date
}
Models use custom initializers to transform API JSON into app-ready types. The API may return data in a different shape than what the app needs internally.
// Example: init(from decoder:) handles API-specific quirks
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.matchId = try container.decode(String.self, forKey: .matchId)
// API sends "start_time" but Swift model uses "startTime"
// Handled by CodingKeys enum mapping
}
Most transformations are handled automatically by CodingKeys enums that map between snake_case API keys and camelCase Swift properties. Complex transformations live in custom init(from:) implementations.
JSON decoding strategy
The app configures a shared JSONDecoder with ISO 8601 date support that handles both fractional seconds and standard formats.
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
// Try ISO 8601 with fractional seconds first
let formatterWithFraction = ISO8601DateFormatter()
formatterWithFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatterWithFraction.date(from: dateString) {
return date
}
// Fall back to standard ISO 8601
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
if let date = formatter.date(from: dateString) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(dateString)")
}
The dual-format decoder exists because some backend responses include fractional seconds (e.g., 2024-03-15T10:30:00.000Z) and others do not (e.g., 2024-03-15T10:30:00Z). Both must decode correctly.