summaryrefslogtreecommitdiff
path: root/Hutch/Models/Todo.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
commit32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch)
treeee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/Models/Todo.swift
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
Diffstat (limited to 'Hutch/Models/Todo.swift')
-rw-r--r--Hutch/Models/Todo.swift234
1 files changed, 234 insertions, 0 deletions
diff --git a/Hutch/Models/Todo.swift b/Hutch/Models/Todo.swift
new file mode 100644
index 0000000..8a2b06c
--- /dev/null
+++ b/Hutch/Models/Todo.swift
@@ -0,0 +1,234 @@
+import Foundation
+
+// MARK: - Enums
+
+/// Status of a ticket.
+enum TicketStatus: String, Codable, Sendable, CaseIterable {
+ case reported = "REPORTED"
+ case confirmed = "CONFIRMED"
+ case inProgress = "IN_PROGRESS"
+ case pending = "PENDING"
+ case resolved = "RESOLVED"
+
+ /// Whether this status represents an open (unresolved) ticket.
+ var isOpen: Bool {
+ switch self {
+ case .reported, .confirmed, .inProgress, .pending: true
+ case .resolved: false
+ }
+ }
+
+ var displayName: String {
+ switch self {
+ case .reported: "Reported"
+ case .confirmed: "Confirmed"
+ case .inProgress: "In Progress"
+ case .pending: "Pending"
+ case .resolved: "Resolved"
+ }
+ }
+}
+
+/// Resolution of a ticket.
+enum TicketResolution: String, Codable, Sendable {
+ case unresolved = "UNRESOLVED"
+ case fixed = "FIXED"
+ case implemented = "IMPLEMENTED"
+ case wontFix = "WONT_FIX"
+ case byDesign = "BY_DESIGN"
+ case invalid = "INVALID"
+ case duplicate = "DUPLICATE"
+ case notOurBug = "NOT_OUR_BUG"
+ case closed = "CLOSED"
+ case notApplicable = "NOT_APPLICABLE"
+
+ init(from decoder: Decoder) throws {
+ let value = try decoder.singleValueContainer().decode(String.self)
+ self = TicketResolution(rawValue: value) ?? .unresolved
+ }
+
+ var displayName: String {
+ switch self {
+ case .unresolved: "Unresolved"
+ case .fixed: "Fixed"
+ case .implemented: "Implemented"
+ case .wontFix: "Won't Fix"
+ case .byDesign: "By Design"
+ case .invalid: "Invalid"
+ case .duplicate: "Duplicate"
+ case .notOurBug: "Not Our Bug"
+ case .closed: "Closed"
+ case .notApplicable: "Not Applicable"
+ }
+ }
+}
+
+/// Authenticity of a ticket or comment.
+enum Authenticity: String, Codable, Sendable {
+ case authentic = "AUTHENTIC"
+ case tampered = "TAMPERED"
+ case unauthenticated = "UNAUTHENTICATED"
+}
+
+// MARK: - Label
+
+/// A label that can be applied to tickets.
+/// Named `TicketLabel` to avoid collision with `SwiftUI.Label`.
+struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let name: String
+ let backgroundColor: String
+ let foregroundColor: String
+}
+
+// MARK: - Tracker
+
+/// A bug tracker from todo.sr.ht.
+struct Tracker: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+
+ static func == (lhs: Tracker, rhs: Tracker) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+// MARK: - Tracker Summary (for list view)
+
+/// Lightweight tracker model matching the fields returned by the trackers list query.
+struct TrackerSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ /// GraphQL resource identifier used by `tracker(id:)` queries.
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+}
+
+// MARK: - Ticket Summary (for list view)
+
+/// Lightweight ticket model for the list query.
+struct TicketSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let title: String
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let created: Date
+ let submitter: Entity
+ let labels: [TicketLabel]
+ let assignees: [Entity]
+
+ static func == (lhs: TicketSummary, rhs: TicketSummary) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+// MARK: - Ticket Detail
+
+/// Full ticket model for the detail view.
+struct TicketDetail: Codable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let title: String
+ let description: String?
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let authenticity: Authenticity
+ let submitter: Entity
+ let assignees: [Entity]
+ let labels: [TicketLabel]
+}
+
+// MARK: - Event
+
+/// A timeline event on a ticket. Each event contains one or more changes.
+struct TicketEvent: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ var changes: [EventChange]
+}
+
+/// A single change within an event, decoded from the polymorphic EventDetail
+/// interface using inline fragments.
+struct EventChange: Codable, Sendable, Identifiable {
+ let id: UUID
+
+ let eventType: String
+
+ // Comment fields
+ let author: Entity?
+ var text: String?
+ let authenticity: Authenticity?
+
+ // StatusChange fields
+ let oldStatus: TicketStatus?
+ let newStatus: TicketStatus?
+
+ // LabelUpdate fields
+ let labeler: Entity?
+ let label: EventLabel?
+
+ // Assignment fields
+ let assigner: Entity?
+ let assignee: Entity?
+
+ // Mention fields
+ // TicketMention: mentioned is a Ticket with { id }
+ // UserMention: mentioned is an Entity with { canonicalName }
+ let mentioned: MentionTarget?
+
+ // Created fields (author is shared with Comment)
+
+ private enum CodingKeys: String, CodingKey {
+ case eventType
+ case author, text, authenticity
+ case oldStatus, newStatus
+ case labeler, label
+ case assigner, assignee
+ case mentioned
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.id = UUID()
+ self.eventType = try container.decode(String.self, forKey: .eventType)
+ self.author = try container.decodeIfPresent(Entity.self, forKey: .author)
+ self.text = try container.decodeIfPresent(String.self, forKey: .text)
+ self.authenticity = try container.decodeIfPresent(Authenticity.self, forKey: .authenticity)
+ self.oldStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .oldStatus)
+ self.newStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .newStatus)
+ self.labeler = try container.decodeIfPresent(Entity.self, forKey: .labeler)
+ self.label = try container.decodeIfPresent(EventLabel.self, forKey: .label)
+ self.assigner = try container.decodeIfPresent(Entity.self, forKey: .assigner)
+ self.assignee = try container.decodeIfPresent(Entity.self, forKey: .assignee)
+ self.mentioned = try container.decodeIfPresent(MentionTarget.self, forKey: .mentioned)
+ }
+}
+
+/// Decoded from the `mentioned` field which may be a Ticket (with `id`) or
+/// an Entity (with `canonicalName`) depending on the event type.
+struct MentionTarget: Codable, Sendable {
+ let id: Int?
+ let canonicalName: String?
+}
+
+/// Label info as returned within a LabelUpdate event change.
+struct EventLabel: Codable, Sendable {
+ let name: String
+}