Plan: canopy-tanf Work Activities List Endpoint + Aggregation

On this page

Status

Step Description Status

1

Store: add list_work_activities_for_person(db, person_id, window) that JOINs tanf_work_activities to tanf_work_requirements on work_requirement_id so callers can filter by person_id without exposing requirement IDs

Done (2026-04-20)

2

API: add GET /v1/work-requirements/{person_id}/activities?from=…​&to=…​ — lists TanfWorkActivity rows for the person, optionally filtered by effective_date/end_date overlap with [from, to]. RBAC: require_caseworker_or_above. utoipa-annotated.

Done (2026-04-20)

3

API: add GET /v1/work-requirements/{person_id}/activities/summary?month=YYYY-MM — returns {total_hours: Decimal, core_hours: Decimal, non_core_hours: Decimal, sources: Vec<ActivityType>} computed by summing hours_per_week * weeks_in_month_overlap across active activities. The response shape is what canopy-reporting needs for ACF-199 WPR — see Design.

Done (2026-04-20)

4

Reporting client: extend ServiceClients::get_tanf_work_activities(person_id, month) to call Step 3 endpoint. Remove the list_tanf_work_activities stub that returns Vec::new() at services/canopy-reporting/src/clients.rs (if still present).

Done (2026-04-20)

5

Reporting consumer: replace hardcoded Decimal::from(30) / Decimal::from(20) at services/canopy-reporting/src/reporting/tanf.rs:81-84 with values from Step 3’s summary. Update the total_work_hours and core_activity_hours accumulators accordingly.

Done (2026-04-20)

6

Integration tests: POST an activity via existing create endpoint, GET the list, GET the summary for a given month, assert hours match expected calculation. Mirror the harness pattern in services/canopy-tanf/tests/work_requirements_test.rs.

Done (2026-04-20)

7

Plan sync: remove the "work hours placeholder" errata from tanf-federal-reporting.adoc and the Tier 5.5 row from roadmap.adoc once the WPR calculation produces real numbers.

Done (2026-04-20)

Branch: feature/canopy-tanf-work-activities-list
Labels: type::feature, priority::high, program::tanf, service::tanf, service::reporting, federal-partner::acf, workflow::ready

Context

tanf_work_activities has stored rows since the original TANF migration (services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql:106), and POST /v1/work-requirements/{person_id}/activities exists (services/canopy-tanf/src/api/work_requirement_handlers.rs:64). There is no read path: no GET lists activities for a person, no endpoint aggregates hours per month. create_work_activity at services/canopy-tanf/src/store/mod.rs:239 only writes.

Two consumers need the read path:

  • canopy-reporting computes ACF-199 work participation. Today services/canopy-reporting/src/reporting/tanf.rs:81-84 uses hardcoded 30 / 20 hour placeholders. The fragile canopy-reporting/src/clients.rs stub acknowledges this ("will return empty until that endpoint is added"). The ACF-199 WPR calculation is documented as "meaningless until real hours are wired" in tanf-federal-reporting.adoc Step 5.

  • Worker portal doesn’t surface activity detail today, but the worker-portal-expansion plan’s TANF tab renders exemption/sanction/time-limit summaries without activity-level drill-down. A list endpoint unblocks that UI improvement as a follow-up.

Per-activity hours drive work-requirement compliance calculations under 45 CFR 261.31 (WPR) and PAMMS 2301 (Georgia TANF work plan). Without accurate hours, ACF-199 submissions to ACF are knowingly incorrect — an open compliance risk.

Scope

In scope:

  • One list endpoint, one summary endpoint, one store helper, one reporting consumer fix, tests for all of it.

  • Hour-aggregation math: hours_per_week * overlap_weeks_in_month where overlap_weeks_in_month = (effective_date..end_date.unwrap_or(month_end)) ∩ (month_start..month_end) / 7. PAMMS-consistent (see Design).

  • Core-vs-non-core classification is already defined in services/canopy-reporting/src/reporting/tanf.rs:362-375 as CORE_ACTIVITIES. Reuse that mapping from the summary endpoint.

Out of scope:

  • Adding new activity types. The existing set (employment, job_search, community_service, education, vocational_training) is what the migration defines.

  • Cross-person or household-level aggregation. ACF-199 rolls up from per-person data; the caller does the household math.

  • Write-side changes. POST /activities stays as-is.

Dependencies

  • services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql — table exists.

  • services/canopy-tanf/src/store/mod.rs:239create_work_activity already works.

  • services/canopy-tanf/src/api/work_requirement_handlers.rs — existing handler module to extend.

  • services/canopy-reporting/src/clients.rsServiceClients::get_tanf_work_requirements exists; add a sibling get_tanf_work_activities.

  • services/canopy-reporting/src/reporting/tanf.rs:362-375CORE_ACTIVITIES constant.

  • No new migrations required.

