Skip to content

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; Notes says 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

Program.cs:

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 sectionfrontend/src/app/features/lifestyle/ (Lifestyle component, route /lifestyle), following the same optional/skippable pattern as story.ts: loads via ApiService.getLifestyleProfile(), saves on blur/change via ApiService.saveLifestyleProfile(), framed explicitly as "Optional — improves comparisons" with a local-only privacy footnote.
  • Compact comparison cardfrontend/src/app/shared/housing-commute-delta-card/ (HousingCommuteDeltaCard), a self-contained standalone component taking HousingEquivalence and CommuteCostProjection as plain input()s rather than reaching into ApiService itself. Deliberately decoupled from any job-detail page or the JobImpact concept being built on feature-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