WorkWingman — Lifestyle Costs (Technical)¶
A local-only profile of the user's current home size, commute, and car efficiency, projected into a prospective job's city so a comparison isn't just salary vs. salary — it's "what would my life actually cost there." Two independent projections: housing-size equivalence (what your home size costs in the new metro) and commute cost (gas money to get to the new office). Both degrade gracefully when a source is unavailable or an input is missing — never a thrown exception, never a guessed number.
Model — LifestyleProfile¶
src/WorkWingman.Core/Models/LifestyleProfile.cs:
public class LifestyleProfile
{
public HomeType HomeType { get; set; } = HomeType.Unspecified; // Apartment | House | Other | Unspecified
public int? Bedrooms { get; set; }
public int? SquareFeet { get; set; }
public RentOrOwn? RentOrOwn { get; set; } // Rent | Own
public decimal? MonthlyHousingCost { get; set; }
public double? CommuteMilesOneWay { get; set; }
public double? CarMpg { get; set; }
public int WorkdaysPerWeek { get; set; } = 5;
public DateTimeOffset UpdatedAt { get; set; }
}
Every field except HomeType and WorkdaysPerWeek is nullable — "not answered yet" is a normal,
first-class state, the same convention as IntakeProfile.Extended (see
profile-data-gaps.md if present, or ExtendedProfile's doc comment).
Persisted under its own LocalJsonStore key, "lifestyle"
(LifestyleProfileService),
independent of the "intake"/"story" keys ProfileService owns — saving one never touches the
others (LocalJsonStoreTests-style isolation, one JSON document per collection).
Endpoint (LifestyleController,
src/WorkWingman.Api/Controllers/LifestyleController.cs),
matching ProfileController's get/put shape:
| Method | Path | Behavior |
|---|---|---|
GET |
/api/lifestyle/profile |
Returns the saved profile, or a new all-null default if none saved yet. |
PUT |
/api/lifestyle/profile |
Saves the given profile; stamps UpdatedAt. |
Privacy: this profile never leaves the machine. It is not synced to Drive/Sheets, not sent to any enrichment source, and the housing/commute source adapters below only ever receive a metro area name and a bedroom count — never the user's address, exact rent, or any other profile field. See the UI copy on the "Home & commute" page and the plain-language doc for the user-facing framing of this guarantee.
Signal A — Housing-size equivalence¶
What it shows: what the user's current home size (bedroom count) would rent for in a prospective job's metro, and the delta vs. their current monthly housing cost.
Source research verdicts¶
| Source | Verdict | Why |
|---|---|---|
| HUD Fair Market Rents (FMR) | Used, primary (HudFmrSource) |
Official, free REST API (huduser.gov/portal/dataset/fmr-api.html) published by HUD's Office of Policy Development and Research specifically to support Section 8 rent-reasonableness determinations. Returns rent broken out by bedroom count (Efficiency through Four-Bedroom) per metro/county — exactly the granularity this feature needs. Requires free registration for a bearer token (huduser.gov/hudapi/public/register); base endpoint is GET https://www.huduser.gov/hudapi/public/fmr/data/{entityId} where entityId is a CBSA/metro or county FIPS code — not a human metro name, so HudFmrSource resolves the caller's metro name to a CBSA code via CensusAcsSource.ResolveCbsaCode before calling out, and degrades to "no data" for anything unresolved rather than sending a string HUD's API would 404 on. |
| Census ACS 5-year estimates | Used, secondary (CensusAcsSource) |
Official, free, effectively-public API (census.gov/programs-surveys/acs/data/data-via-api.html) at https://api.census.gov/data/{year}/acs/acs5. Variable B25064_001E ("Median gross rent") gives one metro-wide median — not broken out by bedroom count, which is exactly why it's ranked secondary/coarser rather than primary. Used only when HUD FMR has nothing for a metro. (Median home value, B25077_001E, is available from the same table family for a future buy-vs-rent extension; not consumed today.) |
| Zillow Research (ZORI/ZHVI) | Config-stubbed, not live (ZillowHousingSource) |
Zillow publishes ZORI/ZHVI as free CSVs at zillow.com/research/data, but Zillow's own terms (zillowgroup.com/developers/terms, bridgedataoutput.com/zillowterms) draw a hard line between data consumed live through Zillow's licensed, redistribution-restricted APIs (no redistribution beyond single-record format, no bulk resale, written approval required above modest call volumes) and the free Research CSVs — which carry no documented license to re-serve the raw series through a third-party application's own backend/API. Absent an explicit redistribution grant, treating the CSVs as freely re-servable would mirror the exact LinkedIn/layoffs.fyi ToS pattern this codebase avoids (see LayoffsFyiSource and job-signals.md). This adapter is a registered, swappable seam — a documented no-op today, matching LayoffsFyiSource's shape exactly. |
Aggregation — HousingEquivalenceService¶
src/WorkWingman.Infrastructure/Lifestyle/HousingEquivalenceService.cs
mirrors CompanyLayoffService's per-source try/catch graceful degradation, but with
priority-ordered fallback (first success wins) rather than merge-all, since only one rent
figure is needed:
public interface IHousingSource
{
HousingDataSource Source { get; }
Task<HousingLookupResult?> LookupAsync(string metroArea, int bedrooms, CancellationToken ct = default);
}
Priority: HudFmr (0) → CensusAcs (1) → Zillow (2). One source throwing or returning null
falls through to the next; if every source is unavailable or the metro isn't resolved, the result
is a mostly-null HousingEquivalence with an explanatory Notes string, never an exception.
Bedrooms unset is "unknown home size," not "studio." 0 is itself a real, distinct bedroom
count (HUD's "Efficiency" tier), so an unanswered optional field is never silently coerced to it —
GetEquivalenceAsync short-circuits with an explanatory note and queries no sources at all when
profile.Bedrooms is null, and only proceeds normally when the user has explicitly set 0 or higher.
Result model (HousingEquivalence,
src/WorkWingman.Core/Models/HousingEquivalence.cs):
public class HousingEquivalence
{
public decimal? EquivalentRentForSameBedrooms { get; set; }
public decimal? DeltaVsCurrent { get; set; } // null unless MonthlyHousingCost is also set
public HousingDataSource Source { get; set; } // None | HudFmr | CensusAcs | Zillow
public string SourceUrl { get; set; }
public string AsOf { get; set; }
public string Notes { get; set; }
}
Signal B — Commute cost projection¶
What it shows: current vs. prospective monthly commute cost, given distances, MPG, and per-city gas prices.
CommuteCostService
(src/WorkWingman.Infrastructure/Lifestyle/CommuteCostService.cs)
is pure arithmetic — no network of its own:
monthly cost = miles one-way × 2 (round trip) × workdays/week × (52/12 weeks/month) × (gas price / MPG)
The method (CalculateAsync) is async only because gas price resolution is async
(IGasPriceProvider); for the same inputs and gas prices the output is always identical —
deterministic by construction, verified by exhaustive unit tests including hand-computed examples.
Remote/hybrid/unknown work models: the prospective job's work model (mirroring WorkModel on
JobPosting: "Remote" / "Hybrid" / "OnSite", passed as a plain string to keep this service
decoupled from the JobPosting type) changes the projection:
Remote→ prospective monthly cost is$0, regardless of any distance passed in;Notessays so explicitly.Hybrid→ cost is computed normally (assumes the same workdays/week as today), with a note that actual in-office days may be fewer.null/empty (unknown) → cost is computed normally, with a note flagging that it may not apply if the role turns out to be remote.OnSite→ computed normally, no special note.
If the prospective car's MPG isn't supplied, the current car's MPG is reused (noted); if the
prospective MPG is genuinely unknown and no current MPG exists either, the prospective cost is
null. WorkdaysPerWeek outside the plausible 0–7 range (e.g. a negative value from a malformed
API payload) is likewise treated as invalid input and yields a null cost rather than a negative
or nonsensical number.
Integration seam — IGasPriceProvider¶
public interface IGasPriceProvider
{
Task<decimal> GetPricePerGallonAsync(string cityOrState, CancellationToken ct = default);
}
This branch owns only this small local interface plus a stub default
(StubGasPriceProvider,
src/WorkWingman.Infrastructure/Lifestyle/StubGasPriceProvider.cs) —
a flat national-average constant (FallbackPricePerGallon = 3.25m) for every city, deliberately
not city-aware, so CommuteCostService's math is fully exercised without a real gas-price source.
What integration must wire in: the real EIA-backed gas price provider is being built on the
parallel feature-col-tax branch (per-city/state EIA gasoline price series). At integration, swap
the DI registration in Program.cs:
// Today:
builder.Services.AddSingleton<IGasPriceProvider, StubGasPriceProvider>();
// After integration:
builder.Services.AddSingleton<IGasPriceProvider, EiaGasPriceProvider>(); // or whatever feature-col-tax names it
No other change is required — CommuteCostService depends only on the interface.
Wiring¶
builder.Services.AddSingleton<ILifestyleProfileService, LifestyleProfileService>();
builder.Services.AddSingleton<IHousingSource>(sp =>
new HudFmrSource(sp.GetRequiredService<IConfiguration>()["WorkWingman:HudFmrApiToken"]));
builder.Services.AddSingleton<IHousingSource, CensusAcsSource>();
builder.Services.AddSingleton<IHousingSource, ZillowHousingSource>();
builder.Services.AddSingleton<IHousingEquivalenceService, HousingEquivalenceService>();
builder.Services.AddSingleton<IGasPriceProvider, StubGasPriceProvider>();
builder.Services.AddSingleton<ICommuteCostService, CommuteCostService>();
HudFmrSource's bearer token comes from the WorkWingman:HudFmrApiToken configuration key
(environment variable WorkWingman__HudFmrApiToken) — register a free HUD account
(huduser.gov/hudapi/public/register) and set it
to enable HUD lookups; with no token configured, LookupAsync degrades to "no data" and
HousingEquivalenceService falls through to Census ACS, rather than the API ever calling out
unauthenticated. CensusAcsSource takes an optional apiKey (recommended, not required by
Census for low request volume) and an acsYear (defaults "2023").
Frontend¶
- "Home & commute" profile section —
frontend/src/app/features/lifestyle/(Lifestylecomponent, route/lifestyle), following the same optional/skippable pattern asstory.ts: loads viaApiService.getLifestyleProfile(), saves on blur/change viaApiService.saveLifestyleProfile(), framed explicitly as "Optional — improves comparisons" with a local-only privacy footnote. - Compact comparison card —
frontend/src/app/shared/housing-commute-delta-card/(HousingCommuteDeltaCard), a self-contained standalone component takingHousingEquivalenceandCommuteCostProjectionas plaininput()s rather than reaching intoApiServiceitself. Deliberately decoupled from any job-detail page or theJobImpactconcept being built onfeature-financial-profile— a future job-detail page fetches both projections once and passes them down, composing this card alongside that branch's own card without file conflicts.
ApiService additions:
getLifestyleProfile(): Observable<LifestyleProfile> { return this.http.get<LifestyleProfile>(`${this.base}/api/lifestyle/profile`); }
saveLifestyleProfile(profile: LifestyleProfile) { return this.http.put<void>(`${this.base}/api/lifestyle/profile`, profile); }
Tests¶
LifestyleProfileServiceTests.cs— real temp-directoryLocalJsonStoreround-trip, key isolation fromintake/story.HudFmrSourceTests.cs,CensusAcsSourceTests.cs,ZillowHousingSourceTests.cs— FlurlHttpTeststubs with fixture JSON mirroring each source's documented shape; zero real network.HousingEquivalenceServiceTests.cs— priority fallback, graceful degradation, delta computation, against mockedIHousingSources.CommuteCostServiceTests.cs— exhaustive math tests (hand-computed example, zero-miles, missing inputs, zero/negative MPG, remote/hybrid/unknown work models, delta sign, gas-price-by-city, workday scaling, MPG scaling) against a mockedIGasPriceProvider.StubGasPriceProviderTests.cs— pins the stub's "always returns a usable constant" contract.- Frontend:
lifestyle.spec.ts,housing-commute-delta-card.spec.ts. - Stryker.NET mutation score: see the repo-root report —
**/Lifestyle/*.cswas added totests/WorkWingman.Tests/stryker-config.json'smutatelist. - StrykerJS (frontend) mutation score: scoped to
lifestyle.tsandhousing-commute-delta-card.tsvia the existingfrontend/stryker.conf.json(src/app/**/*.tsmutate glob already covers new files undersrc/app/).
Related docs¶
- Plain-language version: ../plain/lifestyle-costs.md
- Job enrichment signals (the
CompanyLayoffServicepattern this mirrors): job-signals.md - Testing philosophy and tooling: testing.md