Design

Store helper

// services/canopy-tanf/src/store/mod.rs

pub async fn list_work_activities_for_person(
    db: &PgPool,
    person_id: PersonId,
    window: Option<(NaiveDate, NaiveDate)>,
) -> sqlx::Result<Vec<TanfWorkActivity>> {
    match window {
        Some((from, to)) => sqlx::query_as::<_, TanfWorkActivity>(
            r#"SELECT a.*
               FROM tanf_work_activities a
               JOIN tanf_work_requirements r ON a.work_requirement_id = r.id
               WHERE r.person_id = $1
                 AND a.effective_date <= $3
                 AND COALESCE(a.end_date, DATE '9999-12-31') >= $2
               ORDER BY a.effective_date DESC"#,
        )
        .bind(person_id)
        .bind(from)
        .bind(to)
        .fetch_all(db)
        .await,
        None => sqlx::query_as::<_, TanfWorkActivity>(
            r#"SELECT a.*
               FROM tanf_work_activities a
               JOIN tanf_work_requirements r ON a.work_requirement_id = r.id
               WHERE r.person_id = $1
               ORDER BY a.effective_date DESC"#,
        )
        .bind(person_id)
        .fetch_all(db)
        .await,
    }
}

Hour-aggregation formula

For a target month [month_start, month_end]:

activity_days_in_month = (
    min(a.end_date.unwrap_or(month_end), month_end)
  - max(a.effective_date, month_start)
) + 1

weeks_in_month_overlap = activity_days_in_month / 7.0

hours_for_month = a.hours_per_week * weeks_in_month_overlap

Negative activity_days_in_month (activity didn’t overlap month) → 0. Use chrono::NaiveDate::signed_duration_since and rust_decimal arithmetic throughout; no floats.

Summary endpoint response

GET /v1/work-requirements/{person_id}/activities/summary?month=2026-04

{
  "person_id": "…",
  "month": "2026-04",
  "total_hours": "140.00",
  "core_hours":  "120.00",
  "non_core_hours": "20.00",
  "activity_breakdown": [
    { "activity_type": "employment", "hours": 120.00, "is_core": true },
    { "activity_type": "education",  "hours": 20.00,  "is_core": false }
  ]
}

activity_breakdown returns each activity type that contributed hours in the window so auditors can trace WPR numbers back to source rows.

Reporting consumer

// services/canopy-reporting/src/reporting/tanf.rs

// Replace lines 81-84:
let summary = clients.get_tanf_work_activities_summary(person_id, month).await?;
total_work_hours += summary.total_hours;
core_activity_hours += summary.core_hours;

When the upstream returns 404 (no work requirement on file) or an empty list, treat as total_hours = 0 and core_hours = 0 — exempt persons have no hours, which is correct.

Steps

Step 1: Store helper

Files: services/canopy-tanf/src/store/mod.rs.

Add list_work_activities_for_person. Pair it with a unit test against infrastructure_available() (pattern: existing create_work_activity tests).

Step 2: List endpoint

Files: services/canopy-tanf/src/api/work_requirement_handlers.rs, services/canopy-tanf/src/api/mod.rs (route registration).

Handler signature:

#[utoipa::path(
    get,
    path = "/v1/work-requirements/{person_id}/activities",
    params(
        ("person_id" = PersonId, Path, description = "Person whose activities to list"),
        ("from" = Option<NaiveDate>, Query, description = "Inclusive window start"),
        ("to"   = Option<NaiveDate>, Query, description = "Inclusive window end"),
    ),
    responses(
        (status = 200, body = Vec<TanfWorkActivity>),
        (status = 401), (status = 403), (status = 404),
    ),
    security(("bearer_auth" = []))
)]
async fn list_activities(
    State(state): State<AppState>,
    Path(person_id): Path<PersonId>,
    Query(q): Query<ActivityWindowQuery>,
    claims: Claims,
) -> Result<Json<Vec<TanfWorkActivity>>, ApiError> { ... }

Step 3: Summary endpoint

Files: same.

Uses the Step 1 helper filtered to the target month, runs the aggregation formula, returns WorkActivitiesSummary. New response struct lives in services/canopy-tanf/src/domain.rs alongside TanfWorkActivity.

Step 4: Reporting client

Files: services/canopy-reporting/src/clients.rs.

Add get_tanf_work_activities_summary. Remove the list_tanf_work_activities stub if it still returns Vec::new().

Step 5: Replace placeholders

Files: services/canopy-reporting/src/reporting/tanf.rs.

Delete the Decimal::from(30) / Decimal::from(20) literals at line 81-84. Call the Step 4 client method, aggregate into total_work_hours / core_activity_hours.

