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
22 changes: 22 additions & 0 deletions docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,28 @@ scripts/check-boundaries.sh

See that file for exit codes and per-path skip notes.

## Provincial Hansard adapters

The application layer depends on `HansardRepository`. Runtime dispatch is owned
by `JurisdictionRoutedHansardRepository`, which maps each `Jurisdiction` to one
adapter. Provincial rows stay TODO until the province-specific implementation
issue lands.

| Jurisdiction | Adapter file | Status |
|---|---|---|
| Federal | `ios/epac/Data/Repositories/SwiftDataHansardRepository.swift` | Registered in `JurisdictionRoutedHansardRepository` at app startup. |
| Alberta | `ios/epac/Data/Adapters/Hansard/AlbertaHansardAdapter.swift` | TODO — EPAC-613. |
| British Columbia | `ios/epac/Data/Adapters/Hansard/BritishColumbiaHansardAdapter.swift` | TODO — EPAC-680. |
| Manitoba | `ios/epac/Data/Adapters/Hansard/ManitobaHansardAdapter.swift` | TODO — EPAC-786. |
| Nova Scotia | `ios/epac/Data/Adapters/Hansard/NovaScotiaHansardAdapter.swift` | TODO — EPAC-793. |
| Quebec | `ios/epac/Data/Adapters/Hansard/QuebecHansardAdapter.swift` | TODO — EPAC-936. |
| Saskatchewan | `ios/epac/Data/Adapters/Hansard/SaskatchewanHansardAdapter.swift` | TODO — EPAC-874. |

Boundary rule: files matching
`ios/epac/Data/Adapters/Hansard/**/*Adapter.swift` must not import `SwiftUI`,
`SwiftData`, or `UIKit`. If the check fails, move UI or persistence concerns to
a Repository or ViewModel layer.

### What "inward" means for epac today

The boundary between application policy and delivery adapters does not have fully isolated directories yet (those are being created by EPAC-1741, EPAC-1742, EPAC-1743). Until those tickets land, the boundary is conceptual and documented here:
Expand Down
83 changes: 83 additions & 0 deletions ios/epac/Data/Adapters/Hansard/HTMLHansardScaffold.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// HTMLHansardScaffold.swift
// epac
//

import Foundation

protocol HTMLHansardScaffold {
var jurisdiction: Jurisdiction { get }
var language: Locale { get }
}

extension HTMLHansardScaffold {
var language: Locale { Locale(identifier: "en-CA") }

func makeTranscript(
sittingDate: Date,
legislatureNumber: Int?,
sourceURL: URL,
subjects: [SubjectOfBusinessRecord]
) -> HansardTranscript {
HansardTranscript(
jurisdiction: jurisdiction,
sittingDate: sittingDate,
parliamentNumber: nil,
sessionNumber: nil,
legislatureNumber: legislatureNumber,
sourceURL: sourceURL,
language: language,
subjects: subjects
)
}

func makeSubject(
id: String,
title: String,
speeches: [SpeechMessageRecord]
) -> SubjectOfBusinessRecord {
SubjectOfBusinessRecord(
id: normalizedIdentifier(id),
title: normalizedText(title),
speeches: speeches
)
}

func makeSpeech(
interventionID: String,
speakerText: String,
speakerMemberID: String?,
text: String,
timestamp: Date? = nil
) -> SpeechMessageRecord {
let speaker = HansardSpeakerParser.parse(speakerText)
let displayName = [speaker.firstName, speaker.lastName]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " ")
return SpeechMessageRecord(
interventionID: normalizedIdentifier(interventionID),
speakerName: displayName.isEmpty ? normalizedText(speakerText) : displayName,
speakerMemberID: speakerMemberID,
text: normalizedText(strippingHTMLTags(from: text)),
timestamp: timestamp
)
}

func normalizedText(_ text: String) -> String {
text
.replacingOccurrences(of: "\u{00a0}", with: " ")
.split { $0.isWhitespace }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}

func normalizedIdentifier(_ text: String) -> String {
let identifier = normalizedText(text)
return identifier.isEmpty ? "unknown" : identifier
}

