diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index 0e45f773..11855ea6 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -59,6 +59,8 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/` | `PushNotificationPayload` | Backend-only internal push payload carrying required live-vote division fields and the compacted original JSON document. | | `LiveVoteNotification` | Backend-only display-ready push notification value with neutral title/body copy and source division identifiers for APNs delivery. | | `RoyalAssentNotification` | iOS notification content value used when a followed bill transitions to Royal Assent during app or background refresh. | +| `DailyDigest` | A factual once-per-sitting-day summary composed from official Hansard subjects and recorded-division results: date, subject count, attendance estimate, top three subjects, and one optional vote summary. | +| `NotificationPreferences` | The local user's notification preferences value object; epac has no account system, so this carries opt-in flags persisted to UserDefaults. Named `NotificationPreferences` rather than `User` because the chat dependency owns that name in the same module. | | `DispatchResult` | Backend-only push dispatch outcome counts for subscription fan-out, successful deliveries, and failed delivery attempts. | | `LiveParliamentStatus` | A snapshot of whether the House is currently sitting, what business is in progress, and whether a division is active. | | `OnThisDayItem` | A backend-only historical Parliament moment for the same calendar day in prior years. | @@ -108,6 +110,11 @@ to the issue that will build the missing artifact. | `DeviceSubscriptionRepository` | backend Go | outbound | Implemented: `backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go`; adapter: `backend/push-notification-dispatcher/internal/adapter/postgres/subscriptions.go`. | List device subscriptions eligible for the current internal notification fan-out. | | `PushNotificationClient` | backend Go | outbound | Implemented: `backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go`; adapter: `backend/push-notification-dispatcher/internal/adapter/apns/client.go`. | Deliver a typed push payload to a subscribed device through APNs. | | `RoyalAssentNotificationPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift`; adapter: `ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift`. | Schedule notification content when a followed bill is observed to have received Royal Assent. | +| `HansardReadPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/HansardReadPort.swift`; adapter: `ios/epac/Data/Adapters/SwiftDataHansardReadAdapter.swift`. | Read sitting-day, subject count, and top subjects for the daily Parliament digest composition. | +| `VoteReadPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/VoteReadPort.swift`; adapter: `ios/epac/Data/Adapters/SwiftDataVoteReadAdapter.swift`. | Read attendance estimate and the day's recorded vote summary for the daily Parliament digest composition. | +| `DigestNotificationPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/DigestNotificationPort.swift`; adapter: `ios/epac/Data/Adapters/LiveDigestNotificationAdapter.swift`. | Deliver a composed `DailyDigest` to the user as a notification (UNUserNotificationCenter is adapter detail). | +| `UserPreferenceReadPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/UserPreferenceReadPort.swift`; adapter: `ios/epac/Data/Adapters/UserPreferenceAdapter.swift`. | Read the local user's notification preferences (UserDefaults is adapter detail). | +| `DigestDeliveryRecordPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/DigestDeliveryRecordPort.swift`; adapter: `ios/epac/Data/Adapters/UserDefaultsDigestDeliveryRecordAdapter.swift`. | Track whether the daily digest was already delivered on a calendar day so repeated background-refresh wakes inside the digest window don't fire it twice. | | `HansardSearchProviding` | iOS Swift | outbound | Implemented: `ios/epac/Util/HansardSearchService.swift`; conformer: `BackendHansardSearchService`. | Search Hansard through the backend search endpoint from iOS presentation code. | | `HansardSearchRepository` | backend Go | outbound | Implemented: `backend/hansard-search/internal/usecase/search_hansard.go`; adapter: `backend/hansard-search/internal/adapter/sqlitefts5/repository.go`. | Query the verified SQLite FTS5 Hansard search index. | | `ManifestLoader` | backend Go | outbound | Implemented: `backend/hansard-search/internal/usecase/open_search_index.go`; adapter: `backend/hansard-search/internal/adapter/s3manifest/manifest_loader.go`. | Load the current Hansard search-index manifest. | @@ -556,6 +563,41 @@ Current implementation: --- +### SendDailyParliamentDigest + +``` +Actor: iOS background refresh after Hansard ingestion (BGAppRefreshTask), scenePhase .active +Goal: Once per confirmed sitting day, deliver a concise factual digest of the day's debates, attendance, and recorded votes to users who opted in. +Inputs: Today's date, user notification preferences, today's Hansard subjects, today's recorded-division results, last delivery day. +Outputs: A delivered notification (title + body) composed from the official record only; tap deep-links to today's sitting in the Debates calendar. +Entities / values: DailyDigest, DailyDigest.VoteSummary, NotificationPreferences. +Ports: iOS Swift: `HansardReadPort`, `VoteReadPort`, `DigestNotificationPort`, `UserPreferenceReadPort`, `DigestDeliveryRecordPort`. +Primary adapters: SwiftDataHansardReadAdapter, SwiftDataVoteReadAdapter, UserPreferenceAdapter (UserDefaults), LiveDigestNotificationAdapter (UNUserNotificationCenter), UserDefaultsDigestDeliveryRecordAdapter, BackgroundRefreshManager. +Policy boundary: Timing window (default 17:00–23:00 local), sitting-day check, and once-per-day dedupe are use-case policy, not scheduler detail; the start/end hours are parameters of `execute(...)`. +Current implementation: + ios/epac/Application/SendDailyParliamentDigest.swift + ios/epac/Domain/Entities/DailyDigest.swift + ios/epac/Domain/Entities/NotificationPreferences.swift + ios/epac/Domain/Ports/HansardReadPort.swift + ios/epac/Domain/Ports/VoteReadPort.swift + ios/epac/Domain/Ports/DigestNotificationPort.swift + ios/epac/Domain/Ports/UserPreferenceReadPort.swift + ios/epac/Domain/Ports/DigestDeliveryRecordPort.swift + ios/epac/Domain/DailyDigestFormatter.swift + ios/epac/Data/Adapters/SwiftDataHansardReadAdapter.swift + ios/epac/Data/Adapters/SwiftDataVoteReadAdapter.swift + ios/epac/Data/Adapters/UserPreferenceAdapter.swift + ios/epac/Data/Adapters/UserDefaultsDigestDeliveryRecordAdapter.swift + ios/epac/Data/Adapters/LiveDigestNotificationAdapter.swift + ios/epac/Util/BackgroundRefreshManager.swift (composition root: wires use case after Hansard refresh) + ios/epac/Views/Onboarding/OnboardingView.swift (Screen 4 opt-in toggle) + ios/epac/Views/Settings/NotificationsSettingsView.swift (Settings opt-in row) +``` + +> **Boundary note:** UNUserNotificationCenter, UserDefaults, and SwiftData are adapter detail. The use case never imports `UserNotifications`, `SwiftData`, or any UI framework; all displayed text is composed from the typed `DailyDigest` value object by a framework-free formatter, with verbatim authoritative content (no LLM-generated text). Adapters resolve the timing trigger (background refresh or scheduled wake) without leaking scheduler types into the use case. + +--- + ### GetBillDepth ``` diff --git a/ios/epac/Application/SendDailyParliamentDigest.swift b/ios/epac/Application/SendDailyParliamentDigest.swift new file mode 100644 index 00000000..2a89e638 --- /dev/null +++ b/ios/epac/Application/SendDailyParliamentDigest.swift @@ -0,0 +1,86 @@ +// +// SendDailyParliamentDigest.swift +// epac +// + +import Foundation + +/// Composes and delivers the once-per-sitting-day Parliament digest notification +/// for opted-in users. All content traces to the official record (Hansard + +/// recorded divisions); nothing is generated or paraphrased. +/// +/// The timing window (default 17:00–23:00 local) and sitting-day check are +/// expressed as use-case-layer policy rather than scheduler implementation +/// detail, so the same use case can be driven by background refresh, a future +/// push-receipt handler, or a manual debug trigger. +@MainActor +struct SendDailyParliamentDigest { + static let defaultStartHour = 17 + static let defaultEndHour = 23 + static let topSubjectsLimit = 3 + private static let minutesPerHour = 60 + + private let hansardReadPort: any HansardReadPort + private let voteReadPort: any VoteReadPort + private let digestNotificationPort: any DigestNotificationPort + private let userPreferenceReadPort: any UserPreferenceReadPort + private let deliveryRecordPort: any DigestDeliveryRecordPort + private let clock: any Clock + private let calendar: Calendar + + init( + hansardReadPort: any HansardReadPort, + voteReadPort: any VoteReadPort, + digestNotificationPort: any DigestNotificationPort, + userPreferenceReadPort: any UserPreferenceReadPort, + deliveryRecordPort: any DigestDeliveryRecordPort, + clock: any Clock = SystemClock(), + calendar: Calendar = .current + ) { + self.hansardReadPort = hansardReadPort + self.voteReadPort = voteReadPort + self.digestNotificationPort = digestNotificationPort + self.userPreferenceReadPort = userPreferenceReadPort + self.deliveryRecordPort = deliveryRecordPort + self.clock = clock + self.calendar = calendar + } + + func execute( + startHour: Int = Self.defaultStartHour, + endHour: Int = Self.defaultEndHour + ) async throws { + let preferences = try await userPreferenceReadPort.loadPreferences() + guard preferences.dailyDigestOptIn else { return } + + let now = clock.now + let components = calendar.dateComponents([.hour, .minute], from: now) + let minutesSinceMidnight = (components.hour ?? 0) * Self.minutesPerHour + (components.minute ?? 0) + let windowStart = startHour * Self.minutesPerHour + let windowEnd = endHour * Self.minutesPerHour + guard minutesSinceMidnight >= windowStart, minutesSinceMidnight <= windowEnd else { return } + + guard try await hansardReadPort.isSittingDay(now) else { return } + + if try await deliveryRecordPort.wasDelivered(on: now) { return } + + let subjectCount = try await hansardReadPort.fetchSubjectsCount(for: now) + let topSubjects = try await hansardReadPort.fetchTopSubjects( + for: now, + limit: Self.topSubjectsLimit + ) + let attendance = try await voteReadPort.fetchAttendanceEstimate(for: now) + let vote = try await voteReadPort.fetchVoteSummary(for: now) + + let digest = DailyDigest( + date: now, + subjectCount: subjectCount, + attendanceEstimate: attendance, + topSubjects: topSubjects, + vote: vote + ) + + try await digestNotificationPort.send(digest) + try await deliveryRecordPort.recordDelivered(on: now) + } +} diff --git a/ios/epac/Data/Adapters/LiveDigestNotificationAdapter.swift b/ios/epac/Data/Adapters/LiveDigestNotificationAdapter.swift new file mode 100644 index 00000000..1b6829fe --- /dev/null +++ b/ios/epac/Data/Adapters/LiveDigestNotificationAdapter.swift @@ -0,0 +1,69 @@ +// +// LiveDigestNotificationAdapter.swift +// epac +// +// Formats a DailyDigest into a local notification using UNUserNotificationCenter. +// Format spec (EPAC-917): +// Title: "Parliament sat today · " +// Body: " debates · % of MPs present · , , " +// "1 recorded vote: " (only if vote != nil) +// All facts come from the authoritative DailyDigest value; no generated text. +// +// userInfo carries the sitting date (ISO-8601) and a notification kind so the +// AppDelegate's UNUserNotificationCenter delegate can route a tap to the +// corresponding Hansard sitting in the Debates calendar. +// + +import Foundation +import UserNotifications + +/// Constants used by both the notification adapter and the UNUserNotificationCenter +/// delegate that handles taps. Defined outside the `@MainActor` struct so the +/// delegate (nonisolated) can read them without crossing actor boundaries. +enum DailyDigestNotificationPayload { + static let categoryIdentifier = "epac.notifications.dailyDigest" + static let userInfoKindKey = "epac.notification.kind" + static let userInfoDateKey = "epac.notification.date" + static let userInfoKindValue = "dailyDigest" +} + +@MainActor +struct LiveDigestNotificationAdapter: DigestNotificationPort { + private static let immediateTriggerDelay: TimeInterval = 1 + + private static let userInfoDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + return formatter + }() + + func send(_ digest: DailyDigest) async throws { + let center = UNUserNotificationCenter.current() + + let settings = await center.notificationSettings() + if settings.authorizationStatus == .notDetermined { + _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) + } + + let content = UNMutableNotificationContent() + content.title = DailyDigestFormatter.title(for: digest.date) + content.body = DailyDigestFormatter.body(for: digest) + content.sound = .default + content.categoryIdentifier = DailyDigestNotificationPayload.categoryIdentifier + content.userInfo = [ + DailyDigestNotificationPayload.userInfoKindKey: DailyDigestNotificationPayload.userInfoKindValue, + DailyDigestNotificationPayload.userInfoDateKey: Self.userInfoDateFormatter.string(from: digest.date) + ] + + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: Self.immediateTriggerDelay, + repeats: false + ) + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: trigger + ) + try await center.add(request) + } +} diff --git a/ios/epac/Data/Adapters/SwiftDataHansardReadAdapter.swift b/ios/epac/Data/Adapters/SwiftDataHansardReadAdapter.swift new file mode 100644 index 00000000..6ba1f523 --- /dev/null +++ b/ios/epac/Data/Adapters/SwiftDataHansardReadAdapter.swift @@ -0,0 +1,84 @@ +// +// SwiftDataHansardReadAdapter.swift +// epac +// +// Adapter for `HansardReadPort` backed by the local SwiftData store. +// Treats a date as a *confirmed* sitting day only when a Hansard record exists +// for it — i.e. the official record was published, not merely scheduled. +// + +import Foundation +import SwiftData + +@MainActor +struct SwiftDataHansardReadAdapter: HansardReadPort { + private let modelContext: ModelContext + private let calendar: Calendar + + init(modelContext: ModelContext, calendar: Calendar = .current) { + self.modelContext = modelContext + self.calendar = calendar + } + + func isSittingDay(_ date: Date) async throws -> Bool { + try hansard(for: date) != nil + } + + func fetchSubjectsCount(for date: Date) async throws -> Int { + guard let hansard = try hansard(for: date) else { return 0 } + var seen: Set = [] + for order in hansard.orders { + for subject in order.subjects { + seen.insert(subject.title) + } + } + return seen.count + } + + func fetchTopSubjects(for date: Date, limit: Int) async throws -> [String] { + guard limit > 0, let hansard = try hansard(for: date) else { return [] } + let stats = subjectStats(from: hansard) + let ranked = stats.keys.sorted { lhs, rhs in + let lhsStats = stats[lhs] + let rhsStats = stats[rhs] + if lhsStats?.speechCount != rhsStats?.speechCount { + return (lhsStats?.speechCount ?? 0) > (rhsStats?.speechCount ?? 0) + } + return (lhsStats?.firstAppearance ?? .max) < (rhsStats?.firstAppearance ?? .max) + } + return Array(ranked.prefix(limit)) + } + + private struct SubjectStat { + var speechCount: Int + let firstAppearance: Int + } + + private func subjectStats(from hansard: Hansard) -> [String: SubjectStat] { + var stats: [String: SubjectStat] = [:] + var index = 0 + for order in hansard.orders { + for subject in order.subjects { + let title = subject.title + guard !title.isEmpty else { continue } + if var existing = stats[title] { + existing.speechCount += subject.speeches.count + stats[title] = existing + } else { + stats[title] = SubjectStat(speechCount: subject.speeches.count, firstAppearance: index) + } + index += 1 + } + } + return stats + } + + private func hansard(for date: Date) throws -> Hansard? { + let day = calendar.startOfDay(for: date) + let next = calendar.date(byAdding: .day, value: 1, to: day) ?? day + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date >= day && $0.date < next } + ) + return try modelContext.fetch(descriptor).first + } +} diff --git a/ios/epac/Data/Adapters/SwiftDataVoteReadAdapter.swift b/ios/epac/Data/Adapters/SwiftDataVoteReadAdapter.swift new file mode 100644 index 00000000..9b440c1f --- /dev/null +++ b/ios/epac/Data/Adapters/SwiftDataVoteReadAdapter.swift @@ -0,0 +1,62 @@ +// +// SwiftDataVoteReadAdapter.swift +// epac +// +// Adapter for `VoteReadPort` backed by the local SwiftData store. Reads the +// federal recorded divisions held on a date to estimate attendance and +// surface the day's top vote summary. +// + +import Foundation +import SwiftData + +@MainActor +struct SwiftDataVoteReadAdapter: VoteReadPort { + static let houseOfCommonsSeats = 338 + + private let modelContext: ModelContext + private let calendar: Calendar + private let totalSeats: Int + + init( + modelContext: ModelContext, + calendar: Calendar = .current, + totalSeats: Int = SwiftDataVoteReadAdapter.houseOfCommonsSeats + ) { + self.modelContext = modelContext + self.calendar = calendar + self.totalSeats = totalSeats + } + + func fetchAttendanceEstimate(for date: Date) async throws -> Double? { + let votes = try federalVotes(for: date) + guard !votes.isEmpty, totalSeats > 0 else { return nil } + let participation = votes.map { Double($0.yea + $0.nay + $0.paired) / Double(totalSeats) } + let average = participation.reduce(0, +) / Double(participation.count) + return min(max(average, 0), 1) + } + + func fetchVoteSummary(for date: Date) async throws -> DailyDigest.VoteSummary? { + let votes = try federalVotes(for: date) + guard let vote = votes.first else { return nil } + let passed = vote.resultEn.lowercased().contains("agreed") + let billName = vote.billNumberCode.isEmpty ? vote.descriptionEn : vote.billNumberCode + return DailyDigest.VoteSummary( + billName: billName, + passed: passed, + yeas: vote.yea, + nays: vote.nay + ) + } + + private func federalVotes(for date: Date) throws -> [RecordedVote] { + let day = calendar.startOfDay(for: date) + let next = calendar.date(byAdding: .day, value: 1, to: day) ?? day + let federal = Jurisdiction.federal.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.jurisdiction == federal && $0.date >= day && $0.date < next }, + sortBy: [SortDescriptor(\.number)] + ) + return try modelContext.fetch(descriptor) + } +} diff --git a/ios/epac/Data/Adapters/UserDefaultsDigestDeliveryRecordAdapter.swift b/ios/epac/Data/Adapters/UserDefaultsDigestDeliveryRecordAdapter.swift new file mode 100644 index 00000000..3790246a --- /dev/null +++ b/ios/epac/Data/Adapters/UserDefaultsDigestDeliveryRecordAdapter.swift @@ -0,0 +1,35 @@ +// +// UserDefaultsDigestDeliveryRecordAdapter.swift +// epac +// +// Persists the calendar day on which the daily Parliament digest was last +// delivered, so background refresh waking us multiple times within the +// digest window cannot fire the same notification twice. +// + +import Foundation + +@MainActor +struct UserDefaultsDigestDeliveryRecordAdapter: DigestDeliveryRecordPort { + static let lastDeliveredDayKey = "epac.notifications.dailyDigest.lastDeliveredDay" + + private let defaults: UserDefaults + private let calendar: Calendar + + init(defaults: UserDefaults = .standard, calendar: Calendar = .current) { + self.defaults = defaults + self.calendar = calendar + } + + func wasDelivered(on date: Date) async throws -> Bool { + guard let stored = defaults.object(forKey: Self.lastDeliveredDayKey) as? Date else { + return false + } + return calendar.isDate(stored, inSameDayAs: date) + } + + func recordDelivered(on date: Date) async throws { + let dayStart = calendar.startOfDay(for: date) + defaults.set(dayStart, forKey: Self.lastDeliveredDayKey) + } +} diff --git a/ios/epac/Data/Adapters/UserPreferenceAdapter.swift b/ios/epac/Data/Adapters/UserPreferenceAdapter.swift new file mode 100644 index 00000000..3fa47e2b --- /dev/null +++ b/ios/epac/Data/Adapters/UserPreferenceAdapter.swift @@ -0,0 +1,23 @@ +// +// UserPreferenceAdapter.swift +// epac +// +// UserDefaults-backed adapter for the local user's notification preferences. +// + +import Foundation + +@MainActor +struct UserPreferenceAdapter: UserPreferenceReadPort { + static let dailyDigestEnabledKey = "epac.notifications.dailyDigestEnabled" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func loadPreferences() async throws -> NotificationPreferences { + NotificationPreferences(dailyDigestOptIn: defaults.bool(forKey: Self.dailyDigestEnabledKey)) + } +} diff --git a/ios/epac/Domain/DailyDigestFormatter.swift b/ios/epac/Domain/DailyDigestFormatter.swift new file mode 100644 index 00000000..b705db03 --- /dev/null +++ b/ios/epac/Domain/DailyDigestFormatter.swift @@ -0,0 +1,76 @@ +// +// DailyDigestFormatter.swift +// epac +// +// Pure, framework-free composer for the title and body of the daily Parliament +// digest notification. Extracted from the notification adapter so it can be +// unit-tested without UNUserNotificationCenter. +// + +import Foundation + +enum DailyDigestFormatter { + private static let titleDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + return formatter + }() + + static func title(for date: Date) -> String { + String( + format: NSLocalizedString("dailyDigest.notification.title", comment: ""), + titleDateFormatter.string(from: date) + ) + } + + static func body(for digest: DailyDigest) -> String { + var lines: [String] = [] + lines.append(headlineLine(for: digest)) + if let vote = digest.vote { + lines.append(voteLine(for: vote)) + } + return lines.joined(separator: "\n") + } + + // MARK: - Private + + private static func headlineLine(for digest: DailyDigest) -> String { + let debates = String( + format: NSLocalizedString("dailyDigest.notification.debates", comment: ""), + digest.subjectCount + ) + + var pieces = [debates] + + if let share = digest.attendanceEstimate { + let percent = Int((share * 100).rounded()) + pieces.append( + String( + format: NSLocalizedString("dailyDigest.notification.attendance", comment: ""), + percent + ) + ) + } + + if !digest.topSubjects.isEmpty { + pieces.append(digest.topSubjects.joined(separator: ", ")) + } + + return pieces.joined(separator: " · ") + } + + private static func voteLine(for vote: DailyDigest.VoteSummary) -> String { + let outcomeKey = vote.passed + ? "dailyDigest.notification.vote.passed" + : "dailyDigest.notification.vote.defeated" + let outcome = NSLocalizedString(outcomeKey, comment: "") + return String( + format: NSLocalizedString("dailyDigest.notification.vote.line", comment: ""), + vote.billName, + outcome, + vote.yeas, + vote.nays + ) + } +} diff --git a/ios/epac/Domain/Entities/DailyDigest.swift b/ios/epac/Domain/Entities/DailyDigest.swift new file mode 100644 index 00000000..e395ac0f --- /dev/null +++ b/ios/epac/Domain/Entities/DailyDigest.swift @@ -0,0 +1,23 @@ +// +// DailyDigest.swift +// epac +// + +import Foundation + +/// Factual summary of one sitting day, composed from official Hansard and recorded-vote data. +/// All fields trace to authoritative sources; nothing here is generated or paraphrased. +struct DailyDigest: Equatable, Sendable { + let date: Date + let subjectCount: Int + let attendanceEstimate: Double? + let topSubjects: [String] + let vote: VoteSummary? + + struct VoteSummary: Equatable, Sendable { + let billName: String + let passed: Bool + let yeas: Int + let nays: Int + } +} diff --git a/ios/epac/Domain/Entities/NotificationPreferences.swift b/ios/epac/Domain/Entities/NotificationPreferences.swift new file mode 100644 index 00000000..63767daa --- /dev/null +++ b/ios/epac/Domain/Entities/NotificationPreferences.swift @@ -0,0 +1,18 @@ +// +// NotificationPreferences.swift +// epac +// + +import Foundation + +/// In-app representation of the local user's notification preferences. +/// epac has no account system; this is a value object held by adapters that +/// persist preferences locally. +/// +/// Named `NotificationPreferences` rather than `User` because the iOS chat +/// dependency `ExyteChat` exports its own `User` type and the use case sits +/// in the same module — using a domain-specific name keeps both addressable +/// from any file in the app without disambiguation. +struct NotificationPreferences: Equatable, Sendable { + let dailyDigestOptIn: Bool +} diff --git a/ios/epac/Domain/Ports/DigestDeliveryRecordPort.swift b/ios/epac/Domain/Ports/DigestDeliveryRecordPort.swift new file mode 100644 index 00000000..e260f1cf --- /dev/null +++ b/ios/epac/Domain/Ports/DigestDeliveryRecordPort.swift @@ -0,0 +1,16 @@ +// +// DigestDeliveryRecordPort.swift +// epac +// + +import Foundation + +/// Records and queries whether the daily Parliament digest was already +/// delivered on a given calendar day. Lets the use case avoid firing twice +/// when background refresh wakes the app multiple times within the digest +/// window. +@MainActor +protocol DigestDeliveryRecordPort: Sendable { + func wasDelivered(on date: Date) async throws -> Bool + func recordDelivered(on date: Date) async throws +} diff --git a/ios/epac/Domain/Ports/DigestNotificationPort.swift b/ios/epac/Domain/Ports/DigestNotificationPort.swift new file mode 100644 index 00000000..eb91e9a6 --- /dev/null +++ b/ios/epac/Domain/Ports/DigestNotificationPort.swift @@ -0,0 +1,14 @@ +// +// DigestNotificationPort.swift +// epac +// + +import Foundation + +/// Delivers a composed `DailyDigest` to the user as a notification. +/// Implementations format the digest into title/body text and hand it +/// to the platform's notification surface (e.g. UNUserNotificationCenter). +@MainActor +protocol DigestNotificationPort: Sendable { + func send(_ digest: DailyDigest) async throws +} diff --git a/ios/epac/Domain/Ports/HansardReadPort.swift b/ios/epac/Domain/Ports/HansardReadPort.swift new file mode 100644 index 00000000..dc922f52 --- /dev/null +++ b/ios/epac/Domain/Ports/HansardReadPort.swift @@ -0,0 +1,16 @@ +// +// HansardReadPort.swift +// epac +// + +import Foundation + +/// Reads sitting-day facts the daily digest needs from Hansard. +/// Adapters resolve this from the backend's daily-Hansard endpoint or +/// from the locally cached SwiftData `Hansard` model. +@MainActor +protocol HansardReadPort: Sendable { + func isSittingDay(_ date: Date) async throws -> Bool + func fetchSubjectsCount(for date: Date) async throws -> Int + func fetchTopSubjects(for date: Date, limit: Int) async throws -> [String] +} diff --git a/ios/epac/Domain/Ports/UserPreferenceReadPort.swift b/ios/epac/Domain/Ports/UserPreferenceReadPort.swift new file mode 100644 index 00000000..5035516b --- /dev/null +++ b/ios/epac/Domain/Ports/UserPreferenceReadPort.swift @@ -0,0 +1,12 @@ +// +// UserPreferenceReadPort.swift +// epac +// + +import Foundation + +/// Reads the local user's notification preferences. +@MainActor +protocol UserPreferenceReadPort: Sendable { + func loadPreferences() async throws -> NotificationPreferences +} diff --git a/ios/epac/Domain/Ports/VoteReadPort.swift b/ios/epac/Domain/Ports/VoteReadPort.swift new file mode 100644 index 00000000..ae875041 --- /dev/null +++ b/ios/epac/Domain/Ports/VoteReadPort.swift @@ -0,0 +1,16 @@ +// +// VoteReadPort.swift +// epac +// + +import Foundation + +/// Reads attendance and recorded-vote facts the daily digest needs. +/// Attendance is approximated from the day's recorded division counts +/// (yeas + nays + paired + absent) where available, since no separate +/// roll-call list is published. +@MainActor +protocol VoteReadPort: Sendable { + func fetchAttendanceEstimate(for date: Date) async throws -> Double? + func fetchVoteSummary(for date: Date) async throws -> DailyDigest.VoteSummary? +} diff --git a/ios/epac/Model/Fetch.swift b/ios/epac/Model/Fetch.swift index 5c460e00..f7505a6b 100644 --- a/ios/epac/Model/Fetch.swift +++ b/ios/epac/Model/Fetch.swift @@ -154,6 +154,7 @@ actor Fetch: ModelActor, ObservableObject { try? await downloadFiscalMonitorEntries() try? loadCabinetPositions() try? loadMinisterialExpenses() + try? await downloadHansard(Date()) if let bills = try? await BillsService.fetchBills() { await BillFollowStore.shared.updateStoredState(in: bills) } diff --git a/ios/epac/Util/BackgroundRefreshManager.swift b/ios/epac/Util/BackgroundRefreshManager.swift index 949b950d..5cf5682c 100644 --- a/ios/epac/Util/BackgroundRefreshManager.swift +++ b/ios/epac/Util/BackgroundRefreshManager.swift @@ -41,6 +41,7 @@ final class BackgroundRefreshManager { let refreshTask = Task { let fetch = Fetch(modelContainer: container) await fetch.backgroundRefresh() + await sendDailyDigestIfEligible(container: container) Log.debug("BackgroundRefreshManager: refresh complete") } @@ -53,4 +54,20 @@ final class BackgroundRefreshManager { task.setTaskCompleted(success: !refreshTask.isCancelled) } } + + func sendDailyDigestIfEligible(container: ModelContainer) async { + let context = ModelContext(container) + let useCase = SendDailyParliamentDigest( + hansardReadPort: SwiftDataHansardReadAdapter(modelContext: context), + voteReadPort: SwiftDataVoteReadAdapter(modelContext: context), + digestNotificationPort: LiveDigestNotificationAdapter(), + userPreferenceReadPort: UserPreferenceAdapter(), + deliveryRecordPort: UserDefaultsDigestDeliveryRecordAdapter() + ) + do { + try await useCase.execute() + } catch { + Log.warning("Daily digest use case failed: \(error.localizedDescription)") + } + } } diff --git a/ios/epac/Util/QuickActionHandler.swift b/ios/epac/Util/QuickActionHandler.swift index ebb0b20d..8618fbfb 100644 --- a/ios/epac/Util/QuickActionHandler.swift +++ b/ios/epac/Util/QuickActionHandler.swift @@ -13,6 +13,7 @@ // import UIKit +import UserNotifications @MainActor extension AppDelegate { @@ -20,6 +21,7 @@ extension AppDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + UNUserNotificationCenter.current().delegate = self if let shortcutItem = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem, let action = QuickAction(rawValue: shortcutItem.type) { // Router not wired yet; stash for delivery once ContentView appears. diff --git a/ios/epac/Views/Onboarding/OnboardingView.swift b/ios/epac/Views/Onboarding/OnboardingView.swift index 3990faa5..749878a9 100644 --- a/ios/epac/Views/Onboarding/OnboardingView.swift +++ b/ios/epac/Views/Onboarding/OnboardingView.swift @@ -53,6 +53,7 @@ struct OnboardingView: View { @State private var page = OnboardingLayout.welcomePage @State private var postalCodeVM = PostalCodeViewModel() @State private var selectedTopics: Set = [] + @State private var dailyDigestOptIn = false @Environment(\.modelContext) private var modelContext private let totalPages = OnboardingLayout.totalPages @@ -370,7 +371,10 @@ struct OnboardingView: View { } .padding(.horizontal, EpacSpacing.l) .padding(.vertical, EpacSpacing.l) - .padding(.bottom, OnboardingLayout.topicsGridBottomPadding) + + dailyDigestOptInRow + .padding(.horizontal, EpacSpacing.l) + .padding(.bottom, OnboardingLayout.topicsGridBottomPadding) } ContinueButton(label: selectedTopics.isEmpty @@ -379,9 +383,9 @@ struct OnboardingView: View { selectedTopics.count)) { if !selectedTopics.isEmpty { FollowTopic.live().execute(topicIDs: selectedTopics) - Log.info("onboarding.step.3.completed topicsFollowed=\(selectedTopics.count)") + Log.info("onboarding.step.3.completed topicsFollowed=\(selectedTopics.count) dailyDigest=\(dailyDigestOptIn)") } else { - Log.info("onboarding.step.3.skipped") + Log.info("onboarding.step.3.skipped dailyDigest=\(dailyDigestOptIn)") } advance() } @@ -390,6 +394,20 @@ struct OnboardingView: View { } } + private var dailyDigestOptInRow: some View { + Toggle(isOn: $dailyDigestOptIn) { + VStack(alignment: .leading, spacing: EpacSpacing.xs) { + Text(NSLocalizedString("onboarding.topics.dailyDigest", comment: "")) + .font(.epacBody.weight(.medium)) + Text(NSLocalizedString("onboarding.topics.dailyDigest.description", comment: "")) + .font(.epacCaption) + .foregroundStyle(Color.epacText.secondary) + } + } + .tint(Color.epacBrand.accent) + .accessibilityIdentifier("onboarding.topics.dailyDigest.toggle") + } + // MARK: - Navigation helpers private func advance() { @@ -401,8 +419,10 @@ struct OnboardingView: View { } private func complete() { - Log.info("onboarding.completed") - UserDefaults.standard.set(true, forKey: "epac.onboarding.completed") + Log.info("onboarding.completed dailyDigest=\(dailyDigestOptIn)") + let defaults = UserDefaults.standard + defaults.set(dailyDigestOptIn, forKey: UserPreferenceAdapter.dailyDigestEnabledKey) + defaults.set(true, forKey: "epac.onboarding.completed") onComplete() } } diff --git a/ios/epac/Views/Settings/NotificationsSettingsView.swift b/ios/epac/Views/Settings/NotificationsSettingsView.swift new file mode 100644 index 00000000..2cc442c2 --- /dev/null +++ b/ios/epac/Views/Settings/NotificationsSettingsView.swift @@ -0,0 +1,32 @@ +// +// NotificationsSettingsView.swift +// epac +// + +import SwiftUI + +@MainActor +struct NotificationsSettingsView: View { + @AppStorage(UserPreferenceAdapter.dailyDigestEnabledKey) + private var dailyDigestEnabled = false + + var body: some View { + List { + Section { + Toggle(isOn: $dailyDigestEnabled) { + Text(NSLocalizedString("settings.notifications.dailyDigest", comment: "")) + .font(.epacBody.weight(.medium)) + } + .tint(Color.epacBrand.accent) + .accessibilityIdentifier("settings.notifications.dailyDigest.toggle") + } footer: { + Text(NSLocalizedString("settings.notifications.dailyDigest.description", comment: "")) + .font(.epacCaption) + .foregroundStyle(Color.epacText.secondary) + } + } + .listStyle(.insetGrouped) + .navigationTitle(NSLocalizedString("settings.notifications.title", comment: "")) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/ios/epac/Views/Settings/SettingsView.swift b/ios/epac/Views/Settings/SettingsView.swift index 76033102..fa324a99 100644 --- a/ios/epac/Views/Settings/SettingsView.swift +++ b/ios/epac/Views/Settings/SettingsView.swift @@ -21,6 +21,7 @@ struct SettingsView: View { accountSection appearanceSection languageSection + notificationsSection followsSection dataPrivacySection aboutSection @@ -142,6 +143,19 @@ struct SettingsView: View { } } + // MARK: - Notifications + + private var notificationsSection: some View { + Section(NSLocalizedString("settings.notifications.title", comment: "")) { + NavigationLink(destination: NotificationsSettingsView()) { + Label( + NSLocalizedString("settings.notifications.manage", comment: ""), + systemImage: "bell.badge" + ) + } + } + } + // MARK: - Follows private var followsSection: some View { diff --git a/ios/epac/en.lproj/Localizable.strings b/ios/epac/en.lproj/Localizable.strings index 3f59b4df..2f50ceec 100644 --- a/ios/epac/en.lproj/Localizable.strings +++ b/ios/epac/en.lproj/Localizable.strings @@ -597,6 +597,8 @@ "onboarding.topics.title" = "What matters to you?"; "onboarding.topics.subtitle" = "Follow up to 4 topics to personalize your Home feed."; "onboarding.topics.follow" = "Follow %d topic(s) →"; +"onboarding.topics.dailyDigest" = "Daily Parliament summary"; +"onboarding.topics.dailyDigest.description" = "Sitting days only. A short factual recap of debates and votes when Hansard is published, drawn from the official record."; "onboarding.promise.title" = "You're set up"; "onboarding.promise.subtitle" = "Parliament is in session. Here's what you'll get."; @@ -645,6 +647,19 @@ "settings.followed.members" = "Followed MPs"; "settings.followed.topics" = "Followed Topics"; +"settings.notifications.title" = "Notifications"; +"settings.notifications.manage" = "Manage notifications"; +"settings.notifications.dailyDigest" = "Daily Parliament summary"; +"settings.notifications.dailyDigest.description" = "A concise factual recap of debates and votes on each sitting day when Hansard is published. Off by default."; + +/* Daily Parliament digest notification (EPAC-917) */ +"dailyDigest.notification.title" = "Parliament sat today · %@"; +"dailyDigest.notification.debates" = "%d debates"; +"dailyDigest.notification.attendance" = "%d%% of MPs present"; +"dailyDigest.notification.vote.line" = "1 recorded vote: %1$@ %2$@ %3$d–%4$d"; +"dailyDigest.notification.vote.passed" = "passed"; +"dailyDigest.notification.vote.defeated" = "defeated"; + "settings.privacy.title" = "Data & Privacy"; "settings.privacy.policy" = "Privacy Policy"; "settings.privacy.dataHandling" = "Data handling"; diff --git a/ios/epac/epacApp.swift b/ios/epac/epacApp.swift index 431dda26..e83a1307 100644 --- a/ios/epac/epacApp.swift +++ b/ios/epac/epacApp.swift @@ -9,6 +9,7 @@ import BackgroundTasks import SwiftData import SwiftUI import UIKit +import UserNotifications enum AppRuntime { static let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil @@ -46,7 +47,7 @@ private enum UITestFreshState { } @MainActor -class AppDelegate: NSObject, UIApplicationDelegate { +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { /// Injected by ContentView once it has created the router. var router: NavigationRouter? { didSet { @@ -58,6 +59,32 @@ class AppDelegate: NSObject, UIApplicationDelegate { } var coldLaunchAction: QuickAction? + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + let kind = userInfo[DailyDigestNotificationPayload.userInfoKindKey] as? String + let dateString = userInfo[DailyDigestNotificationPayload.userInfoDateKey] as? String + if kind == DailyDigestNotificationPayload.userInfoKindValue, + let dateString, + let url = URL(string: "cabinetdoor://sitting/\(dateString)") { + Task { @MainActor in + await UIApplication.shared.open(url) + } + } + completionHandler() + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } } @main @@ -175,6 +202,9 @@ struct epacApp: App { BackgroundRefreshManager.shared.modelContainer = sharedModelContainer ReviewRequestManager.shared.recordAppOpen() BackgroundRefreshManager.shared.scheduleRefresh() + Task { @MainActor in + await BackgroundRefreshManager.shared.sendDailyDigestIfEligible(container: sharedModelContainer) + } } } #if targetEnvironment(macCatalyst) diff --git a/ios/epac/fr.lproj/Localizable.strings b/ios/epac/fr.lproj/Localizable.strings index 53b2078a..a2013714 100644 --- a/ios/epac/fr.lproj/Localizable.strings +++ b/ios/epac/fr.lproj/Localizable.strings @@ -599,6 +599,8 @@ "onboarding.topics.title" = "Ce qui vous importe"; "onboarding.topics.subtitle" = "Suivez jusqu'à 4 sujets pour personnaliser votre accueil."; "onboarding.topics.follow" = "Suivre %d sujet(s) →"; +"onboarding.topics.dailyDigest" = "Sommaire quotidien du Parlement"; +"onboarding.topics.dailyDigest.description" = "Jours de séance seulement. Un résumé factuel des débats et des votes lorsque le hansard est publié, à partir du dossier officiel."; "onboarding.promise.title" = "Vous êtes prêt"; "onboarding.promise.subtitle" = "Le Parlement siège. Voici ce que vous obtiendrez."; @@ -647,6 +649,19 @@ "settings.followed.members" = "Députés suivis"; "settings.followed.topics" = "Sujets suivis"; +"settings.notifications.title" = "Notifications"; +"settings.notifications.manage" = "Gérer les notifications"; +"settings.notifications.dailyDigest" = "Sommaire quotidien du Parlement"; +"settings.notifications.dailyDigest.description" = "Un résumé factuel des débats et des votes chaque jour de séance lorsque le hansard est publié. Désactivé par défaut."; + +/* Notification Sommaire quotidien (EPAC-917) */ +"dailyDigest.notification.title" = "Le Parlement a siégé aujourd'hui · %@"; +"dailyDigest.notification.debates" = "%d débats"; +"dailyDigest.notification.attendance" = "%d %% des députés présents"; +"dailyDigest.notification.vote.line" = "1 vote enregistré : %1$@ %2$@ %3$d–%4$d"; +"dailyDigest.notification.vote.passed" = "adopté"; +"dailyDigest.notification.vote.defeated" = "rejeté"; + "settings.privacy.title" = "Données et confidentialité"; "settings.privacy.policy" = "Politique de confidentialité"; "settings.privacy.dataHandling" = "Traitement des données"; diff --git a/ios/epacTests/Application/SendDailyParliamentDigestTests.swift b/ios/epacTests/Application/SendDailyParliamentDigestTests.swift new file mode 100644 index 00000000..2f6ae29e --- /dev/null +++ b/ios/epacTests/Application/SendDailyParliamentDigestTests.swift @@ -0,0 +1,289 @@ +// +// SendDailyParliamentDigestTests.swift +// epacTests +// + +@testable import epac +import Foundation +import Testing + +@MainActor +struct SendDailyParliamentDigestTests { + + @Test func skipsWhenUserHasNotOptedIn() async throws { + let notifier = DigestNotificationSpy() + let useCase = SendDailyParliamentDigest( + hansardReadPort: HansardReadStub(isSittingDay: true, subjectCount: 9, topSubjects: ["A", "B", "C"]), + voteReadPort: VoteReadStub(attendance: 0.92, voteSummary: nil), + digestNotificationPort: notifier, + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: false)), + deliveryRecordPort: DeliveryRecordSpy(), + clock: FixedClock(now: Self.localDate(hour: 19)), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(notifier.sentDigests.isEmpty) + } + + @Test func skipsBefore5PM() async throws { + let notifier = DigestNotificationSpy() + let useCase = SendDailyParliamentDigest( + hansardReadPort: HansardReadStub(isSittingDay: true, subjectCount: 9, topSubjects: ["A"]), + voteReadPort: VoteReadStub(attendance: 0.85, voteSummary: nil), + digestNotificationPort: notifier, + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: true)), + deliveryRecordPort: DeliveryRecordSpy(), + clock: FixedClock(now: Self.localDate(hour: 16, minute: 59)), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(notifier.sentDigests.isEmpty) + } + + @Test func skipsAfter11PM() async throws { + let notifier = DigestNotificationSpy() + let useCase = SendDailyParliamentDigest( + hansardReadPort: HansardReadStub(isSittingDay: true, subjectCount: 9, topSubjects: ["A"]), + voteReadPort: VoteReadStub(attendance: 0.85, voteSummary: nil), + digestNotificationPort: notifier, + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: true)), + deliveryRecordPort: DeliveryRecordSpy(), + clock: FixedClock(now: Self.localDate(hour: 23, minute: 59)), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(notifier.sentDigests.isEmpty) + } + + @Test func skipsOnNonSittingDay() async throws { + let notifier = DigestNotificationSpy() + let useCase = SendDailyParliamentDigest( + hansardReadPort: HansardReadStub(isSittingDay: false, subjectCount: 0, topSubjects: []), + voteReadPort: VoteReadStub(attendance: nil, voteSummary: nil), + digestNotificationPort: notifier, + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: true)), + deliveryRecordPort: DeliveryRecordSpy(), + clock: FixedClock(now: Self.localDate(hour: 19)), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(notifier.sentDigests.isEmpty) + } + + @Test func sendsDigestOnSittingDayInsideWindow() async throws { + let now = Self.localDate(hour: 19) + let notifier = DigestNotificationSpy() + let deliveryRecord = DeliveryRecordSpy() + let useCase = SendDailyParliamentDigest( + hansardReadPort: HansardReadStub( + isSittingDay: true, + subjectCount: 12, + topSubjects: ["Agriculture", "Finance", "Housing"] + ), + voteReadPort: VoteReadStub( + attendance: 0.92, + voteSummary: DailyDigest.VoteSummary( + billName: "C-234", + passed: true, + yeas: 180, + nays: 120 + ) + ), + digestNotificationPort: notifier, + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: true)), + deliveryRecordPort: deliveryRecord, + clock: FixedClock(now: now), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(notifier.sentDigests.count == 1) + let digest = try #require(notifier.sentDigests.first) + #expect(digest.date == now) + #expect(digest.subjectCount == 12) + #expect(digest.attendanceEstimate == 0.92) + #expect(digest.topSubjects == ["Agriculture", "Finance", "Housing"]) + #expect(digest.vote?.billName == "C-234") + #expect(digest.vote?.passed == true) + #expect(digest.vote?.yeas == 180) + #expect(digest.vote?.nays == 120) + #expect(deliveryRecord.recordedDates.count == 1) + } + + @Test func passesTopSubjectsLimitOfThreeToHansardPort() async throws { + let hansard = HansardReadStub(isSittingDay: true, subjectCount: 5, topSubjects: ["A"]) + let useCase = SendDailyParliamentDigest( + hansardReadPort: hansard, + voteReadPort: VoteReadStub(attendance: 0.5, voteSummary: nil), + digestNotificationPort: DigestNotificationSpy(), + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: true)), + deliveryRecordPort: DeliveryRecordSpy(), + clock: FixedClock(now: Self.localDate(hour: 19)), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(hansard.lastTopSubjectsLimit == 3) + } + + @Test func sendsAtStartHourBoundary() async throws { + let notifier = DigestNotificationSpy() + let useCase = SendDailyParliamentDigest( + hansardReadPort: HansardReadStub(isSittingDay: true, subjectCount: 1, topSubjects: ["X"]), + voteReadPort: VoteReadStub(attendance: nil, voteSummary: nil), + digestNotificationPort: notifier, + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: true)), + deliveryRecordPort: DeliveryRecordSpy(), + clock: FixedClock(now: Self.localDate(hour: 17, minute: 0)), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(notifier.sentDigests.count == 1) + } + + @Test func sendsAtEndHourBoundary() async throws { + let notifier = DigestNotificationSpy() + let useCase = SendDailyParliamentDigest( + hansardReadPort: HansardReadStub(isSittingDay: true, subjectCount: 1, topSubjects: ["X"]), + voteReadPort: VoteReadStub(attendance: nil, voteSummary: nil), + digestNotificationPort: notifier, + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: true)), + deliveryRecordPort: DeliveryRecordSpy(), + clock: FixedClock(now: Self.localDate(hour: 23, minute: 0)), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(notifier.sentDigests.count == 1) + } + + @Test func skipsWhenAlreadyDeliveredToday() async throws { + let notifier = DigestNotificationSpy() + let deliveryRecord = DeliveryRecordSpy(alreadyDeliveredOnDate: Self.localDate(hour: 17)) + let useCase = SendDailyParliamentDigest( + hansardReadPort: HansardReadStub(isSittingDay: true, subjectCount: 1, topSubjects: ["X"]), + voteReadPort: VoteReadStub(attendance: nil, voteSummary: nil), + digestNotificationPort: notifier, + userPreferenceReadPort: UserPreferenceStub(user: NotificationPreferences(dailyDigestOptIn: true)), + deliveryRecordPort: deliveryRecord, + clock: FixedClock(now: Self.localDate(hour: 19)), + calendar: Self.fixedCalendar + ) + + try await useCase.execute() + + #expect(notifier.sentDigests.isEmpty) + #expect(deliveryRecord.recordedDates.isEmpty) + } + + // MARK: - Calendar helpers + + private static let fixedCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Toronto")! + return calendar + }() + + private static func localDate(hour: Int, minute: Int = 0) -> Date { + var components = DateComponents() + components.year = 2026 + components.month = 6 + components.day = 12 + components.hour = hour + components.minute = minute + return fixedCalendar.date(from: components)! + } +} + +// MARK: - Stubs / Spies + +@MainActor +private struct UserPreferenceStub: UserPreferenceReadPort { + let preferences: NotificationPreferences + + init(user: NotificationPreferences) { + self.preferences = user + } + + func loadPreferences() async throws -> NotificationPreferences { preferences } +} + +@MainActor +private final class HansardReadStub: HansardReadPort { + let isSittingDayResult: Bool + let subjectCountResult: Int + let topSubjectsResult: [String] + private(set) var lastTopSubjectsLimit: Int? + + init(isSittingDay: Bool, subjectCount: Int, topSubjects: [String]) { + self.isSittingDayResult = isSittingDay + self.subjectCountResult = subjectCount + self.topSubjectsResult = topSubjects + } + + func isSittingDay(_ date: Date) async throws -> Bool { isSittingDayResult } + func fetchSubjectsCount(for date: Date) async throws -> Int { subjectCountResult } + func fetchTopSubjects(for date: Date, limit: Int) async throws -> [String] { + lastTopSubjectsLimit = limit + return Array(topSubjectsResult.prefix(limit)) + } +} + +@MainActor +private struct VoteReadStub: VoteReadPort { + let attendance: Double? + let voteSummary: DailyDigest.VoteSummary? + + func fetchAttendanceEstimate(for date: Date) async throws -> Double? { attendance } + func fetchVoteSummary(for date: Date) async throws -> DailyDigest.VoteSummary? { voteSummary } +} + +@MainActor +private final class DigestNotificationSpy: DigestNotificationPort { + private(set) var sentDigests: [DailyDigest] = [] + + func send(_ digest: DailyDigest) async throws { + sentDigests.append(digest) + } +} + +@MainActor +private final class DeliveryRecordSpy: DigestDeliveryRecordPort { + private(set) var recordedDates: [Date] = [] + private var alreadyDeliveredOnDate: Date? + private let calendar: Calendar + + init(alreadyDeliveredOnDate: Date? = nil) { + self.alreadyDeliveredOnDate = alreadyDeliveredOnDate + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Toronto")! + self.calendar = calendar + } + + func wasDelivered(on date: Date) async throws -> Bool { + guard let already = alreadyDeliveredOnDate else { return false } + return calendar.isDate(already, inSameDayAs: date) + } + + func recordDelivered(on date: Date) async throws { + recordedDates.append(date) + alreadyDeliveredOnDate = date + } +} + +private struct FixedClock: Clock { + let now: Date +} diff --git a/ios/epacTests/Domain/DailyDigestFormatterTests.swift b/ios/epacTests/Domain/DailyDigestFormatterTests.swift new file mode 100644 index 00000000..70529b9e --- /dev/null +++ b/ios/epacTests/Domain/DailyDigestFormatterTests.swift @@ -0,0 +1,109 @@ +// +// DailyDigestFormatterTests.swift +// epacTests +// + +@testable import epac +import Foundation +import Testing + +struct DailyDigestFormatterTests { + + @Test func bodyJoinsHeadlineFieldsWithMiddot() { + let digest = DailyDigest( + date: date(year: 2026, month: 6, day: 12), + subjectCount: 9, + attendanceEstimate: 0.92, + topSubjects: ["Housing", "Healthcare", "Climate"], + vote: nil + ) + + let body = DailyDigestFormatter.body(for: digest) + + #expect(body.contains("9 debates")) + #expect(body.contains("92% of MPs present")) + #expect(body.contains("Housing, Healthcare, Climate")) + #expect(body.contains(" · ")) + } + + @Test func bodyOmitsAttendanceWhenUnknown() { + let digest = DailyDigest( + date: date(year: 2026, month: 6, day: 12), + subjectCount: 4, + attendanceEstimate: nil, + topSubjects: ["Trade"], + vote: nil + ) + + let body = DailyDigestFormatter.body(for: digest) + + #expect(!body.contains("% of MPs present")) + #expect(body.contains("4 debates")) + #expect(body.contains("Trade")) + } + + @Test func bodyOmitsTopSubjectsLineWhenEmpty() { + let digest = DailyDigest( + date: date(year: 2026, month: 6, day: 12), + subjectCount: 2, + attendanceEstimate: 0.5, + topSubjects: [], + vote: nil + ) + + let body = DailyDigestFormatter.body(for: digest) + + #expect(body.contains("2 debates")) + #expect(body.contains("50% of MPs present")) + } + + @Test func bodyAppendsVoteLineForPassedVote() { + let digest = DailyDigest( + date: date(year: 2026, month: 6, day: 12), + subjectCount: 9, + attendanceEstimate: 0.92, + topSubjects: ["Housing"], + vote: DailyDigest.VoteSummary(billName: "C-234", passed: true, yeas: 180, nays: 120) + ) + + let body = DailyDigestFormatter.body(for: digest) + + #expect(body.contains("C-234")) + #expect(body.contains("passed")) + #expect(body.contains("180–120")) + #expect(body.contains("\n")) + } + + @Test func bodyAppendsVoteLineForDefeatedVote() { + let digest = DailyDigest( + date: date(year: 2026, month: 6, day: 12), + subjectCount: 9, + attendanceEstimate: 0.92, + topSubjects: ["Housing"], + vote: DailyDigest.VoteSummary(billName: "C-99", passed: false, yeas: 120, nays: 180) + ) + + let body = DailyDigestFormatter.body(for: digest) + + #expect(body.contains("C-99")) + #expect(body.contains("defeated")) + #expect(body.contains("120–180")) + } + + @Test func titleIncludesFormattedDate() { + let title = DailyDigestFormatter.title(for: date(year: 2026, month: 6, day: 12)) + + #expect(title.contains("Parliament sat today")) + #expect(title.contains("2026")) + } + + // MARK: - Helpers + + private func date(year: Int, month: Int, day: Int) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + return Calendar.current.date(from: components)! + } +} diff --git a/ios/epacTests/SnapshotTests.swift b/ios/epacTests/SnapshotTests.swift index 63e25311..0ff62a9e 100644 --- a/ios/epacTests/SnapshotTests.swift +++ b/ios/epacTests/SnapshotTests.swift @@ -436,6 +436,22 @@ final class SnapshotTests: XCTestCase { ) } + // MARK: - Daily Parliament digest notifications (EPAC-917) + + @MainActor + func testNotificationsSettings_dailyDigest() { + UserDefaults.standard.removeObject(forKey: UserPreferenceAdapter.dailyDigestEnabledKey) + defer { UserDefaults.standard.removeObject(forKey: UserPreferenceAdapter.dailyDigestEnabledKey) } + + snapshot( + NavigationStack { + NotificationsSettingsView() + } + .frame(width: 375, height: 320), + name: "NotificationsSettings_dailyDigest" + ) + } + // MARK: - Design system tokens (EPAC-440) func testDesignSystem_colorTokens() { diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_a11y.png new file mode 100644 index 00000000..02c2652a Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_dark.png new file mode 100644 index 00000000..eeaea432 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_light.png new file mode 100644 index 00000000..5b82e780 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testNotificationsSettings_dailyDigest.NotificationsSettings_dailyDigest_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_dark.png index b3dededc..1301ec5e 100644 Binary files a/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_dark.png and b/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_light.png index 8748fead..a4b03803 100644 Binary files a/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_light.png and b/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_xxl.png b/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_xxl.png index 656ad044..ea17555c 100644 Binary files a/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_xxl.png and b/ios/epacTests/__Snapshots__/SnapshotTests/testSettings_root.Settings_root_xxl.png differ