Step 6: Integration tests

Files: services/canopy-tanf/tests/work_requirements_test.rs or new work_activities_test.rs.

Covers: empty list (no activities), single-activity list, windowed filter, summary with overlapping activities, summary with activity that starts/ends mid-month.

Step 7: Docs + roadmap sync

Files: docs/modules/ROOT/pages/plans/tanf-federal-reporting.adoc, docs/modules/ROOT/pages/roadmap.adoc.

Remove the work-hours-placeholder errata line. Update the Tier 5.5 row to Done with a reference to this plan.

Files Touched

File Change

services/canopy-tanf/src/store/mod.rs

+list_work_activities_for_person + unit test

services/canopy-tanf/src/api/work_requirement_handlers.rs

+list_activities, +summary handlers

services/canopy-tanf/src/api/mod.rs

Route registration + utoipa exposure

services/canopy-tanf/src/domain.rs

+WorkActivitiesSummary response struct

services/canopy-reporting/src/clients.rs

+get_tanf_work_activities_summary, -stub

services/canopy-reporting/src/reporting/tanf.rs

Replace placeholder hours with real summary

services/canopy-tanf/tests/work_activities_test.rs

New integration tests

docs/modules/ROOT/pages/plans/tanf-federal-reporting.adoc

Errata removal

docs/modules/ROOT/pages/roadmap.adoc

Tier 5.5 row → Done

CHANGELOG.adoc

Unreleased entry

Verification

  1. cargo nextest run -p canopy-tanf --test work_activities_test — all new tests pass.

  2. cargo nextest run -p canopy-reporting — no regressions.

  3. Manual: seed a work requirement + two activities (one core, one non-core) via POST; call GET /summary?month=2026-04; assert both appear with correct aggregated hours.

  4. Manual: trigger ACF-199 generation against the seeded data; confirm total_work_hours matches the summary endpoint output (no more 30/20 literals).

  5. cargo xtask validate — full battery green.

Documentation Updates

  • .claude/CLAUDE.md — canopy-tanf route count bumped 15 → 17, new plan listed

  • .claude/docs/services.md — canopy-tanf row has no route count today; no change needed

  • xref:api/canopy-tanf.adoc — this reference file does not exist (api/ only covers shared services); deferred with the rest of the per-program reference pages

  • CHANGELOG.adoc== Unreleased=== Added entry

  • tanf-federal-reporting.adoc — moved the "work hours placeholder" errata entry to Resolved

  • roadmap.adoc — Tier 5.5 row for canopy-reporting/src/reporting/tanf.rs:81 → Done

Potential Improvements

These are orthogonal to the core WPR fix and can be follow-ups:

  • Per-row activity_breakdown (not just per-type). The summary currently sums hours by activity_type (one row per distinct type in the window). Auditors resolving an ACF-199 discrepancy may want per-work_activity_id drill-down so they can trace a flagged hour count back to the individual logged row — useful when the same person has two employment entries with different effective_date ranges in the same month.

  • Boundary rounding at the endpoint. hours_per_week * days / 7 produces a 28-fractional-digit Decimal (e.g., 128.57142857142857142857142857). The endpoint returns the raw value; clients comparing ratios hit a single-ulp tolerance because of rust_decimal’s 28-digit floor (see the summary_splits_core_vs_non_core test’s 1e-20 tolerance). Rounding to 2 fractional places at the JSON boundary would keep the math exact internally but produce clean, comparable strings for consumers.

  • Exempt-family exclusion from denominator. The WPR formula per 45 CFR 261 counts only non-exempt work-eligible individuals in the denominator. canopy-reporting already filters on work_requirements.exempt, but this plan only wired the numerator; the denominator-side filter lives in reporting/tanf.rs and still uses every adult. Tracked separately in tanf-federal-reporting.adoc errata.

  • Summary caching for month-end reporting runs. Every cargo xtask report acf-199 call fans out one summary HTTP call per adult. For a 100k-case state-wide run this is 100k sequential calls. A bulk endpoint (POST /v1/work-requirements/activities/summary { person_ids: […​], month }) or an in-process cache keyed on (person_id, month) would cut ACF-199 wall-clock by ~20x. Premature until the reporting pipeline is stressed at real scale.


Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):

  • #320 — Cache work-activity summaries for month-end reporting (from Potential Improvements)

Tracked follow-ups (filed 2026-05-04 during PI sweep):

  • #406 — Per-row activity_breakdown drill-down on summary endpoint

  • Boundary rounding at the endpoint — cosmetic; client-side rounding is sufficient. Single-ulp tolerance is documented in the test. Deferred indefinitely.

  • Exempt-family exclusion from denominator — already tracked in tanf-federal-reporting.adoc errata; no separate issue needed.

Edit this page · default