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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
85 changes: 85 additions & 0 deletions ios/epac/Application/LoadMPAttendance.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
156 changes: 156 additions & 0 deletions ios/epac/Data/Adapters/SwiftDataMPAttendanceAdapter.swift
Original file line number Diff line number Diff line change
@@ -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<MemberVote>(
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<ParliamentMember>())
.filter { $0.memberID > 0 && $0.jurisdiction == .federal }
let votesByMember = Dictionary(
grouping: try modelContext.fetch(FetchDescriptor<MemberVote>()),
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<RecordedVote>(
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<ParliamentMember>(
predicate: #Predicate { $0.memberID == memberID }
)).first
}
}
81 changes: 81 additions & 0 deletions ios/epac/Domain/MPAttendance.swift
Original file line number Diff line number Diff line change
@@ -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 <date>" 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?
}
20 changes: 20 additions & 0 deletions ios/epac/Domain/Ports/MPAttendanceQueryPort.swift
Original file line number Diff line number Diff line change
@@ -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]
}
Loading
Loading