WorkWingman — Family Cost Comparison (Technical)¶
An optional household profile plus a city-to-city comparison of grocery cost and school/tuition cost, projected onto a prospective job's city vs. the user's current city. Built to the same hard rule as job-signals: every figure is either an official/free public source or an explicitly-labeled, cited approximation — nothing is scraped, and nothing is fabricated when a source has no clean answer.
Privacy¶
FamilyProfile (household size, kids' ages, school type, current spends, current city) is stored
locally only, in its own document under LocalJsonStore (family-profile.json in
%USERPROFILE%\Wingman\data, or the configured data directory — see
LocalJsonStore.cs). It never
leaves the machine except via the same Drive-mirror path every other profile section already
uses. Every field is optional and defaults to something reasonable or blank — skipping the
"Family" section entirely does not block any other part of the app.
Data model¶
public class FamilyProfile
{
public int Adults { get; set; } = 2;
public List<ChildAge> Kids { get; set; } = [];
public SchoolType CurrentSchoolType { get; set; } = SchoolType.NotApplicable;
public decimal? CurrentMonthlyGrocerySpend { get; set; }
public decimal? CurrentPrivateTuitionAnnual { get; set; }
public string CurrentCity { get; set; } = string.Empty;
public DateTimeOffset UpdatedAt { get; set; }
}
public class ChildAge { public int AgeYears { get; set; } }
public enum SchoolType { NotApplicable, Public, Private }
Kids' sex is deliberately not collected — see "Grocery estimate method" below for why age alone
is enough for this cost model. CurrentCity is free text (e.g. "Austin, TX"), the same shape
already used by JobPosting.Location — no shared geocoding dependency on any other in-flight
branch.
Source research and verdicts¶
Groceries — USDA Food Plans + BLS regional index¶
USE: USDA Official Food Plans ("Cost of Food"), published by the Food and Nutrition Service
(rebranded Food and Nutrition Administration, mid-2026) —
https://www.fna.usda.gov/research/cnpp/usda-food-plans/cost-food-monthly-reports. Free,
official, monthly. Four cost levels (Thrifty, Low-Cost, Moderate-Cost, Liberal) by 15 age/sex
bands. Caveat: PDF/Excel-table publication only — no REST API, no clean CSV. The exact
per-band Moderate-Cost figures used in
UsdaFoodPlanCalculator
are transcribed from the most recently published report as of this feature's build and must be
refreshed by hand against the current monthly PDF — see "As-of / refresh" below.
USDA's own household-size adjustment (reproduced exactly in UsdaFoodPlanCalculator):
1-person households cost +20% per person, 2-person +10%, 3-person +5%, 4-person
no adjustment (baseline), 5-6 person -5%, 7+ person -10% — larger households achieve
economies of scale on food purchasing/prep that a flat per-person sum would miss.
USE: BLS CPI regional "food at home" index for the scaling factor —
https://www.bls.gov/developers/ (Public Data API v2, free with registration for the full
500-query/day allowance; series family CUUR<area>SEFA, e.g. Northeast urban =
CUUR0100SEFA, per https://www.bls.gov/cpi/factsheets/cpi-series-ids.htm). Caveat: metro-level
"food at home" coverage is sparse outside the largest metros — BLS publishes it reliably for
~20+ large metro areas and at the Census-region level everywhere else. Given that,
BlsRegionalFoodPriceIndexSource
ships as a seeded snapshot table (city-name fragment → index, ~18 major metros) rather than a
live API call today — a real HTTP adapter against the BLS API (same ApiClientBase/Flurl/Polly
shape as SecEdgarSource) can replace its body without touching IFamilyCostService or any
caller. Cities outside the snapshot fall back to 1.00 (national average, no adjustment) and are
flagged via IsFallbackNationalAverage, never guessed.
Method: UsdaFoodPlanCalculator.NationalBaselineMonthly(adults, kidAges) sums the Moderate-Cost
per-person figure for each household member (adults modeled as an even male/female split,
rounding the odd extra adult to male — a family-size cost estimate, not a per-person nutrition
plan) and applies the household-size adjustment; FamilyCostService.GroceryEstimateAsync
multiplies that baseline by the target city's regional index. Kids' sex isn't collected, so a
child whose age falls in a sex-specific USDA band (9+) is costed at the average of the male and
female figures for that band — a neutral blend, never defaulting to the higher male rate.
Delta baseline: DeltaVsCurrentCity prefers the user's own stated CurrentMonthlyGrocerySpend
when present. When it's blank (the model's "use the estimate" case), the delta falls back to the
same USDA-scaled estimate computed for the user's CurrentCity (using that city's own regional
index), so a user who skips the optional spend field still sees a target-vs-current delta. It's
null only when neither a stated spend nor a usable, distinct current city exists.
School district quality — SEDA (NCES EDGE geocoding noted, GreatSchools ruled out)¶
AVOID: GreatSchools API — confirmed partner/paid-only. Current public pricing: "School Essentials" ~$52.50/mo (15,000 calls), "School Quality" ~$97.50/mo, 14-day free trial, no remaining free tier (https://www.greatschools.org/api). Not used.
USE: Stanford Educational Opportunity Project / SEDA — https://edopportunity.org/get-the-data/. Free, official-research-grade, publishes nationally-comparable district average-achievement AND growth percentiles (not raw state proficiency rates, which aren't comparable across states) as downloadable CSV/Stata files. Current technical documentation is versioned v5.0 (March 2024). Caveat: verify the current license text at edopportunity.org before any commercial redistribution — Stanford's published terms grant a "revocable, non-exclusive, non-transferable right to access and use" the data; this is treated as research/attribution-friendly, not a blanket commercial license, so WorkWingman surfaces the metric with attribution and does not redistribute the underlying dataset.
NOTED, not used directly: NCES EDGE (https://nces.ed.gov/programs/edge/Geographic/DistrictBoundaries) — free district-boundary shapefiles + ArcGIS REST services for address/city → district geocoding. This is the correct long-term tool for mapping an arbitrary "City, ST" string to one SEDA district ID; it carries no quality metric itself. NCES CCD (Common Core of Data, https://nces.ed.gov/ccd/) is free district enrollment/demographic/finance data, also with no quality score — noted for completeness, not wired in.
What ships today:
SedaSchoolDistrictSource
is a seeded snapshot (city-name fragment → district name + achievement/growth percentile,
~8 major metros), the same documented-placeholder pattern as the grocery index source. A live
implementation would chain NCES EDGE geocoding → a SEDA district-ID lookup → the downloaded SEDA
CSV, none of which changes ISchoolDistrictSource's contract. Cities outside the snapshot
resolve to null — "no clean mapping," never a fabricated percentile.
Private school cost — a documented dead end + a caveated fallback¶
NCES Private School Universe Survey (PSS) — https://nces.ed.gov/surveys/pss/ — does NOT collect tuition data. This is the obvious "official" candidate and it was checked directly against its published table list and the Digest of Education Statistics: PSS covers religious orientation, enrollment, staffing, and program type, never cost. No federal survey publishes an authoritative private-tuition-by-state figure. This is recorded here so a future contributor doesn't re-research the same dead end.
AVOID (do not scrape): metro-level tuition aggregators (e.g. Private School Review and
similar lead-generation sites). Their robots.txt disallows broad site sections and their
business model (paid lead generation) makes ToS-ambiguous bulk extraction legally risky — the
same LinkedIn-style pattern this codebase avoids everywhere else (see
job-signals.md).
What ships today:
StateAveragePrivateTuitionSource
uses a small seeded snapshot of state-level average annual tuition figures sourced from
published state/regional independent-school-association aggregate reports (NAIS-style member
surveys) — explicitly labeled in the Source field as an approximate, non-federal figure, not
an NCES number. States outside the snapshot resolve to a PrivateTuitionEstimate with a resolved
State but a null StateAverageAnnualTuition — "no seeded average," never a guess.
Current-side tuition, by precedence: (1) the family's actual CurrentPrivateTuitionAnnual if
entered (labeled Source = "User-provided current tuition (from Family profile)") — honored even
for a Public profile, since a stated figure is explicit intent; (2) otherwise the state average,
but only when CurrentSchoolType == Private — a Public/NotApplicable family pays $0 today,
so filling in the state average there would understate the real cost of moving a kid into private
school in the target city, so the current side is left null instead; (3) otherwise null. The
target side always keeps the state-average estimate (no user figure to prefer).
Reliable vs. caveated fields¶
| Field | Reliability | Why |
|---|---|---|
GroceryEstimate.NationalBaselineMonthly |
Reliable, given an accurate family composition | Directly reproduces USDA's own published methodology and adjustment table. |
GroceryEstimate.RegionalIndex / MonthlyGroceryCost |
Caveated | Seeded snapshot of ~18 metros today, not a live BLS call; falls back to 1.00 (flagged) elsewhere. |
SchoolDistrictSignal.AchievementPercentile / GrowthPercentile |
Caveated | Seeded snapshot of ~8 metros; SEDA itself is a solid nationally-comparable source once a real geocoding step is wired in. |
PrivateTuitionEstimate.StateAverageAnnualTuition |
Caveated, explicitly approximate | No federal source exists at all; this is an association-aggregate figure, not an NCES number — labeled as such in Source. |
Aggregation¶
FamilyCostService follows
the same shape as CompanyLayoffService: swappable adapters
(IRegionalFoodPriceIndexSource, ISchoolDistrictSource, IPrivateTuitionSource), graceful
degradation (OperationCanceledException propagates, everything else degrades to a safe default
rather than throwing), and no fabricated numbers — a null field always means "no data," never a
best-effort guess.
public interface IFamilyCostService
{
Task<GroceryEstimate> GroceryEstimateAsync(FamilyProfile family, string city, CancellationToken ct = default);
Task<SchoolComparison> SchoolComparisonAsync(FamilyProfile family, string targetCity, CancellationToken ct = default);
}
Wiring¶
Registered in Program.cs as plain singletons, same as
every other service:
builder.Services.AddSingleton<IFamilyProfileService, FamilyProfileService>();
builder.Services.AddSingleton<IRegionalFoodPriceIndexSource, BlsRegionalFoodPriceIndexSource>();
builder.Services.AddSingleton<ISchoolDistrictSource, SedaSchoolDistrictSource>();
builder.Services.AddSingleton<IPrivateTuitionSource, StateAveragePrivateTuitionSource>();
builder.Services.AddSingleton<IFamilyCostService, FamilyCostService>();
FamilyController exposes
GET/PUT /api/family/profile, GET /api/family/groceries?city=, and
GET /api/family/schools?targetCity= (optional ¤tCity= overrides the saved profile's
current city; the current side otherwise comes from the saved FamilyProfile) — a dedicated route separate from
ProfileController's /api/profile/*, so this feature never touches IProfileService,
IntakeProfile, or StoryProfile.
Frontend: Family is a standalone routed
page (/family) for the optional profile form (adults, kids + ages via add/remove rows, school
type, optional current spends). FamilyCostCard
is a separate, input-only display component (grocery/schools signal inputs, no service
injection) meant to compose next to the other cost-comparison cards (financial-profile,
lifestyle-costs, col-tax) on a future job-detail view without any file overlap at integration
time.
As-of / refresh¶
- USDA figures snapshot date: see the per-band comment in
UsdaFoodPlanCalculator— refresh by pulling the latest "Cost of Food at Home — Moderate-Cost Plan" monthly PDF from fna.usda.gov/research/cnpp/usda-food-plans and updatingMonthlyCostByAgeSex. BlsRegionalFoodPriceIndexSource.SnapshotAsOf,SedaSchoolDistrictSource.SnapshotAsOf, andStateAveragePrivateTuitionSource.SnapshotAsOfeach carry their own snapshot date, surfaced on every returned estimate so the UI/docs can show "as of" rather than implying live data.
Tests¶
UsdaFoodPlanCalculatorTests.cs— exhaustive pure-logic coverage: every age band boundary, every household-size bracket, zero/negative adults, out-of-range ages (clamped, never throws), odd adult counts.FamilyCostServiceTests.cs— NSubstitute mocks for all three source interfaces, proving the delta math, graceful degradation, and cancellation propagation in isolation from any one adapter's own data.BlsRegionalFoodPriceIndexSourceTests.cs,SedaSchoolDistrictSourceTests.cs,StateAveragePrivateTuitionSourceTests.cs— seeded-snapshot lookup, case-insensitive matching, blank/unknown-city fallback.FamilyProfileServiceTests.cs— own store-key isolation fromProfileService, round-trip,UpdatedAtstamping.- Frontend:
family.spec.ts(form add/remove/update-kid logic, save/no-op-until-loaded, graceful degradation) andfamily-cost-card.spec.ts(delta-label computation, "not available" fallbacks). - Stryker.NET: this feature's files are covered by the existing
**/Enrichment/*.csglob instryker-config.json— no config change needed. See the repo's Stryker run output for the current mutation score on this feature.
Related docs¶
- Plain-language version: ../plain/family-costs.md
- Job enrichment signals (the sibling feature this pattern is copied from): job-signals.md
- Testing philosophy and tooling: testing.md