func strippingHTMLTags(from html: String) -> String {
html.replacing(/<[^>]+>/, with: " ")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// JurisdictionRoutedHansardRepository.swift
// epac
//

import Foundation

@MainActor
struct JurisdictionRoutedHansardRepository: HansardRepository {
private let adapters: [Jurisdiction: any HansardRepository]

init(adapters: [Jurisdiction: any HansardRepository]) {
self.adapters = adapters
}

func fetchTranscript(jurisdiction: Jurisdiction, sittingDate: Date) async throws -> HansardTranscript {
try await adapter(for: jurisdiction).fetchTranscript(
jurisdiction: jurisdiction,
sittingDate: sittingDate
)
}

func listSittingDates(jurisdiction: Jurisdiction, from startDate: Date, through endDate: Date) async throws -> [Date] {
try await adapter(for: jurisdiction).listSittingDates(
jurisdiction: jurisdiction,
from: startDate,
through: endDate
)
}

func storeTranscript(_ transcript: HansardTranscript) async throws {
try await adapter(for: transcript.jurisdiction).storeTranscript(transcript)
}

private func adapter(for jurisdiction: Jurisdiction) throws -> any HansardRepository {
guard let adapter = adapters[jurisdiction] else {
throw HansardAdapterError.unsupportedJurisdiction(jurisdiction)
}
return adapter
}
}

enum HansardAdapterError: Error, Equatable {
case unsupportedJurisdiction(Jurisdiction)
}
49 changes: 49 additions & 0 deletions ios/epac/Data/Adapters/Hansard/ProvincialHansardAdapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Provincial Hansard Adapter Scaffold

Each provincial Hansard adapter implements `HansardRepository` for exactly one
`Jurisdiction` and lives under:

`ios/epac/Data/Adapters/Hansard/<Province>HansardAdapter.swift`

The adapter is responsible for source-specific delivery details only. The
application layer calls `JurisdictionRoutedHansardRepository`, which dispatches
requests by jurisdiction.

## Transport

Fetch only from the province's authoritative Hansard source. Keep URL building,
HTTP status handling, retry policy, and source metadata inside the adapter or a
small province-local helper. If multiple provinces need the same HTTP client
shape after the first implementation lands, extract it then; do not add a shared
network abstraction before there are two real callers.

## Parsing

Return `HansardTranscript` with stable `SubjectOfBusinessRecord` and
`SpeechMessageRecord` values. Reuse `HTMLHansardScaffold` for HTML-only sources
such as Nova Scotia, Manitoba, and Saskatchewan when its default transcript,
subject, speech, and whitespace helpers fit. For PDF or XML sources, write the
smallest province-local parser that preserves source IDs and source text.

## Speaker Resolution

Use `HansardSpeakerParser` for common speaker prefixes before mapping a speaker
line to the province's member list. The Quebec adapter can rely on the parser's
French prefixes (`L'hon.`, `L’hon.`, `Mme`, `M.`), but member identity still
belongs to the province-specific adapter because each legislature publishes a
different member list and stable identifier shape.

## Sitting Calendar Discovery

Implement `listSittingDates(jurisdiction:from:through:)` from the province's
authoritative sitting calendar or Hansard index. The returned dates must be
filtered to the requested range and sorted before returning. If a province has
only per-day Hansard pages and no separate calendar endpoint, document that in
the adapter and derive the date list from the source index.

## Boundary Rule

Files matching `ios/epac/Data/Adapters/Hansard/**/*Adapter.swift` must not
import `SwiftUI`, `SwiftData`, or `UIKit`. If an adapter needs UI state or
persistence, move that concern to a repository, ViewModel, or another outer
layer and keep the adapter focused on transport and parsing.
26 changes: 26 additions & 0 deletions ios/epac/Util/HansardRepositoryEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import SwiftUI

private struct HansardRepositoryEnvironmentKey: EnvironmentKey {
static let defaultValue: any HansardRepository = UnsupportedHansardRepository()
}

extension EnvironmentValues {
var hansardRepository: any HansardRepository {
get { self[HansardRepositoryEnvironmentKey.self] }
set { self[HansardRepositoryEnvironmentKey.self] = newValue }
}
}

private struct UnsupportedHansardRepository: HansardRepository {
func fetchTranscript(jurisdiction: Jurisdiction, sittingDate: Date) async throws -> HansardTranscript {
throw HansardAdapterError.unsupportedJurisdiction(jurisdiction)
}

func listSittingDates(jurisdiction: Jurisdiction, from startDate: Date, through endDate: Date) async throws -> [Date] {
throw HansardAdapterError.unsupportedJurisdiction(jurisdiction)
}

func storeTranscript(_ transcript: HansardTranscript) async throws {
throw HansardAdapterError.unsupportedJurisdiction(transcript.jurisdiction)
}
}
18 changes: 17 additions & 1 deletion ios/epac/Util/HansardSpeakerParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,23 @@ struct HansardSpeakerParser {
return (firstName: cleanNames.dropLast().joined(separator: " "), lastName: cleanNames.last.map(String.init))
}

private static let speakerTitleWords = ["Hon.", "Rt.", "Mr.", "Ms.", "Mrs.", "Mme.", "Dr.", "The", "Hon", "Rt", "Right"]
private static let speakerTitleWords = [
"Hon.",
"Rt.",
"Mr.",
"Ms.",
"Mrs.",
"Mme.",
"Mme",
"M.",
"L'hon.",
"L\u{2019}hon.",
"Dr.",
"The",
"Hon",
"Rt",
"Right"
]
}

