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..c94d2b03 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 @@ -489,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, @@ -874,6 +896,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 +975,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/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, 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,