From 0b0ea9130d0a489980e620f2a1faacdf177b020c Mon Sep 17 00:00:00 2001 From: riddim-developer-bot Date: Sat, 13 Jun 2026 16:08:49 -0400 Subject: [PATCH 1/2] =?UTF-8?q?[EPAC-691]:=20Royal=20Assent=20tracker=20?= =?UTF-8?q?=E2=80=94=20Home=20card,=20Became=20Law=20filter,=20follow-bill?= =?UTF-8?q?=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the iOS slice that closes the legislative loop when a bill becomes law: - TrackRoyalAssent use case + RecentLawQueryPort surface bills that received Royal Assent in the last 30 days, sorted most recent first. - NotifyFollowedBillRoyalAssent use case + RoyalAssentNotificationPort schedule a local user notification when a followed bill transitions to Royal Assent during a backend bill refresh. UNUserNotificationCenter stays behind LiveRoyalAssentNotificationAdapter. - Bill gains royalAssentDate, summary, and sponsorProfileURL parsed from the existing LEGISinfo adapter (no new wire format on iOS). - HomeFeedView adds a "Recently Became Law" section backed by RecentlyBecameLawCard. Each row links into BillDetailView, surfaces the sponsor as a MemberProfileView link when the local SwiftData store has the MP, falls back to a Parliament.ca sponsor link otherwise, and links out to LEGISinfo. - BillsView accepts an initialTab argument so the home "See laws" link deep-links into the Became Law filter. - BillFollowStore triggers the notification path when refresh data shows a followed bill flipped to Royal Assent. - Use case catalog gains TrackRoyalAssent and NotifyFollowedBillRoyalAssent entries with current implementation pointers. Release-Note: New "Recently Became Law" card on the Home feed, a "Became Law" filter in the Bills tab, and a notification when a bill you follow receives Royal Assent. --- docs/architecture/use-case-catalog.md | 55 +++++- .../NotifyFollowedBillRoyalAssent.swift | 25 +++ ios/epac/Application/TrackRoyalAssent.swift | 44 +++++ ios/epac/Data/Adapters/BillsService.swift | 19 +++ .../LiveRoyalAssentNotificationAdapter.swift | 34 ++++ .../Entities/RoyalAssentNotification.swift | 11 ++ .../Domain/Ports/RecentLawQueryPort.swift | 11 ++ .../Ports/RoyalAssentNotificationPort.swift | 11 ++ ios/epac/Model/Bill.swift | 43 ++++- ios/epac/Model/Fetch.swift | 3 + ios/epac/Util/BillFollowStore.swift | 6 + ios/epac/Views/Bills/BillsView.swift | 7 +- ios/epac/Views/Home/HomeFeedView.swift | 161 ++++++++++++++++++ ios/epac/en.lproj/Localizable.strings | 5 + ios/epac/fr.lproj/Localizable.strings | 5 + ios/epacTests/BillsSelectionTests.swift | 3 + .../NotifyFollowedBillRoyalAssentTests.swift | 49 ++++++ ios/epacTests/SnapshotTests.swift | 39 ++++- ios/epacTests/TrackRoyalAssentTests.swift | 81 +++++++++ .../Views/Search/SearchViewModelTests.swift | 3 + 20 files changed, 607 insertions(+), 8 deletions(-) create mode 100644 ios/epac/Application/NotifyFollowedBillRoyalAssent.swift create mode 100644 ios/epac/Application/TrackRoyalAssent.swift create mode 100644 ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift create mode 100644 ios/epac/Domain/Entities/RoyalAssentNotification.swift create mode 100644 ios/epac/Domain/Ports/RecentLawQueryPort.swift create mode 100644 ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift create mode 100644 ios/epacTests/NotifyFollowedBillRoyalAssentTests.swift create mode 100644 ios/epacTests/TrackRoyalAssentTests.swift diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index 6b05297c..7a53d2af 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -25,7 +25,7 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/` | `Sitting` | A House sitting date with Parliament/session metadata and source URL. | | `EPetition` | An e-petition submitted to the House of Commons with signatures, sponsor, deadline, and optional government response. | | `PetitionGovernmentResponse` | The government's official written response tabled in the House of Commons for a qualified petition. | -| `Bill` | A Parliament of Canada bill with number, title, stage, sponsor, and LEGISinfo source URL. | +| `Bill` | A Parliament of Canada bill with number, title, stage, sponsor, Royal Assent date when available, and LEGISinfo source URL. | | `BillVersion` | Backend-only bill publication/version row with source links for text, PDF, and XML artifacts. | | `BillAmendment` | Backend-only House or committee amendment record associated with a bill and chamber stage. | | `PBOCosting` | Backend-only Parliamentary Budget Officer costing link associated with a bill. | @@ -53,6 +53,7 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/` | `DeviceSubscription` | An APNs token plus the topic/bill/member preferences registered for that device. | | `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. | | `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. | @@ -92,11 +93,13 @@ to the issue that will build the missing artifact. | `SittingRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/SittingRepository.swift`; adapter: `ios/epac/Data/Adapters/HansardSittingRepositoryAdapter.swift`. | List sitting dates and load transcripts for a sitting date. | | `BillRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillRepository.swift`; adapter: `ios/epac/Data/Adapters/LEGISinfoBillRepository.swift`. | List current-session bills. | | `BillRepository` | backend Go | outbound | Implemented: `backend/bills/internal/usecase/bills.go`; adapter: `backend/bills/internal/adapter/sqlite/repository.go`. | List bills and load bill-depth rows from the verified bills SQLite artifact. | +| `RecentLawQueryPort` | iOS Swift | outbound | Implemented by `ios/epac/Application/TrackRoyalAssent.swift`; adapter input is `BillRepository`. | Query current-session bills that received Royal Assent within the recent-law window. | | `MemberRepository` | backend Go | outbound | Implemented: `backend/members/internal/usecase/members.go`; adapter: `backend/members/internal/adapter/sqlite/repository.go`. | List members and load member-profile attendance rows from the verified members SQLite artifact. | | `MemberContentRepository` | backend Go | outbound | Implemented: `backend/member-speeches/internal/usecase/usecase.go` with adapter `backend/member-speeches/internal/adapter/artifact/artifact.go`; `backend/member-votes/main.go` has a local vote-feed interface implemented by `S3ArtifactMemberContentRepository`. | Load per-member append-only content feeds such as speeches and recorded votes. There is no iOS Swift protocol with this name today. | | `TopicPreferenceStore` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/TopicPreferenceStore.swift`; adapter: `ios/epac/Data/Adapters/TopicFollowStoreAdapter.swift`. | Read and persist followed topic IDs as a Domain-layer port. | | `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. | | `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. | @@ -497,6 +500,54 @@ Current implementation: --- +### TrackRoyalAssent + +``` +Actor: User (iOS app, Home feed / Bills tab) +Goal: Surface current-session bills that recently became law through Royal Assent. +Inputs: Current date, current-session bill records. +Outputs: Bills with Royal Assent in the last 30 days, sorted most recent first. +Entities / values: Bill. +Ports: iOS Swift: `RecentLawQueryPort`, `BillRepository`, `Clock`. +Primary adapters: HomeFeedView RecentlyBecameLawCard, BillsView Became Law filter, LEGISinfoBillRepository wrapping BillsService. +Current implementation: + ios/epac/Application/TrackRoyalAssent.swift + ios/epac/Domain/Ports/RecentLawQueryPort.swift + ios/epac/Domain/Ports/BillRepository.swift + ios/epac/Data/Adapters/LEGISinfoBillRepository.swift + ios/epac/Data/Adapters/BillsService.swift + ios/epac/Views/Home/HomeFeedView.swift + ios/epac/Views/Bills/BillsView.swift +``` + +> **Boundary note:** The use case filters typed `Bill` values only. LEGISinfo field names, date parsing, sponsor profile URL construction, and source-link construction remain inside `BillsService`. + +--- + +### NotifyFollowedBillRoyalAssent + +``` +Actor: iOS background refresh / User foreground bill refresh +Goal: Notify the user when a bill they follow is observed transitioning to Royal Assent. +Inputs: Followed bill state, freshly fetched bill records. +Outputs: Notification content for the followed bill and updated last-known follow state. +Entities / values: Bill, BillFollowState, RoyalAssentNotification. +Ports: iOS Swift: `RoyalAssentNotificationPort`, `BillRepository`. +Primary adapters: BillFollowStore, LiveRoyalAssentNotificationAdapter, BackgroundRefreshManager, BillsView refresh path. +Current implementation: + ios/epac/Application/NotifyFollowedBillRoyalAssent.swift + ios/epac/Domain/Entities/RoyalAssentNotification.swift + ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift + ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift + ios/epac/Util/BillFollowStore.swift + ios/epac/Util/BackgroundRefreshManager.swift + ios/epac/Model/Fetch.swift +``` + +> **Boundary note:** UserNotifications is adapter detail. The use case formats typed notification content and does not import APNs or `UNUserNotificationCenter`; iOS background wake timing remains governed by BGAppRefresh. + +--- + ### GetBillDepth ``` @@ -964,7 +1015,7 @@ Current implementation: > Retired in EPAC-1921. The backend `live-status` Lambda and `/api/v1/live` route are removed from desired state. The historical architecture note remains in `docs/architecture/live-status-backend-epac165.md`. ---- +--- ### Lobbying-index builder boundary diff --git a/ios/epac/Application/NotifyFollowedBillRoyalAssent.swift b/ios/epac/Application/NotifyFollowedBillRoyalAssent.swift new file mode 100644 index 00000000..d3500443 --- /dev/null +++ b/ios/epac/Application/NotifyFollowedBillRoyalAssent.swift @@ -0,0 +1,25 @@ +// +// NotifyFollowedBillRoyalAssent.swift +// epac +// + +import Foundation + +@MainActor +struct NotifyFollowedBillRoyalAssent { + private let notificationPort: any RoyalAssentNotificationPort + + init(notificationPort: any RoyalAssentNotificationPort) { + self.notificationPort = notificationPort + } + + func execute(bill: Bill) async throws { + let title = bill.number + let body = String( + format: NSLocalizedString("notification.royalAssent.body", comment: ""), + bill.title + ) + let notification = RoyalAssentNotification(title: title, body: body) + try await notificationPort.sendNotification(notification) + } +} diff --git a/ios/epac/Application/TrackRoyalAssent.swift b/ios/epac/Application/TrackRoyalAssent.swift new file mode 100644 index 00000000..ca68ff46 --- /dev/null +++ b/ios/epac/Application/TrackRoyalAssent.swift @@ -0,0 +1,44 @@ +// +// TrackRoyalAssent.swift +// epac +// + +import Foundation + +@MainActor +struct TrackRoyalAssent: RecentLawQueryPort { + private static let recentWindowDays = 30 + + private let repository: any BillRepository + private let clock: any Clock + private let calendar: Calendar + + init( + repository: any BillRepository, + clock: any Clock = SystemClock(), + calendar: Calendar = .current + ) { + self.repository = repository + self.clock = clock + self.calendar = calendar + } + + func recentlyBecameLaw() async throws -> [Bill] { + let bills = try await repository.fetchBills() + let today = calendar.startOfDay(for: clock.now) + let earliest = calendar.date(byAdding: .day, value: -Self.recentWindowDays, to: today) ?? today + + return bills + .filter { bill in + guard bill.status == .royalAssent, + let date = bill.becameLawDate else { + return false + } + let day = calendar.startOfDay(for: date) + return day >= earliest && day <= today + } + .sorted { lhs, rhs in + (lhs.becameLawDate ?? .distantPast) > (rhs.becameLawDate ?? .distantPast) + } + } +} diff --git a/ios/epac/Data/Adapters/BillsService.swift b/ios/epac/Data/Adapters/BillsService.swift index 43e42a17..b8a35a11 100644 --- a/ios/epac/Data/Adapters/BillsService.swift +++ b/ios/epac/Data/Adapters/BillsService.swift @@ -89,6 +89,7 @@ struct BillsService { let readingDates = billReadingDates(from: raw) let introducedDate = billIntroducedDate(from: readingDates) let stages = billStages(number: number, raw: raw, dates: readingDates) + let summary = billSummary(from: raw, selectedTitle: title) let currentStage = raw.LatestCompletedMajorStageEn ?? raw.CurrentStatusEn ?? "" let billSlug = "\(parliament)-\(session)/\(number.lowercased())" @@ -103,6 +104,9 @@ struct BillsService { status: billStatus(from: raw), currentStage: currentStage, introducedDate: introducedDate, + royalAssentDate: readingDates.royalAssent, + summary: summary, + sponsorProfileURL: sponsorProfileURL(from: raw), stages: stages, legisInfoURL: legisURL, type: billType(from: raw), @@ -121,6 +125,15 @@ struct BillsService { return nil } + private static func billSummary(from raw: LEGISinfoBill, selectedTitle: String) -> String? { + guard let longTitle = raw.LongTitleEn?.trimmingCharacters(in: .whitespacesAndNewlines), + !longTitle.isEmpty, + longTitle != selectedTitle else { + return nil + } + return longTitle + } + private static func billStatus(from raw: LEGISinfoBill) -> BillStatus { let statusLower = (raw.CurrentStatusEn ?? "").lowercased() if raw.ReceivedRoyalAssentDateTime != nil || statusLower.contains("royal assent") { @@ -137,6 +150,11 @@ struct BillsService { return billTypeMappings.first { typeLower.contains($0.needle) }?.type ?? .unknown } + private static func sponsorProfileURL(from raw: LEGISinfoBill) -> URL? { + guard let sponsorID = raw.SponsorId, sponsorID > 0 else { return nil } + return URL(string: "https://www.ourcommons.ca/members/en/\(sponsorID)") + } + private static func billReadingDates(from raw: LEGISinfoBill) -> BillReadingDates { BillReadingDates( houseFirst: parseDate(raw.PassedHouseFirstReadingDateTime), @@ -225,6 +243,7 @@ private struct LEGISinfoBill: Decodable { let CurrentStatusEn: String? let BillTypeEn: String? let SponsorEn: String? + let SponsorId: Int? let OriginatingChamberId: Int? let ParliamentNumber: Int let SessionNumber: Int diff --git a/ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift b/ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift new file mode 100644 index 00000000..b2ad4109 --- /dev/null +++ b/ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift @@ -0,0 +1,34 @@ +// +// LiveRoyalAssentNotificationAdapter.swift +// epac +// + +import Foundation +import UserNotifications + +struct LiveRoyalAssentNotificationAdapter: RoyalAssentNotificationPort { + func sendNotification(_ notification: RoyalAssentNotification) async throws { + let center = UNUserNotificationCenter.current() + + // Check authorization first; request it if not determined yet. + let settings = await center.notificationSettings() + if settings.authorizationStatus == .notDetermined { + _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) + } + + let content = UNMutableNotificationContent() + content.title = notification.title + content.body = notification.body + content.sound = .default + + // Fire the local notification almost immediately (1 second delay). + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: trigger + ) + + try await center.add(request) + } +} diff --git a/ios/epac/Domain/Entities/RoyalAssentNotification.swift b/ios/epac/Domain/Entities/RoyalAssentNotification.swift new file mode 100644 index 00000000..72b662fc --- /dev/null +++ b/ios/epac/Domain/Entities/RoyalAssentNotification.swift @@ -0,0 +1,11 @@ +// +// RoyalAssentNotification.swift +// epac +// + +import Foundation + +struct RoyalAssentNotification: Equatable, Sendable { + let title: String + let body: String +} diff --git a/ios/epac/Domain/Ports/RecentLawQueryPort.swift b/ios/epac/Domain/Ports/RecentLawQueryPort.swift new file mode 100644 index 00000000..5fde06fb --- /dev/null +++ b/ios/epac/Domain/Ports/RecentLawQueryPort.swift @@ -0,0 +1,11 @@ +// +// RecentLawQueryPort.swift +// epac +// + +import Foundation + +@MainActor +protocol RecentLawQueryPort: Sendable { + func recentlyBecameLaw() async throws -> [Bill] +} diff --git a/ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift b/ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift new file mode 100644 index 00000000..421d91ba --- /dev/null +++ b/ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift @@ -0,0 +1,11 @@ +// +// RoyalAssentNotificationPort.swift +// epac +// + +import Foundation + +@MainActor +protocol RoyalAssentNotificationPort: Sendable { + func sendNotification(_ notification: RoyalAssentNotification) async throws +} diff --git a/ios/epac/Model/Bill.swift b/ios/epac/Model/Bill.swift index 8429632c..08632cf8 100644 --- a/ios/epac/Model/Bill.swift +++ b/ios/epac/Model/Bill.swift @@ -21,11 +21,18 @@ struct Bill: Identifiable, Codable, Sendable { let status: BillStatus let currentStage: String let introducedDate: Date? + let royalAssentDate: Date? + let summary: String? + let sponsorProfileURL: URL? let stages: [BillStage] let legisInfoURL: URL let type: BillType let parliament: Int let session: Int + + var becameLawDate: Date? { + royalAssentDate ?? stages.first { $0.name.localizedCaseInsensitiveContains("royal assent") }?.completedDate + } } // MARK: - BillStage @@ -58,12 +65,27 @@ enum BillStatus: String, Codable, Equatable, Sendable { init(from decoder: Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) - self = BillStatus(rawValue: raw) ?? .unknown + if let exact = BillStatus(rawValue: raw) { + self = exact + return + } + + let normalized = raw.lowercased() + if normalized.contains("royal assent") || normalized.contains("royalassent") { + self = .royalAssent + } else if normalized.contains("defeat") { + self = .defeated + } else if normalized.isEmpty { + self = .unknown + } else { + self = .inProgress + } } } // MARK: - BillType +// swiftlint:disable redundant_string_enum_value enum BillType: String, Codable, Equatable, Sendable { case government = "government" case privateMember = "privateMember" @@ -94,6 +116,23 @@ enum BillType: String, Codable, Equatable, Sendable { init(from decoder: Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) - self = BillType(rawValue: raw) ?? .unknown + if let exact = BillType(rawValue: raw) { + self = exact + return + } + + let normalized = raw.lowercased() + if normalized.contains("private member") { + self = .privateMember + } else if normalized.contains("senate private") { + self = .senatePrivate + } else if normalized.contains("senate public") { + self = .senatePublic + } else if normalized.contains("government") { + self = .government + } else { + self = .unknown + } } } +// swiftlint:enable redundant_string_enum_value diff --git a/ios/epac/Model/Fetch.swift b/ios/epac/Model/Fetch.swift index 7a25331b..5c460e00 100644 --- a/ios/epac/Model/Fetch.swift +++ b/ios/epac/Model/Fetch.swift @@ -154,6 +154,9 @@ actor Fetch: ModelActor, ObservableObject { try? await downloadFiscalMonitorEntries() try? loadCabinetPositions() try? loadMinisterialExpenses() + if let bills = try? await BillsService.fetchBills() { + await BillFollowStore.shared.updateStoredState(in: bills) + } } func hansard(_ date: Date) async throws -> PersistentIdentifier { diff --git a/ios/epac/Util/BillFollowStore.swift b/ios/epac/Util/BillFollowStore.swift index 1c71f572..677dfbb9 100644 --- a/ios/epac/Util/BillFollowStore.swift +++ b/ios/epac/Util/BillFollowStore.swift @@ -102,6 +102,12 @@ final class BillFollowStore { let statusChanged = bill.status.rawValue != state.lastKnownStatus let stageChanged = !bill.currentStage.isEmpty && bill.currentStage != state.lastKnownStage if statusChanged || stageChanged { + if statusChanged && bill.status == .royalAssent { + Task { + let useCase = NotifyFollowedBillRoyalAssent(notificationPort: LiveRoyalAssentNotificationAdapter()) + try? await useCase.execute(bill: bill) + } + } followed[bill.number] = BillFollowState( lastKnownStatus: bill.status.rawValue, lastKnownStage: bill.currentStage, diff --git a/ios/epac/Views/Bills/BillsView.swift b/ios/epac/Views/Bills/BillsView.swift index b6cffbd7..616b04d4 100644 --- a/ios/epac/Views/Bills/BillsView.swift +++ b/ios/epac/Views/Bills/BillsView.swift @@ -17,6 +17,7 @@ private enum BillsLayout { static let retryDelaySeconds: Int64 = 2 static let rowSpacing = EpacSpacing.xs static let badgeRowSpacing: CGFloat = 6 + static let segmentedControlVerticalPadding: CGFloat = 8 static let rowVerticalPadding = EpacSpacing.xs static let newIndicatorFontSize: CGFloat = 6 static let statusBadgeHorizontalPadding: CGFloat = 6 @@ -70,7 +71,7 @@ struct BillsView: View { @State private var bills: [Bill] = [] @State private var isLoading = false @State private var loadFailed = false - @State private var selectedTab: BillFilterTab = BillsView.loadSelectedTab() + @State private var selectedTab: BillFilterTab @State private var billStore = BillFollowStore.shared @State private var searchText = "" @State private var shareItems: ActivityItem? @@ -84,6 +85,7 @@ struct BillsView: View { init( selection: Binding? = nil, + initialTab: BillFilterTab? = nil, billRepository: any BillRepository = LEGISinfoBillRepository(), billNumbersFilter: Set = [], navigationTitle: String? = nil @@ -92,6 +94,7 @@ struct BillsView: View { self.billRepository = billRepository self.billNumbersFilter = Set(billNumbersFilter.map { $0.uppercased() }) self.navigationTitleText = navigationTitle ?? NSLocalizedString("bills.navTitle", comment: "") + self._selectedTab = State(initialValue: initialTab ?? Self.loadSelectedTab()) } private var filtered: [Bill] { @@ -133,7 +136,7 @@ struct BillsView: View { } .pickerStyle(.segmented) .padding(.horizontal) - .padding(.vertical, 8) + .padding(.vertical, BillsLayout.segmentedControlVerticalPadding) .background(Color(.systemBackground)) } diff --git a/ios/epac/Views/Home/HomeFeedView.swift b/ios/epac/Views/Home/HomeFeedView.swift index 7798e5d6..8d84944d 100644 --- a/ios/epac/Views/Home/HomeFeedView.swift +++ b/ios/epac/Views/Home/HomeFeedView.swift @@ -30,6 +30,7 @@ private enum HomeFeedLayout { static let healthcarePreviewLimit = 2 static let reorderBillsThreshold = 5 static let unreadDotSize: CGFloat = 8 + static let sponsorLineLimit = 2 } @MainActor @@ -57,6 +58,7 @@ struct HomeFeedView: View { @State private var showNotifySettingsAlert = false @State private var selectedBillForNotify: FollowedBill? @State private var shareItem: ActivityItem? + @State private var recentLawBills: [Bill] = [] var body: some View { NavigationStack { @@ -64,6 +66,9 @@ struct HomeFeedView: View { todaySection electionCountdownSection myMPSection + if !recentLawBills.isEmpty { + recentlyBecameLawSection + } followingSection if !mySenators.isEmpty { senatorsSection @@ -353,6 +358,20 @@ struct HomeFeedView: View { // MARK: - Section 3: Following + private var recentlyBecameLawSection: some View { + Section(header: Text(NSLocalizedString("home.recentlyBecameLaw", comment: "")).accessibilityAddTraits(.isHeader)) { + RecentlyBecameLawCard( + bills: recentLawBills, + sponsorMember: sponsorMember(for:) + ) + NavigationLink(destination: BillsView(initialTab: .becameLaw)) { + Text(NSLocalizedString("home.seeLaws", comment: "")) + .font(.epacCaption) + .foregroundStyle(Color.epacBrand.accent) + } + } + } + private var followingSection: some View { Section(header: Text(NSLocalizedString("home.following", comment: "")).accessibilityAddTraits(.isHeader)) { billsGroup @@ -874,6 +893,32 @@ struct HomeFeedView: View { if let result = try? await loadFollowed.execute() { self.followedBills = result } + + self.recentLawBills = (try? await TrackRoyalAssent(repository: LEGISinfoBillRepository()).recentlyBecameLaw()) ?? [] + } + + private func sponsorMember(for bill: Bill) -> ParliamentMember? { + let sponsor = normalizedSponsorName(bill.sponsorName) + guard !sponsor.isEmpty, + let members = try? modelContext.fetch(FetchDescriptor()) else { + return nil + } + + return members.first { member in + let fullName = normalizedSponsorName(member.name) + return fullName == sponsor + || sponsor.contains(normalizedSponsorName(member.lastName)) + && sponsor.contains(normalizedSponsorName(member.firstName)) + } + } + + private func normalizedSponsorName(_ name: String) -> String { + name + .replacingOccurrences(of: "Hon. ", with: "") + .replacingOccurrences(of: "The Honourable ", with: "") + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) } private var hasPersonalizedContext: Bool { @@ -927,3 +972,119 @@ struct HomeFeedView: View { Log.info("home.today.tap target=\(target)") } } + +struct RecentlyBecameLawCard: View { + let bills: [Bill] + let sponsorMember: @MainActor (Bill) -> ParliamentMember? + + init( + bills: [Bill], + sponsorMember: @escaping @MainActor (Bill) -> ParliamentMember? = { _ in nil } + ) { + self.bills = bills + self.sponsorMember = sponsorMember + } + + var body: some View { + VStack(alignment: .leading, spacing: EpacSpacing.m) { + ForEach(bills) { bill in + lawRow(for: bill) + if bill.id != bills.last?.id { + Divider() + } + } + } + .padding(.vertical, EpacSpacing.xs) + .accessibilityIdentifier("home-recently-became-law-card") + } + + private func lawRow(for bill: Bill) -> some View { + VStack(alignment: .leading, spacing: EpacSpacing.s) { + NavigationLink(destination: BillDetailView(bill: bill)) { + VStack(alignment: .leading, spacing: EpacSpacing.xs) { + HStack(alignment: .firstTextBaseline, spacing: EpacSpacing.xs) { + Text(bill.number) + .font(.caption.monospacedDigit().weight(.bold)) + .foregroundStyle(Color.epacBrand.accent) + Text(royalAssentDateLabel(for: bill)) + .font(.epacCaption) + .foregroundStyle(Color.epacText.secondary) + } + Text(bill.title) + .font(.epacSubheadline.weight(.semibold)) + .foregroundStyle(Color.epacText.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + + if let summary = bill.summary, !summary.isEmpty { + Text(summary) + .font(.epacCaption) + .foregroundStyle(Color.epacText.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + ViewThatFits(in: .horizontal) { + HStack(spacing: EpacSpacing.s) { + sponsorLabel(for: bill) + Spacer() + legisInfoLink(for: bill) + } + VStack(alignment: .leading, spacing: EpacSpacing.xs) { + sponsorLabel(for: bill) + legisInfoLink(for: bill) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel(for: bill)) + } + + private func sponsorLabel(for bill: Bill) -> some View { + Group { + if let member = sponsorMember(bill) { + NavigationLink(destination: MemberProfileView(member: member)) { + Label(member.name, systemImage: "person.crop.circle") + } + } else if let profileURL = bill.sponsorProfileURL { + Link(destination: profileURL) { + Label(bill.sponsorName, systemImage: "person.crop.circle") + } + } else if !bill.sponsorName.isEmpty { + Label(bill.sponsorName, systemImage: "person.crop.circle") + } + } + .font(.epacCaption) + .foregroundStyle(Color.epacText.secondary) + .lineLimit(HomeFeedLayout.sponsorLineLimit) + } + + private func legisInfoLink(for bill: Bill) -> some View { + Link(destination: bill.legisInfoURL) { + Label(NSLocalizedString("bills.detail.legisinfo", comment: ""), systemImage: "arrow.up.right.square") + } + .font(.epacCaption) + .foregroundStyle(Color.epacBrand.accent) + } + + private func royalAssentDateLabel(for bill: Bill) -> String { + guard let date = bill.becameLawDate else { + return NSLocalizedString("bill.status.royalAssent", comment: "") + } + return String( + format: NSLocalizedString("home.recentlyBecameLaw.date", comment: ""), + date.formatted(date: .abbreviated, time: .omitted) + ) + } + + private func accessibilityLabel(for bill: Bill) -> String { + [ + bill.number, + bill.title, + royalAssentDateLabel(for: bill), + bill.sponsorName + ] + .filter { !$0.isEmpty } + .joined(separator: ", ") + } +} diff --git a/ios/epac/en.lproj/Localizable.strings b/ios/epac/en.lproj/Localizable.strings index 0a7fa2b3..b6a3a702 100644 --- a/ios/epac/en.lproj/Localizable.strings +++ b/ios/epac/en.lproj/Localizable.strings @@ -401,9 +401,12 @@ "home.debates.subtitle" = "Browse previous sittings"; "home.myMP.activityCount" = "%d recent activities"; "home.myMP.notSet" = "Set your postal code to track your MP"; +"home.recentlyBecameLaw" = "Recently Became Law"; +"home.recentlyBecameLaw.date" = "Royal Assent %@"; "home.followedBills" = "Bills You Follow"; "home.followedTopics" = "Topics You Follow"; "home.recentDebates" = "Recent in Hansard"; +"home.seeLaws" = "See laws →"; "home.seeAllBills" = "See all bills →"; "home.clearAllBills" = "Clear All"; "home.manageTopics" = "Manage topics →"; @@ -784,3 +787,5 @@ "appStore.screenshot.contact.subjectLine" = "Bill C-226 second reading"; "appStore.screenshot.contact.body" = "I am writing about Bill C-226, An Act to establish a national framework to improve food price transparency. Please share how you plan to vote and why."; "appStore.screenshot.contact.send" = "Send"; + +"notification.royalAssent.body" = "%@ received Royal Assent today and is now law."; diff --git a/ios/epac/fr.lproj/Localizable.strings b/ios/epac/fr.lproj/Localizable.strings index dc4338bb..9c37817b 100644 --- a/ios/epac/fr.lproj/Localizable.strings +++ b/ios/epac/fr.lproj/Localizable.strings @@ -403,9 +403,12 @@ "home.debates.subtitle" = "Parcourez les séances précédentes"; "home.myMP.activityCount" = "%d activités récentes"; "home.myMP.notSet" = "Entrez votre code postal pour suivre votre député"; +"home.recentlyBecameLaw" = "Devenus lois récemment"; +"home.recentlyBecameLaw.date" = "Sanction royale %@"; "home.followedBills" = "Projets de loi suivis"; "home.followedTopics" = "Sujets suivis"; "home.recentDebates" = "Récent au Hansard"; +"home.seeLaws" = "Voir les lois →"; "home.seeAllBills" = "Voir tous les projets →"; "home.clearAllBills" = "Tout effacer"; "home.manageTopics" = "Gérer les sujets →"; @@ -786,3 +789,5 @@ "appStore.screenshot.contact.subjectLine" = "Projet de loi C-226 — 2e lecture"; "appStore.screenshot.contact.body" = "J'écris au sujet du projet de loi C-226, Loi visant à établir un cadre national pour améliorer la transparence des prix alimentaires. Merci d'indiquer comment vous comptez voter et pourquoi."; "appStore.screenshot.contact.send" = "Envoyer"; + +"notification.royalAssent.body" = "%@ a reçu la sanction royale aujourd'hui et est maintenant loi."; diff --git a/ios/epacTests/BillsSelectionTests.swift b/ios/epacTests/BillsSelectionTests.swift index e4f30103..04f20dfc 100644 --- a/ios/epacTests/BillsSelectionTests.swift +++ b/ios/epacTests/BillsSelectionTests.swift @@ -86,6 +86,9 @@ private enum BillsSelectionTestData { status: .inProgress, currentStage: "Second Reading", introducedDate: nil, + royalAssentDate: nil, + summary: nil, + sponsorProfileURL: nil, stages: [], legisInfoURL: URL(string: "https://www.parl.ca/legisinfo/en/bill/45-1/c-50")!, type: .government, diff --git a/ios/epacTests/NotifyFollowedBillRoyalAssentTests.swift b/ios/epacTests/NotifyFollowedBillRoyalAssentTests.swift new file mode 100644 index 00000000..4915fff8 --- /dev/null +++ b/ios/epacTests/NotifyFollowedBillRoyalAssentTests.swift @@ -0,0 +1,49 @@ +// +// NotifyFollowedBillRoyalAssentTests.swift +// epacTests +// + +@testable import epac +import XCTest + +@MainActor +final class NotifyFollowedBillRoyalAssentTests: XCTestCase { + func testSendsNotificationWithFormattedBody() async throws { + let port = MockRoyalAssentNotificationPort() + let useCase = NotifyFollowedBillRoyalAssent(notificationPort: port) + + let bill = Bill( + id: "C-226", + number: "C-226", + title: "National Framework for Food Price Transparency Act", + sponsorName: "Jane Smith", + status: .royalAssent, + currentStage: "Royal Assent", + introducedDate: nil, + royalAssentDate: nil, + summary: nil, + sponsorProfileURL: nil, + stages: [], + legisInfoURL: URL(string: "https://www.parl.ca")!, + type: .government, + parliament: 45, + session: 1 + ) + + try await useCase.execute(bill: bill) + + XCTAssertEqual(port.sentNotifications.count, 1) + let sent = port.sentNotifications.first + XCTAssertEqual(sent?.title, "C-226") + XCTAssertEqual(sent?.body, "National Framework for Food Price Transparency Act received Royal Assent today and is now law.") + } +} + +@MainActor +private final class MockRoyalAssentNotificationPort: RoyalAssentNotificationPort { + var sentNotifications: [RoyalAssentNotification] = [] + + func sendNotification(_ notification: RoyalAssentNotification) async throws { + sentNotifications.append(notification) + } +} diff --git a/ios/epacTests/SnapshotTests.swift b/ios/epacTests/SnapshotTests.swift index 22af763c..8c78fe8f 100644 --- a/ios/epacTests/SnapshotTests.swift +++ b/ios/epacTests/SnapshotTests.swift @@ -118,11 +118,22 @@ final class SnapshotTests: XCTestCase { // MARK: - BillRow - private static func makeBill(number: String, title: String, status: BillStatus, stage: String) -> Bill { + private static func makeBill( + number: String, + title: String, + status: BillStatus, + stage: String, + royalAssentDate: Date? = nil, + summary: String? = nil + ) -> Bill { Bill( id: number, number: number, title: title, sponsorName: "Jane Smith", status: status, currentStage: stage, - introducedDate: nil, stages: [], + introducedDate: nil, + royalAssentDate: royalAssentDate, + summary: summary, + sponsorProfileURL: URL(string: "https://www.ourcommons.ca/members/en/jane-smith(12345)"), + stages: [], legisInfoURL: URL(string: "https://www.parl.ca/legisinfo/en/bill/44-1/c-50")!, type: .government, parliament: 44, session: 1 ) @@ -149,6 +160,30 @@ final class SnapshotTests: XCTestCase { ) } + func testRecentlyBecameLawCard() { + let bill = Self.makeBill( + number: "C-12", + title: "Strengthening Canada's Immigration System and Borders Act", + status: .royalAssent, + stage: "Royal Assent", + royalAssentDate: Self.date("2026-06-10"), + summary: "An Act respecting certain measures relating to the security of Canada's borders and the integrity of the Canadian immigration system." + ) + + snapshot( + NavigationStack { + List { + Section("Recently Became Law") { + RecentlyBecameLawCard(bills: [bill]) + } + } + .listStyle(.insetGrouped) + } + .frame(width: 375, height: 360), + name: "RecentlyBecameLawCard" + ) + } + // MARK: - Bill lobbying context (EPAC-2159) func testBillLobbyingContextPanel_populated() { diff --git a/ios/epacTests/TrackRoyalAssentTests.swift b/ios/epacTests/TrackRoyalAssentTests.swift new file mode 100644 index 00000000..c0663c6a --- /dev/null +++ b/ios/epacTests/TrackRoyalAssentTests.swift @@ -0,0 +1,81 @@ +// +// TrackRoyalAssentTests.swift +// epacTests +// + +@testable import epac +import XCTest + +@MainActor +final class TrackRoyalAssentTests: XCTestCase { + func testRecentlyBecameLawFiltersToLastThirtyDaysAndSortsMostRecentFirst() async throws { + let repository = RoyalAssentBillRepository(bills: [ + Self.bill(number: "C-10", status: .royalAssent, royalAssentDate: Self.date("2026-06-10")), + Self.bill(number: "C-11", status: .royalAssent, royalAssentDate: Self.date("2026-05-20")), + Self.bill(number: "C-12", status: .royalAssent, royalAssentDate: Self.date("2026-04-30")), + Self.bill(number: "C-13", status: .inProgress, royalAssentDate: nil), + Self.bill(number: "S-1", status: .royalAssent, royalAssentDate: Self.date("2026-06-12")) + ]) + let useCase = TrackRoyalAssent( + repository: repository, + clock: RoyalAssentClock(now: Self.date("2026-06-12")), + calendar: Self.calendar + ) + + let bills = try await useCase.recentlyBecameLaw() + + XCTAssertEqual(bills.map(\.number), ["S-1", "C-10", "C-11"]) + } + + private static func bill(number: String, status: BillStatus, royalAssentDate: Date?) -> Bill { + Bill( + id: number, + number: number, + title: "An Act respecting \(number)", + sponsorName: "Jane Smith", + status: status, + currentStage: status == .royalAssent ? "Royal Assent" : "Second Reading", + introducedDate: nil, + royalAssentDate: royalAssentDate, + summary: "Long title for \(number).", + sponsorProfileURL: nil, + stages: [], + legisInfoURL: URL(string: "https://www.parl.ca/legisinfo/en/bill/45-1/\(number.lowercased())")!, + type: .government, + parliament: 45, + session: 1 + ) + } + + private static func date(_ value: String) -> Date { + dateFormatter.date(from: value)! + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +@MainActor +private struct RoyalAssentBillRepository: BillRepository { + let bills: [Bill] + + func fetchBills() async throws -> [Bill] { + bills + } +} + +private struct RoyalAssentClock: Clock { + let now: Date +} diff --git a/ios/epacTests/Views/Search/SearchViewModelTests.swift b/ios/epacTests/Views/Search/SearchViewModelTests.swift index b7dced46..1f5b2153 100644 --- a/ios/epacTests/Views/Search/SearchViewModelTests.swift +++ b/ios/epacTests/Views/Search/SearchViewModelTests.swift @@ -40,6 +40,9 @@ struct SearchViewModelTests { status: .inProgress, currentStage: "First Reading", introducedDate: nil, + royalAssentDate: nil, + summary: nil, + sponsorProfileURL: nil, stages: [], legisInfoURL: URL(string: "https://example.com/bills/C-42")!, type: .privateMember, From 07e18447c036172b2dc41fd8c3c5c2896c0b2e19 Mon Sep 17 00:00:00 2001 From: riddim-developer-bot Date: Sun, 14 Jun 2026 01:08:59 -0400 Subject: [PATCH 2/2] [EPAC-691]: Add new Bill parameters to fallbackBill and LoadFollowedBillsTests CI iOS Build failed because the rebase landed alongside main's fallbackBill helper and LoadFollowedBills test, both of which construct Bill without the royalAssentDate / summary / sponsorProfileURL parameters this PR adds. Pass nil for each in both call sites. --- ios/epac/Views/Home/HomeFeedView.swift | 3 +++ ios/epacTests/Domain/LoadFollowedBillsTests.swift | 3 +++ 2 files changed, 6 insertions(+) diff --git a/ios/epac/Views/Home/HomeFeedView.swift b/ios/epac/Views/Home/HomeFeedView.swift index 8d84944d..c94d2b03 100644 --- a/ios/epac/Views/Home/HomeFeedView.swift +++ b/ios/epac/Views/Home/HomeFeedView.swift @@ -508,6 +508,9 @@ struct HomeFeedView: View { status: followed.status, currentStage: followed.currentStage, introducedDate: followed.lastUpdateTimestamp, + royalAssentDate: nil, + summary: nil, + sponsorProfileURL: nil, stages: [], legisInfoURL: URL(string: "https://www.parl.ca")!, type: .unknown, diff --git a/ios/epacTests/Domain/LoadFollowedBillsTests.swift b/ios/epacTests/Domain/LoadFollowedBillsTests.swift index 14b488bc..f2cb2718 100644 --- a/ios/epacTests/Domain/LoadFollowedBillsTests.swift +++ b/ios/epacTests/Domain/LoadFollowedBillsTests.swift @@ -35,6 +35,9 @@ final class LoadFollowedBillsTests: XCTestCase { status: .inProgress, currentStage: "Third Reading", introducedDate: now, + royalAssentDate: nil, + summary: nil, + sponsorProfileURL: nil, stages: [], legisInfoURL: URL(string: "https://parl.ca")!, type: .government,