diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index e185aba9..6fef35e6 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -1445,6 +1445,28 @@ Current implementation: --- +### LoadMPAttendance + +``` +Actor: User (iOS app, foreground) +Goal: Compute an MP's attendance record (attendance rate, breakdown) and compare it against national/party baselines. +Inputs: Member ID. +Outputs: MPAttendance value object containing an AttendanceRecord and optional AttendanceComparison. +Entities / values: ParliamentMember, AttendanceRecord, AttendanceComparison, MPAttendance. +Ports: iOS Swift: `MPAttendanceQueryPort`. +Primary adapters: SwiftDataMPAttendanceAdapter, MemberAttendanceSection, AttendanceRecordCard. +Current implementation: + ios/epac/Domain/Ports/MPAttendanceQueryPort.swift + ios/epac/Domain/MPAttendance.swift + ios/epac/Application/LoadMPAttendance.swift + ios/epac/Data/Adapters/SwiftDataMPAttendanceAdapter.swift + ios/epac/Views/Members/MemberAttendanceSection.swift +``` + +> Boundary rule: The use case and domain types must not import SwiftData, SwiftUI, or UIKit. The adapter handles raw SwiftData tally querying; presentation policy (paired absences are handled distinctly from unexplained ones) lives in the use case. + +--- + ## Boundary Check Run locally to verify inward files do not import framework types: diff --git a/ios/epac/Application/LoadMPAttendance.swift b/ios/epac/Application/LoadMPAttendance.swift new file mode 100644 index 00000000..35cbdd8b --- /dev/null +++ b/ios/epac/Application/LoadMPAttendance.swift @@ -0,0 +1,85 @@ +// +// LoadMPAttendance.swift +// epac +// +// Use case: compute an MP's attendance record and national / party comparison +// from recorded-division tallies (EPAC-897). No new ingestion — this applies a +// new lens to the vote data already gathered for the voting record (EPAC-23). +// +// This is where the policy lives: a *paired* absence (a pre-arranged pairing, +// common for travelling ministers) is reported separately from an unexplained +// absence, so the two are never conflated. +// + +import Foundation + +struct LoadMPAttendance: Sendable { + /// Minimum divisions an MP needs before they count toward a comparison + /// average — keeps a single procedural vote from skewing the baseline. + static let minimumDivisionsForComparison = 5 + + private let port: any MPAttendanceQueryPort + + init(port: any MPAttendanceQueryPort) { + self.port = port + } + + /// Returns the member's attendance record plus an optional comparison, or + /// `nil` when the member has no recorded divisions to summarise. + @MainActor + func execute(memberID: Int) async throws -> MPAttendance? { + guard let tally = try await port.tally(forMemberID: memberID), + tally.totalDivisions > 0 else { return nil } + + let record = AttendanceRecord( + totalDivisions: tally.totalDivisions, + yea: tally.yea, + nay: tally.nay, + paired: tally.paired, + denominatorStartDate: tally.denominatorStartDate + ) + let comparison = try? await comparison(for: tally) + return MPAttendance(record: record, comparison: comparison) + } + + /// Builds national and same-party baselines from *other* MPs with enough + /// recorded data. Each baseline is `nil` (and hidden by the UI) when too few + /// peers are available, mirroring `PartyLineScoreView`'s graceful degradation. + @MainActor + private func comparison(for member: MemberDivisionTally) async throws -> AttendanceComparison? { + let peers = try await port.allTallies().filter { + $0.memberID != member.memberID && + $0.totalDivisions >= Self.minimumDivisionsForComparison + } + guard !peers.isEmpty else { return nil } + + let nationalRates = peers.map(Self.attendanceRate) + let partyRates = peers.filter { $0.party == member.party }.map(Self.attendanceRate) + + let national = average(of: nationalRates) + let party = average(of: partyRates) + guard national != nil || party != nil else { return nil } + + return AttendanceComparison( + party: member.party, + nationalAverageRate: national, + partyAverageRate: party, + nationalSampleSize: nationalRates.count, + partySampleSize: partyRates.count + ) + } + + /// Present-rate (Yea + Nay over total divisions) for a single tally — the + /// same definition used for the headline figure, so comparisons are apples + /// to apples. + static func attendanceRate(_ tally: MemberDivisionTally) -> Double { + tally.totalDivisions > 0 + ? Double(tally.yea + tally.nay) / Double(tally.totalDivisions) + : 0 + } + + private func average(of rates: [Double]) -> Double? { + guard !rates.isEmpty else { return nil } + return rates.reduce(0, +) / Double(rates.count) + } +} diff --git a/ios/epac/Data/Adapters/SwiftDataMPAttendanceAdapter.swift b/ios/epac/Data/Adapters/SwiftDataMPAttendanceAdapter.swift new file mode 100644 index 00000000..c941f1ea --- /dev/null +++ b/ios/epac/Data/Adapters/SwiftDataMPAttendanceAdapter.swift @@ -0,0 +1,156 @@ +// +// SwiftDataMPAttendanceAdapter.swift +// epac +// +// Adapter for `MPAttendanceQueryPort` backed by the local SwiftData store +// (EPAC-897). The division universe is the set of federal `RecordedVote`s +// already synced for the voting record; a member's ballots come from their +// `MemberVote` rows. Divisions before a member's sworn-in date are never +// counted, so new and by-election MPs are judged only on their own term. +// +// This adapter surfaces raw counts (Yea / Nay / Paired + total divisions) and +// applies no presentation policy — that is `LoadMPAttendance`'s job. +// + +import Foundation +import SwiftData + +@MainActor +struct SwiftDataMPAttendanceAdapter: MPAttendanceQueryPort { + private let modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func tally(forMemberID memberID: Int) async throws -> MemberDivisionTally? { + let divisionDates = try federalDivisionDates() + guard !divisionDates.isEmpty, + let member = try member(withID: memberID) else { return nil } + let votes = try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.memberID == memberID } + )) + return tally(for: member, votes: votes, divisionDates: divisionDates) + } + + func allTallies() async throws -> [MemberDivisionTally] { + let divisionDates = try federalDivisionDates() + guard !divisionDates.isEmpty else { return [] } + + let members = try modelContext.fetch(FetchDescriptor()) + .filter { $0.memberID > 0 && $0.jurisdiction == .federal } + let votesByMember = Dictionary( + grouping: try modelContext.fetch(FetchDescriptor()), + by: \.memberID + ) + + // Counting eligible divisions is keyed only on the sworn-in date, so + // memoise it — most sitting members share the same (or no) start date. + var eligibleCountCache: [Date?: Int] = [:] + return members.compactMap { member in + guard let votes = votesByMember[member.memberID], !votes.isEmpty else { return nil } + return tally( + for: member, + votes: votes, + divisionDates: divisionDates, + eligibleCountCache: &eligibleCountCache + ) + } + } + + // MARK: - Tally + + private func tally( + for member: ParliamentMember, + votes: [MemberVote], + divisionDates: [Int: Date], + eligibleCountCache: inout [Date?: Int] + ) -> MemberDivisionTally { + let start = member.fromDateTime + let eligibleCount: Int + if let cached = eligibleCountCache[start] { + eligibleCount = cached + } else { + eligibleCount = countEligibleDivisions(divisionDates, since: start) + eligibleCountCache[start] = eligibleCount + } + return buildTally(member: member, votes: votes, divisionDates: divisionDates, start: start, eligibleCount: eligibleCount) + } + + private func tally( + for member: ParliamentMember, + votes: [MemberVote], + divisionDates: [Int: Date] + ) -> MemberDivisionTally { + let start = member.fromDateTime + let eligibleCount = countEligibleDivisions(divisionDates, since: start) + return buildTally(member: member, votes: votes, divisionDates: divisionDates, start: start, eligibleCount: eligibleCount) + } + + private func buildTally( + member: ParliamentMember, + votes: [MemberVote], + divisionDates: [Int: Date], + start: Date?, + eligibleCount: Int + ) -> MemberDivisionTally { + var yea = 0, nay = 0, paired = 0 + for vote in votes { + // Only count ballots on divisions in the eligible window. A ballot + // whose division isn't in the local store (e.g. a prior parliament) + // is skipped so it can't outweigh the denominator. + guard let date = divisionDates[vote.voteID], isEligible(date, since: start) else { continue } + incrementTally(vote: vote, yea: &yea, nay: &nay, paired: &paired) + } + // The denominator is the eligible-division count, but never fewer than + // this member's own recorded participations (a safety floor). + let total = max(eligibleCount, yea + nay + paired) + return MemberDivisionTally( + memberID: member.memberID, + party: member.party, + yea: yea, + nay: nay, + paired: paired, + totalDivisions: total, + denominatorStartDate: start + ) + } + + private func incrementTally(vote: MemberVote, yea: inout Int, nay: inout Int, paired: inout Int) { + switch vote.recordedVote.lowercased() { + case "yea": yea += 1 + case "nay": nay += 1 + case "paired": paired += 1 + default: break // "Absent" / other — folded into derived absences + } + } + + // MARK: - Division universe + + /// Maps each federal division's `voteID` to its date. + private func federalDivisionDates() throws -> [Int: Date] { + let federal = Jurisdiction.federal.rawValue + let divisions = try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.jurisdiction == federal } + )) + return Dictionary(divisions.map { ($0.voteID, $0.date) }, uniquingKeysWith: { first, _ in first }) + } + + private func countEligibleDivisions(_ divisionDates: [Int: Date], since start: Date?) -> Int { + guard let start else { return divisionDates.count } + return divisionDates.values.reduce(into: 0) { count, date in + if date >= start { count += 1 } + } + } + + private func isEligible(_ date: Date, since start: Date?) -> Bool { + guard let start else { return true } + return date >= start + } + + private func member(withID memberID: Int) throws -> ParliamentMember? { + try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.memberID == memberID } + )).first + } +} diff --git a/ios/epac/Domain/MPAttendance.swift b/ios/epac/Domain/MPAttendance.swift new file mode 100644 index 00000000..974efa96 --- /dev/null +++ b/ios/epac/Domain/MPAttendance.swift @@ -0,0 +1,81 @@ +// +// MPAttendance.swift +// epac +// +// Attendance value objects for an MP's recorded-division record (EPAC-897). +// These are computed from already-ingested vote data (`MemberVote.type`): +// every recorded division marks each MP as Yea, Nay, Paired, or Absent. +// +// Domain layer: no persistence and no presentation concepts. The +// "a paired absence is not an unexplained absence" rule lives in the +// `LoadMPAttendance` use case, not here and not in the data adapter. +// + +import Foundation + +/// Raw per-member division counts surfaced by the data adapter. The adapter +/// reports counts only; deriving the absence count, the attendance rate, and +/// any comparison is the use case's job (EPAC-897 boundary rule). +struct MemberDivisionTally: Sendable, Equatable { + let memberID: Int + let party: Party + /// Divisions where the member voted Yea. + let yea: Int + /// Divisions where the member voted Nay. + let nay: Int + /// Divisions where the member was Paired (a pre-arranged, cancelled-out absence). + let paired: Int + /// Total recorded divisions held while this member was a sitting member — + /// the denominator. Divisions before the member was sworn in are excluded. + let totalDivisions: Int + /// The date the member was sworn in, when known; powers the "since " label. + let denominatorStartDate: Date? +} + +/// A computed attendance record for one MP — how often they showed up to vote. +/// +/// `absent` is *derived* (`total − yea − nay − paired`) rather than counted, so +/// the figure is correct whether or not the source feed emits explicit "Absent" +/// rows for divisions a member skipped. +struct AttendanceRecord: Sendable, Equatable { + let totalDivisions: Int + let yea: Int + let nay: Int + let paired: Int + let denominatorStartDate: Date? + + /// Divisions where the member cast a recorded Yea or Nay — i.e. was present. + var present: Int { yea + nay } + + /// Divisions missed *without* a pairing arrangement (unexplained absences). + /// Kept distinct from `paired` so ministers' pre-arranged absences are not + /// lumped in with simply not showing up. + var absent: Int { max(0, totalDivisions - yea - nay - paired) } + + /// Fraction of divisions the member was present for, in `0...1`. + var attendanceRate: Double { + totalDivisions > 0 ? Double(present) / Double(totalDivisions) : 0 + } +} + +/// National and same-party attendance baselines an MP can be compared against. +/// Each average is computed across *other* MPs for whom recorded vote data is +/// available locally; a `nil` average means there was not enough data to show one. +struct AttendanceComparison: Sendable, Equatable { + let party: Party + /// Mean attendance rate across other MPs with recorded data (`0...1`), or `nil`. + let nationalAverageRate: Double? + /// Mean attendance rate across other same-party MPs (`0...1`), or `nil`. + let partyAverageRate: Double? + /// Number of other MPs in the national sample (shown so the figure is honest). + let nationalSampleSize: Int + /// Number of other same-party MPs in the party sample. + let partySampleSize: Int +} + +/// Combined result returned by `LoadMPAttendance`: the MP's own record plus an +/// optional comparison against national and party baselines. +struct MPAttendance: Sendable, Equatable { + let record: AttendanceRecord + let comparison: AttendanceComparison? +} diff --git a/ios/epac/Domain/Ports/MPAttendanceQueryPort.swift b/ios/epac/Domain/Ports/MPAttendanceQueryPort.swift new file mode 100644 index 00000000..525b09c4 --- /dev/null +++ b/ios/epac/Domain/Ports/MPAttendanceQueryPort.swift @@ -0,0 +1,20 @@ +// +// MPAttendanceQueryPort.swift +// epac +// +// Port for reading recorded-division counts out of the votes data store. +// Implementations surface raw tallies only — the policy decision that a +// paired absence is treated differently from an unexplained one is applied +// by `LoadMPAttendance`, never by the adapter (EPAC-897 boundary rule). +// + +@MainActor +protocol MPAttendanceQueryPort: Sendable { + /// Tally for a single member, or `nil` when there are no recorded divisions + /// for that member while they were sitting. + func tally(forMemberID memberID: Int) async throws -> MemberDivisionTally? + + /// Tallies for every member with recorded vote data, used to build the + /// national and party comparison baselines. + func allTallies() async throws -> [MemberDivisionTally] +} diff --git a/ios/epac/Model/Migration.swift b/ios/epac/Model/Migration.swift index 418208ba..4c508972 100644 --- a/ios/epac/Model/Migration.swift +++ b/ios/epac/Model/Migration.swift @@ -18,7 +18,7 @@ enum EpacMigrationPlan: SchemaMigrationPlan { // non-optional `contactFetched: Bool`. SwiftData can't infer a default for // non-optional properties during lightweight migration, so didMigrate sets // it explicitly on all pre-existing records. - static let migrateV3toV4 = MigrationStage.custom( + nonisolated(unsafe) static let migrateV3toV4 = MigrationStage.custom( fromVersion: SchemaV3.self, toVersion: SchemaV4.self, willMigrate: nil, @@ -33,28 +33,28 @@ enum EpacMigrationPlan: SchemaMigrationPlan { // Lightweight stage: V5 adds RecordedVote and MemberVote. Adding new model // types with no removal or rename is always a safe lightweight migration. - static let migrateV4toV5 = MigrationStage.lightweight( + nonisolated(unsafe) static let migrateV4toV5 = MigrationStage.lightweight( fromVersion: SchemaV4.self, toVersion: SchemaV5.self ) // Lightweight stage: V6 adds WrittenQuestion. Pure new table, no existing // model changes. - static let migrateV5toV6 = MigrationStage.lightweight( + nonisolated(unsafe) static let migrateV5toV6 = MigrationStage.lightweight( fromVersion: SchemaV5.self, toVersion: SchemaV6.self ) // Lightweight stage: V7 adds FiscalMonitorEntry. Pure new table, no // existing model changes. - static let migrateV6toV7 = MigrationStage.lightweight( + nonisolated(unsafe) static let migrateV6toV7 = MigrationStage.lightweight( fromVersion: SchemaV6.self, toVersion: SchemaV7.self ) // Lightweight stage: V8 adds CabinetPosition. Pure new table, no // existing model changes. - static let migrateV7toV8 = MigrationStage.lightweight( + nonisolated(unsafe) static let migrateV7toV8 = MigrationStage.lightweight( fromVersion: SchemaV7.self, toVersion: SchemaV8.self ) @@ -62,7 +62,7 @@ enum EpacMigrationPlan: SchemaMigrationPlan { // Custom stage: V9 makes ParliamentMember jurisdiction-aware and switches // uniqueness to a stored directoryKey so federal and provincial members can // coexist without name collisions. - static let migrateV8toV9 = MigrationStage.custom( + nonisolated(unsafe) static let migrateV8toV9 = MigrationStage.custom( fromVersion: SchemaV8.self, toVersion: SchemaV9.self, willMigrate: nil, @@ -80,7 +80,7 @@ enum EpacMigrationPlan: SchemaMigrationPlan { ) // Custom stage: V10 adds jurisdiction string to RecordedVote and MemberVote. - static let migrateV9toV10 = MigrationStage.custom( + nonisolated(unsafe) static let migrateV9toV10 = MigrationStage.custom( fromVersion: SchemaV9.self, toVersion: SchemaV10.self, willMigrate: nil, @@ -99,7 +99,7 @@ enum EpacMigrationPlan: SchemaMigrationPlan { // Lightweight stage: V11 adds MinisterialExpenseRecord. Pure new table, no // existing model changes. Data is re-seeded from the bundled JSON on next launch. - static let migrateV10toV11 = MigrationStage.lightweight( + nonisolated(unsafe) static let migrateV10toV11 = MigrationStage.lightweight( fromVersion: SchemaV10.self, toVersion: SchemaV11.self ) diff --git a/ios/epac/Views/Members/MemberAttendanceSection.swift b/ios/epac/Views/Members/MemberAttendanceSection.swift new file mode 100644 index 00000000..0d357cc7 --- /dev/null +++ b/ios/epac/Views/Members/MemberAttendanceSection.swift @@ -0,0 +1,255 @@ +// +// MemberAttendanceSection.swift +// epac +// +// MP attendance record on the member profile (EPAC-897): how often an MP is +// present for recorded divisions, with a Yea / Nay / Paired / Absent breakdown +// and a national / party comparison. The figures are computed by +// `LoadMPAttendance` from vote data already synced for the voting record +// (EPAC-23) — no new ingestion, just a new lens on existing data. +// + +import SwiftData +import SwiftUI + +private enum AttendanceLayout { + static let cardSpacing = EpacSpacing.s + static let sectionSpacing = EpacSpacing.xs + static let barHeight: CGFloat = 12 + static let barCornerRadius: CGFloat = 4 + static let legendSwatch: CGFloat = 10 + static let rowSpacing = EpacSpacing.xs + static let arrowSpacing = EpacSpacing.xxs +} + +/// Loads the attendance record for one MP and renders the card once available. +/// Mirrors `PartyLineScoreView`: an `@State` value filled by a `.task`, with the +/// card shown only when there is something to show. +struct MemberAttendanceSection: View { + let member: ParliamentMember + + @Environment(\.modelContext) private var modelContext + @State private var attendance: MPAttendance? + + var body: some View { + Group { + if let attendance { + AttendanceRecordCard(member: member, attendance: attendance) + } + } + .task(id: member.memberID) { await load() } + } + + @MainActor + private func load() async { + attendance = nil + let useCase = LoadMPAttendance(port: SwiftDataMPAttendanceAdapter(modelContext: modelContext)) + attendance = try? await useCase.execute(memberID: member.memberID) + } +} + +/// Presentation-only attendance card. Takes an already-computed `MPAttendance`, +/// so it renders without touching SwiftData and can be snapshot-tested directly. +struct AttendanceRecordCard: View { + let member: ParliamentMember + let attendance: MPAttendance + + @State private var showInfo = false + + private var record: AttendanceRecord { attendance.record } + + var body: some View { + VStack(alignment: .leading, spacing: AttendanceLayout.cardSpacing) { + header + + Text(String(format: NSLocalizedString("attendance.rate", comment: ""), + percent(record.attendanceRate))) + .font(.title2.weight(.semibold)) + + Text(denominatorText) + .font(.caption2) + .foregroundStyle(.secondary) + + breakdownBar + legend + + if record.paired > 0 { + Text(String(format: NSLocalizedString("attendance.paired.note", comment: ""), record.paired)) + .font(.caption2) + .foregroundStyle(.secondary) + } + + if let comparison = attendance.comparison { + comparisonSection(comparison) + } + + sourceFooter + } + .padding() + .background(Color.appSurface) + .cornerRadius(EpacCornerRadius.m) + .regularSizeClassFormSheet(isPresented: $showInfo) { infoSheet } + } + + // MARK: - Header + + private var header: some View { + HStack { + Text(NSLocalizedString("attendance.title", comment: "")) + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button { showInfo = true } label: { + Image(systemName: "info.circle").foregroundStyle(.secondary) + } + .accessibilityLabel(NSLocalizedString("attendance.infoButton", comment: "")) + } + } + + private var denominatorText: String { + if let start = record.denominatorStartDate { + return String(format: NSLocalizedString("attendance.denominator.sinceDate", comment: ""), + record.present, record.totalDivisions, + start.formatted(date: .abbreviated, time: .omitted)) + } + return String(format: NSLocalizedString("attendance.denominator.noDate", comment: ""), + record.present, record.totalDivisions) + } + + // MARK: - Breakdown bar + legend + + // Colours come from the shared design-token palette. Unlike `Color.ballot` + // (per-ballot colouring on the voting screen, where Paired is the amber + // highlight), the attendance lens deliberately makes a *paired* absence + // neutral and an *unexplained* absence the amber highlight — EPAC-897 requires + // that pre-arranged pairings are not visually penalised like simply not + // showing up. + private var segments: [AttendanceSegment] { + [ + AttendanceSegment(id: "yea", count: record.yea, color: .appPositive), + AttendanceSegment(id: "nay", count: record.nay, color: .appDestructive), + AttendanceSegment(id: "paired", count: record.paired, color: .appNeutral), + AttendanceSegment(id: "absent", count: record.absent, color: .appWarning) + ] + } + + private var breakdownBar: some View { + GeometryReader { geo in + HStack(spacing: 0) { + ForEach(segments) { segment in + segment.color + .frame(width: width(for: segment.count, in: geo.size.width)) + } + } + } + .frame(height: AttendanceLayout.barHeight) + .clipShape(RoundedRectangle(cornerRadius: AttendanceLayout.barCornerRadius)) + .accessibilityElement() + .accessibilityLabel(String(format: NSLocalizedString("attendance.bar.accessibility", comment: ""), + record.yea, record.nay, record.paired, record.absent, record.totalDivisions)) + } + + private func width(for count: Int, in total: CGFloat) -> CGFloat { + guard record.totalDivisions > 0 else { return 0 } + return total * CGFloat(count) / CGFloat(record.totalDivisions) + } + + private var legend: some View { + VStack(spacing: AttendanceLayout.rowSpacing) { + legendRow(color: .appPositive, label: NSLocalizedString("attendance.legend.yea", comment: ""), count: record.yea) + legendRow(color: .appDestructive, label: NSLocalizedString("attendance.legend.nay", comment: ""), count: record.nay) + legendRow(color: .appNeutral, label: NSLocalizedString("attendance.legend.paired", comment: ""), count: record.paired) + legendRow(color: .appWarning, label: NSLocalizedString("attendance.legend.absent", comment: ""), count: record.absent) + } + } + + private func legendRow(color: Color, label: String, count: Int) -> some View { + HStack(spacing: EpacSpacing.xs) { + Circle().fill(color) + .frame(width: AttendanceLayout.legendSwatch, height: AttendanceLayout.legendSwatch) + Text(label).font(.caption) + Spacer() + Text(count.formatted()).font(.caption.weight(.medium)).monospacedDigit() + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(label): \(count)") + } + + // MARK: - Comparison + + private func comparisonSection(_ comparison: AttendanceComparison) -> some View { + VStack(alignment: .leading, spacing: AttendanceLayout.rowSpacing) { + Divider() + Text(NSLocalizedString("attendance.comparison.title", comment: "")) + .font(.caption).foregroundStyle(.secondary) + if let national = comparison.nationalAverageRate { + comparisonRow(label: NSLocalizedString("attendance.comparison.national", comment: ""), + baseline: national) + } + if let party = comparison.partyAverageRate { + comparisonRow(label: String(format: NSLocalizedString("attendance.comparison.party", comment: ""), + member.party.shortName), + baseline: party) + } + Text(String(format: NSLocalizedString("attendance.comparison.method", comment: ""), + LoadMPAttendance.minimumDivisionsForComparison, comparison.nationalSampleSize)) + .font(.caption2).foregroundStyle(.secondary) + } + } + + private func comparisonRow(label: String, baseline: Double) -> some View { + let above = record.attendanceRate >= baseline + return HStack(spacing: AttendanceLayout.arrowSpacing) { + Image(systemName: above ? "arrow.up" : "arrow.down") + .font(.caption2) + .foregroundStyle(above ? Color.appPositive : Color.appWarning) + Text(label).font(.subheadline) + Spacer() + Text(String(format: NSLocalizedString("attendance.percent", comment: ""), percent(baseline))) + .font(.subheadline.weight(.medium)).monospacedDigit() + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(label): \(percent(baseline))%") + } + + // MARK: - Source + info + + private var sourceFooter: some View { + HStack(spacing: EpacSpacing.xxs) { + Text(NSLocalizedString("attendance.source.prefix", comment: "")) + Link(NSLocalizedString("attendance.source.title", comment: ""), destination: DataSource.votes().url) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private var infoSheet: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: EpacSpacing.m) { + Text(NSLocalizedString("attendance.info.body", comment: "")) + .font(.body) + } + .padding() + } + .navigationTitle(NSLocalizedString("attendance.info.title", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(NSLocalizedString("attendance.info.done", comment: "")) { showInfo = false } + } + } + } + .presentationDetents([.medium]) + } + + private func percent(_ rate: Double) -> Int { + Int((rate * 100).rounded()) + } +} + +/// One coloured slice of the attendance breakdown bar. +private struct AttendanceSegment: Identifiable { + let id: String + let count: Int + let color: Color +} diff --git a/ios/epac/Views/Members/MemberProfileView.swift b/ios/epac/Views/Members/MemberProfileView.swift index 8eba8003..0aeda2d3 100644 --- a/ios/epac/Views/Members/MemberProfileView.swift +++ b/ios/epac/Views/Members/MemberProfileView.swift @@ -157,6 +157,8 @@ struct MemberProfileView: View { PartyLineScoreView(member: member) + MemberAttendanceSection(member: member) + VStack(alignment: .leading, spacing: MemberProfileLayout.cardSpacing) { NavigationLink(destination: partyDestination(for: member.party)) { HStack(spacing: 0) { @@ -654,6 +656,8 @@ struct CabinetPositionSection: View { /// tabled witness invitations. Best-effort: hidden when attribution data /// is unavailable (tabling member is not consistently present in committee records). private struct MemberCommitteeWitnessesSection: View { + private static let maxOrganizations = 5 + let member: ParliamentMember @State private var organizations: [WitnessOrganization] = [] @@ -661,7 +665,7 @@ private struct MemberCommitteeWitnessesSection: View { Group { if !organizations.isEmpty { DisclosureGroup { - ForEach(organizations.prefix(5)) { org in + ForEach(organizations.prefix(Self.maxOrganizations)) { org in NavigationLink( destination: WitnessOrganizationView( organizationName: org.displayName, diff --git a/ios/epac/en.lproj/Localizable.strings b/ios/epac/en.lproj/Localizable.strings index ac988aab..4d2d93a4 100644 --- a/ios/epac/en.lproj/Localizable.strings +++ b/ios/epac/en.lproj/Localizable.strings @@ -219,6 +219,29 @@ "partyLine.info.body" = "This score shows how often this MP voted the same way as the majority of their party caucus on recorded votes. Paired votes and absences are excluded. A higher score means the MP almost always follows the party whip; a lower score signals independence. The score is computed from vote records published by the Parliament of Canada."; "partyLine.info.done" = "Done"; +/* MP attendance record (EPAC-897) */ +"attendance.title" = "Attendance record"; +"attendance.infoButton" = "About this record"; +"attendance.rate" = "Present %d%% of the time"; +"attendance.denominator.sinceDate" = "%1$d of %2$d divisions since %3$@"; +"attendance.denominator.noDate" = "%1$d of %2$d recorded divisions"; +"attendance.percent" = "%d%%"; +"attendance.legend.yea" = "Yea"; +"attendance.legend.nay" = "Nay"; +"attendance.legend.paired" = "Paired"; +"attendance.legend.absent" = "Absent"; +"attendance.bar.accessibility" = "%1$d Yea, %2$d Nay, %3$d Paired, %4$d Absent of %5$d divisions"; +"attendance.paired.note" = "Includes %d paired absences — pre-arranged with an opposing member, not the same as missing a vote."; +"attendance.comparison.title" = "How this compares"; +"attendance.comparison.national" = "National average"; +"attendance.comparison.party" = "%@ caucus"; +"attendance.comparison.method" = "Averages cover MPs with at least %1$d recorded divisions (%2$d nationally)."; +"attendance.source.prefix" = "Source:"; +"attendance.source.title" = "Parliament.ca open API — Recorded Votes"; +"attendance.info.title" = "About Attendance"; +"attendance.info.body" = "This shows how often this MP was present to vote on recorded divisions. \"Present\" counts every division where the MP cast a recorded Yea or Nay. A paired absence is a pre-arranged agreement with an opposing member to both sit out a vote so the result is unaffected; it is shown separately and is not treated the same as simply missing a vote. Divisions held before the MP was sworn in are never counted, so newer and by-election members are judged only on their own time in office. Figures are computed from recorded votes published by the Parliament of Canada."; +"attendance.info.done" = "Done"; + /* Contact MP */ "contact.writeToMP" = "Write to MP"; diff --git a/ios/epac/fr.lproj/Localizable.strings b/ios/epac/fr.lproj/Localizable.strings index 7c340ce6..42a93a5a 100644 --- a/ios/epac/fr.lproj/Localizable.strings +++ b/ios/epac/fr.lproj/Localizable.strings @@ -221,6 +221,29 @@ "partyLine.info.body" = "Ce score indique à quelle fréquence ce député a voté de la même façon que la majorité de son caucus lors des votes enregistrés. Les votes jumelés et les absences sont exclus. Un score élevé signifie que le député suit presque toujours la ligne du parti; un score faible indique une indépendance. Le score est calculé à partir des dossiers de vote publiés par le Parlement du Canada."; "partyLine.info.done" = "Terminé"; +/* MP attendance record (EPAC-897) */ +"attendance.title" = "Registre des présences"; +"attendance.infoButton" = "À propos de ce registre"; +"attendance.rate" = "Présent(e) %d%% du temps"; +"attendance.denominator.sinceDate" = "%1$d de %2$d votes depuis le %3$@"; +"attendance.denominator.noDate" = "%1$d de %2$d votes enregistrés"; +"attendance.percent" = "%d%%"; +"attendance.legend.yea" = "Pour"; +"attendance.legend.nay" = "Contre"; +"attendance.legend.paired" = "Jumelé"; +"attendance.legend.absent" = "Absent"; +"attendance.bar.accessibility" = "%1$d Pour, %2$d Contre, %3$d Jumelé, %4$d Absent de %5$d votes"; +"attendance.paired.note" = "Comprend %d absences jumelées — ententes préalables avec un député de l'opposition, ce qui diffère d'un vote manqué."; +"attendance.comparison.title" = "Comparaison"; +"attendance.comparison.national" = "Moyenne nationale"; +"attendance.comparison.party" = "Caucus %@"; +"attendance.comparison.method" = "Les moyennes couvrent les députés ayant au moins %1$d votes enregistrés (%2$d à l'échelle nationale)."; +"attendance.source.prefix" = "Source :"; +"attendance.source.title" = "API ouverte de Parlement.ca — Votes enregistrés"; +"attendance.info.title" = "À propos des présences"; +"attendance.info.body" = "Ce registre indique à quelle fréquence ce député a été présent lors des votes enregistrés. « Présent » compte chaque vote où le député a exprimé un vote Pour ou Contre. Une absence jumelée est une entente préalable avec un député de l'opposition pour s'abstenir tous deux d'un vote, de sorte que le résultat n'est pas affecté; elle est indiquée séparément et n'est pas traitée de la même manière qu'un simple vote manqué. Les votes tenus avant que le député ne prête serment ne sont jamais comptés. Les chiffres sont calculés à partir des votes enregistrés publiés par le Parlement du Canada."; +"attendance.info.done" = "Terminé"; + /* Contact MP */ "contact.writeToMP" = "Écrire à mon député"; diff --git a/ios/epacTests/Application/LoadMPAttendanceTests.swift b/ios/epacTests/Application/LoadMPAttendanceTests.swift new file mode 100644 index 00000000..200c0712 --- /dev/null +++ b/ios/epacTests/Application/LoadMPAttendanceTests.swift @@ -0,0 +1,84 @@ +// +// LoadMPAttendanceTests.swift +// epacTests +// +// Tests for LoadMPAttendance use case (EPAC-897). +// + +@testable import epac +import Foundation +import Testing + +@MainActor +struct LoadMPAttendanceTests { + @Test func returnsNilWhenTallyIsEmptyOrZeroDivisions() async throws { + let port = MPAttendanceQueryPortSpy() + let useCase = LoadMPAttendance(port: port) + + let result = try await useCase.execute(memberID: 1) + #expect(result == nil) + } + + @Test func computesCorrectAttendanceRates() async throws { + let port = MPAttendanceQueryPortSpy() + let swornInDate = Date() + port.tallies = [ + MemberDivisionTally( + memberID: 1, + party: .liberal, + yea: 10, + nay: 5, + paired: 3, + totalDivisions: 20, + denominatorStartDate: swornInDate + ) + ] + let useCase = LoadMPAttendance(port: port) + + let result = try await useCase.execute(memberID: 1) + #expect(result != nil) + let record = result!.record + #expect(record.totalDivisions == 20) + #expect(record.yea == 10) + #expect(record.nay == 5) + #expect(record.paired == 3) + #expect(record.present == 15) + #expect(record.absent == 2) // 20 - 15 - 3 + #expect(record.attendanceRate == 15.0 / 20.0) + #expect(record.denominatorStartDate == swornInDate) + } + + @Test func computesComparisonCorrectly() async throws { + let port = MPAttendanceQueryPortSpy() + let target = MemberDivisionTally(memberID: 1, party: .liberal, yea: 8, nay: 2, paired: 0, totalDivisions: 10, denominatorStartDate: nil) + let peer1 = MemberDivisionTally(memberID: 2, party: .liberal, yea: 9, nay: 0, paired: 1, totalDivisions: 10, denominatorStartDate: nil) // rate: 0.9 + let peer2 = MemberDivisionTally(memberID: 3, party: .conservative, yea: 5, nay: 0, paired: 0, totalDivisions: 10, denominatorStartDate: nil) // rate: 0.5 + let peerTooFew = MemberDivisionTally(memberID: 4, party: .liberal, yea: 3, nay: 0, paired: 0, totalDivisions: 3, denominatorStartDate: nil) // excluded (< 5 divisions) + + port.tallies = [target, peer1, peer2, peerTooFew] + let useCase = LoadMPAttendance(port: port) + + let result = try await useCase.execute(memberID: 1) + #expect(result != nil) + let comparison = result!.comparison + #expect(comparison != nil) + #expect(comparison!.party == .liberal) + #expect(comparison!.nationalSampleSize == 2) // peer1 and peer2 + #expect(comparison!.partySampleSize == 1) // peer1 + #expect(comparison!.nationalAverageRate == 0.7) // (0.9 + 0.5) / 2 + #expect(comparison!.partyAverageRate == 0.9) // peer1 + } +} + +@MainActor +private final class MPAttendanceQueryPortSpy: MPAttendanceQueryPort { + var tallies: [MemberDivisionTally] = [] + + func tally(forMemberID memberID: Int) async throws -> MemberDivisionTally? { + tallies.first { $0.memberID == memberID } + } + + func allTallies() async throws -> [MemberDivisionTally] { + tallies + } +} diff --git a/ios/epacTests/Data/SwiftDataMPAttendanceAdapterTests.swift b/ios/epacTests/Data/SwiftDataMPAttendanceAdapterTests.swift new file mode 100644 index 00000000..78d9ed34 --- /dev/null +++ b/ios/epacTests/Data/SwiftDataMPAttendanceAdapterTests.swift @@ -0,0 +1,113 @@ +// +// SwiftDataMPAttendanceAdapterTests.swift +// epacTests +// +// Tests for SwiftDataMPAttendanceAdapter (EPAC-897). +// + +@testable import epac +import Foundation +import SwiftData +import Testing + +@MainActor +struct SwiftDataMPAttendanceAdapterTests { + private func makeContainer() throws -> ModelContainer { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + return try ModelContainer(for: Schema(versionedSchema: SchemaV11.self), configurations: config) + } + + @Test func tallyReturnsNilWhenNoRecordedVotesInSystem() async throws { + let container = try makeContainer() + let context = ModelContext(container) + let adapter = SwiftDataMPAttendanceAdapter(modelContext: context) + + let result = try await adapter.tally(forMemberID: 1) + #expect(result == nil) + } + + @Test func tallyReturnsNilWhenMemberDoesNotExist() async throws { + let container = try makeContainer() + let context = ModelContext(container) + + // Insert a division so divisionDates is not empty + let vote = RecordedVote( + voteID: 100, + parliament: 44, + session: 1, + number: 1, + date: Date(), + descriptionEn: "Test division", + billNumberCode: "C-11", + yea: 0, nay: 0, paired: 0, + resultEn: "Carried" + ) + context.insert(vote) + + let adapter = SwiftDataMPAttendanceAdapter(modelContext: context) + let result = try await adapter.tally(forMemberID: 999) + #expect(result == nil) + } + + @Test func tallyComputesCorrectTalliesForMember() async throws { + let container = try makeContainer() + let context = ModelContext(container) + + let swornInDate = Date().addingTimeInterval(-3600 * 24 * 5) // 5 days ago + let oldDate = Date().addingTimeInterval(-3600 * 24 * 10) // 10 days ago (pre sworn-in) + let newDate = Date().addingTimeInterval(-3600 * 24 * 2) // 2 days ago (post sworn-in) + + // Insert member + let member = ParliamentMember( + name: "John Doe", + lastName: "Doe", + firstName: "John", + photoURL: URL(string: "https://example.com/photo.jpg")!, + riding: "Ottawa Centre", + province: .Ontario, + party: .liberal, + memberID: 42, + fromDateTime: swornInDate + ) + context.insert(member) + + // Insert votes (divisions) + let division1 = RecordedVote(voteID: 101, parliament: 44, session: 1, number: 1, date: oldDate, descriptionEn: "Old", billNumberCode: "C-1", yea: 0, nay: 0, paired: 0, resultEn: "Carried") + let division2 = RecordedVote(voteID: 102, parliament: 44, session: 1, number: 2, date: newDate, descriptionEn: "New Yea", billNumberCode: "C-2", yea: 0, nay: 0, paired: 0, resultEn: "Carried") + let division3 = RecordedVote(voteID: 103, parliament: 44, session: 1, number: 3, date: newDate, descriptionEn: "New Nay", billNumberCode: "C-3", yea: 0, nay: 0, paired: 0, resultEn: "Carried") + let division4 = RecordedVote(voteID: 104, parliament: 44, session: 1, number: 4, date: newDate, descriptionEn: "New Paired", billNumberCode: "C-4", yea: 0, nay: 0, paired: 0, resultEn: "Carried") + let division5 = RecordedVote(voteID: 105, parliament: 44, session: 1, number: 5, date: newDate, descriptionEn: "New Absent", billNumberCode: "C-5", yea: 0, nay: 0, paired: 0, resultEn: "Carried") + + context.insert(division1) + context.insert(division2) + context.insert(division3) + context.insert(division4) + context.insert(division5) + + // Member's votes + // Note: old vote (pre-sworn in) is counted on division1 but should be filtered out by the adapter + let mv1 = MemberVote(voteID: 101, memberID: 42, recordedVote: "yea") + let mv2 = MemberVote(voteID: 102, memberID: 42, recordedVote: "yea") + let mv3 = MemberVote(voteID: 103, memberID: 42, recordedVote: "nay") + let mv4 = MemberVote(voteID: 104, memberID: 42, recordedVote: "paired") + let mv5 = MemberVote(voteID: 105, memberID: 42, recordedVote: "absent") + + context.insert(mv1) + context.insert(mv2) + context.insert(mv3) + context.insert(mv4) + context.insert(mv5) + + let adapter = SwiftDataMPAttendanceAdapter(modelContext: context) + let tally = try await adapter.tally(forMemberID: 42) + + let result = try #require(tally) + #expect(result.memberID == 42) + #expect(result.party == .liberal) + #expect(result.yea == 1) // mv2 (yea). mv1 is ignored because it's before swornInDate. + #expect(result.nay == 1) // mv3 (nay) + #expect(result.paired == 1) // mv4 (paired) + #expect(result.totalDivisions == 4) // division2, 3, 4, 5 (all >= swornInDate). division1 is ignored. + #expect(result.denominatorStartDate == swornInDate) + } +} diff --git a/ios/epacTests/SnapshotTests.swift b/ios/epacTests/SnapshotTests.swift index 4f1bf376..0d8b0863 100644 --- a/ios/epacTests/SnapshotTests.swift +++ b/ios/epacTests/SnapshotTests.swift @@ -1277,4 +1277,67 @@ final class SnapshotTests: XCTestCase { name: "PetitionDetailView_notQualified" ) } + + // MARK: - MP Attendance record card (EPAC-897) + + @MainActor + func testMPAttendanceCard_standard() { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + let swornInDate = formatter.date(from: "2021-11-22")! + + let attendance = MPAttendance( + record: AttendanceRecord( + totalDivisions: 100, + yea: 75, + nay: 10, + paired: 10, + denominatorStartDate: swornInDate + ), + comparison: AttendanceComparison( + party: .liberal, + nationalAverageRate: 0.88, + partyAverageRate: 0.91, + nationalSampleSize: 338, + partySampleSize: 156 + ) + ) + + snapshot( + AttendanceRecordCard( + member: Self.member(party: .liberal), + attendance: attendance + ) + .padding() + .background(Color.appBackground) + .frame(width: 375), + name: "MPAttendanceCard_standard" + ) + } + + @MainActor + func testMPAttendanceCard_noDateNoComparison() { + let attendance = MPAttendance( + record: AttendanceRecord( + totalDivisions: 50, + yea: 40, + nay: 5, + paired: 0, + denominatorStartDate: nil + ), + comparison: nil + ) + + snapshot( + AttendanceRecordCard( + member: Self.member(party: .conservative), + attendance: attendance + ) + .padding() + .background(Color.appBackground) + .frame(width: 375), + name: "MPAttendanceCard_noDateNoComparison" + ) + } } diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_a11y.png new file mode 100644 index 00000000..e81392df Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_dark.png new file mode 100644 index 00000000..6eb29bc1 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_light.png new file mode 100644 index 00000000..9d4aa9af Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractDetail_withAmendments.ContractDetail_withAmendments_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_a11y.png new file mode 100644 index 00000000..0262a30b Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_dark.png new file mode 100644 index 00000000..797f0894 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_light.png new file mode 100644 index 00000000..6aaae8af Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_empty.ContractsBrowser_empty_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_a11y.png new file mode 100644 index 00000000..d02d9d58 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_dark.png new file mode 100644 index 00000000..f74d0230 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_light.png new file mode 100644 index 00000000..fbb4ad0f Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testContractsBrowser_withResults.ContractsBrowser_withResults_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_a11y.png new file mode 100644 index 00000000..a19a9c2c Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_dark.png new file mode 100644 index 00000000..1a683261 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_light.png new file mode 100644 index 00000000..8a336a5b Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantRow_populated.GrantRow_populated_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_a11y.png new file mode 100644 index 00000000..c2be56da Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_dark.png new file mode 100644 index 00000000..6630f377 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_light.png new file mode 100644 index 00000000..c066079b Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_empty.GrantsView_empty_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_a11y.png new file mode 100644 index 00000000..bed00482 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_dark.png new file mode 100644 index 00000000..1a891ac5 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_light.png new file mode 100644 index 00000000..adeb6580 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testGrantsView_loadError.GrantsView_loadError_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_a11y.png new file mode 100644 index 00000000..bbeb8774 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_dark.png new file mode 100644 index 00000000..d3f71dc3 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_light.png new file mode 100644 index 00000000..2251df80 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_noDateNoComparison.MPAttendanceCard_noDateNoComparison_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_a11y.png new file mode 100644 index 00000000..fc0919b8 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_dark.png new file mode 100644 index 00000000..8445d671 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_light.png new file mode 100644 index 00000000..ca63ddf4 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPAttendanceCard_standard.MPAttendanceCard_standard_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_a11y.png new file mode 100644 index 00000000..c9316d14 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_dark.png new file mode 100644 index 00000000..6d553ebe Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_light.png new file mode 100644 index 00000000..dd3a215f Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_communicationsPresent.MPLobbyingTab_communicationsPresent_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_a11y.png new file mode 100644 index 00000000..e10d3cc9 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_dark.png new file mode 100644 index 00000000..f9a609d6 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_light.png new file mode 100644 index 00000000..ee1704af Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_emptyState.MPLobbyingTab_emptyState_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_a11y.png new file mode 100644 index 00000000..8114a228 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_dark.png new file mode 100644 index 00000000..72b2156d Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_light.png new file mode 100644 index 00000000..777e5136 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMPLobbyingTab_filteredState.MPLobbyingTab_filteredState_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_a11y.png new file mode 100644 index 00000000..cb3f056a Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_dark.png new file mode 100644 index 00000000..da6a399c Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_light.png new file mode 100644 index 00000000..daae46b4 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_hospitalityOnly.MinisterialExpenseRow_hospitalityOnly_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_a11y.png new file mode 100644 index 00000000..62be8219 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_dark.png new file mode 100644 index 00000000..514b0d7d Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_light.png new file mode 100644 index 00000000..dddb3d23 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testMinisterialExpenseRow_withDisclosures.MinisterialExpenseRow_withDisclosures_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_a11y.png new file mode 100644 index 00000000..73ef22ba Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_dark.png new file mode 100644 index 00000000..c84d4acf Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_light.png new file mode 100644 index 00000000..2dba2000 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_noAppearances.WitnessOrganizationContent_noAppearances_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_a11y.png new file mode 100644 index 00000000..814ee02f Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_dark.png new file mode 100644 index 00000000..fc658052 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_light.png new file mode 100644 index 00000000..6f8ba319 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withAppearances.WitnessOrganizationContent_withAppearances_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_a11y.png new file mode 100644 index 00000000..3b0822e8 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_dark.png new file mode 100644 index 00000000..19c5b511 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_light.png new file mode 100644 index 00000000..8ed9f297 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testWitnessOrganizationContent_withLobbyingBadge.WitnessOrganizationContent_withLobbyingBadge_light.png differ