private extension String {
Expand Down
2 changes: 2 additions & 0 deletions ios/epac/Views/Calendar/SittingCalendarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ struct SittingCalendarView: View {
@Binding var selectedDate: DateComponents?

@Environment(\.modelContext) private var modelContext
@Environment(\.hansardRepository) private var hansardRepository
@Environment(\.isPresented) private var isPresented
@Environment(\.font) private var font
@StateObject private var calendarViewProxy = CalendarViewProxy()
Expand Down Expand Up @@ -192,6 +193,7 @@ struct SittingCalendarView: View {
.frame(maxWidth: .infinity)
.task(id: viewModel.currentYear) {
// id-based task cancels any in-flight fetch when year changes via the chevron picker.
viewModel.configure(browseHansardSitting: BrowseHansardSitting(repository: hansardRepository))
if viewModel.dates.isEmpty {
await viewModel.fetchSittingCalendar(viewModel.currentYear, modelContext: modelContext, fetch: fetch)
scrollToToday(animated: false)
Expand Down
6 changes: 5 additions & 1 deletion ios/epac/Views/Calendar/SittingLoaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SwiftUI

struct SittingLoaderView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.hansardRepository) private var hansardRepository
@EnvironmentObject private var fetch: Fetch

let date: Date
Expand Down Expand Up @@ -57,7 +58,10 @@ struct SittingLoaderView: View {
}

do {
try await fetch.downloadHansard(date)
_ = try await LoadDailyHansard(repository: hansardRepository).execute(
jurisdiction: .federal,
sittingDate: date
)
guard let hansard = fetchHansard() else {
loadState = .failed
return
Expand Down
3 changes: 2 additions & 1 deletion ios/epac/Views/Chat/SpeechView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct SpeechView: View {
@Environment(\.modelContext) var modelContext
@Environment(\.colorScheme) var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.hansardRepository) private var hansardRepository
@Environment(NavigationRouter.self) var router
@EnvironmentObject var fetch: Fetch

Expand Down Expand Up @@ -222,7 +223,7 @@ struct SpeechView: View {
guard viewModel.messages.isEmpty else { return }
do {
viewModel.configure(readHansardSpeech: ReadHansardSpeech(
repository: SwiftDataHansardRepository(modelContext: modelContext, fetch: fetch)
repository: hansardRepository
))
try await viewModel.loadSpeech(
jurisdiction: .federal,
Expand Down
7 changes: 5 additions & 2 deletions ios/epac/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct ContentView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
var fetch: Fetch
var hansardRepository: any HansardRepository
var appDelegate: AppDelegate
@State private var viewModel = ContentViewModel()
@State private var router = NavigationRouter()
Expand All @@ -40,8 +41,9 @@ struct ContentView: View {
@State private var showOnboarding = !AppRuntime.isRunningTests && !AppEnvironment.isMarketingCaptureMode && !UserDefaults.standard.bool(forKey: "epac.onboarding.completed")
@State private var showWhatsNew = false

init(modelContainer: ModelContainer, appDelegate: AppDelegate) {
self.fetch = Fetch(modelContainer: modelContainer)
init(fetch: Fetch, hansardRepository: any HansardRepository, appDelegate: AppDelegate) {
self.fetch = fetch
self.hansardRepository = hansardRepository
self.appDelegate = appDelegate
if AppEnvironment.isAppPreviewMode {
Self.configureAppPreviewMode()
Expand Down Expand Up @@ -80,6 +82,7 @@ struct ContentView: View {
}
}
.environmentObject(fetch)
.environment(\.hansardRepository, hansardRepository)
.environment(router)
.environment(networkMonitor)
.onOpenURL { url in
Expand Down
Loading
Loading