Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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

```
Expand Down
86 changes: 86 additions & 0 deletions ios/epac/Application/SendDailyParliamentDigest.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
69 changes: 69 additions & 0 deletions ios/epac/Data/Adapters/LiveDigestNotificationAdapter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// LiveDigestNotificationAdapter.swift
// epac
//
// Formats a DailyDigest into a local notification using UNUserNotificationCenter.
// Format spec (EPAC-917):
// Title: "Parliament sat today · <Date>"
// Body: "<N> debates · <M>% of MPs present · <Subject 1>, <Subject 2>, <Subject 3>"
// "1 recorded vote: <Bill name> <passed|defeated> <Y>–<N>" (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)
}
}
84 changes: 84 additions & 0 deletions ios/epac/Data/Adapters/SwiftDataHansardReadAdapter.swift
Original file line number Diff line number Diff line change
@@ -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<String> = []
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<Hansard>(
predicate: #Predicate { $0.date >= day && $0.date < next }
)
return try modelContext.fetch(descriptor).first
}
}
62 changes: 62 additions & 0 deletions ios/epac/Data/Adapters/SwiftDataVoteReadAdapter.swift
Original file line number Diff line number Diff line change
@@ -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<RecordedVote>(
predicate: #Predicate { $0.jurisdiction == federal && $0.date >= day && $0.date < next },
sortBy: [SortDescriptor(\.number)]
)
return try modelContext.fetch(descriptor)
}
}
Loading
Loading