Skip to content

Commit 4f84e97

Browse files
committed
phase 6: production vs published target + methodology page
Each city with a real, public housing commitment on paper now gets a dual-line chart of cumulative units delivered vs the linear-pace trajectory to its goal. Sources: NYC Housing New York 2.0 (300k units by 2026, HPD) SF Housing Element 6th Cycle (46.6k affordable by 2031, ABAG) LA Housing Element 6th Cycle (184k affordable by 2029, SCAG) DC Bowser Housing Framework (12k affordable by 2025, DMPED) Targets live in lib/targets.ts with a sourceUrl and a notes field for the messy parts (what counts as affordable here, whether the basis is starts or completions, where our open-data sum will diverge from the agency's own accounting). Also adds /methodology, a long-form page documenting every datasource, every derived metric, and the known places the math gets fuzzy. Linked from each analytical panel. Future-me and journalists will both want this page. Sidebar grid goes from three toggles to four; layout flows to 2 cols. Signed-off-by: Charlie Tonneslan <cst0520@gmail.com>
1 parent 5540e76 commit 4f84e97

8 files changed

Lines changed: 921 additions & 6 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Built as a portfolio piece around the question: what does it actually take to co
1414
- **Rent burden choropleth** — toggleable layer showing what percentage of renter households in each census tract are paying >30% of income on rent, from ACS 2022 5-year.
1515
- **Supply–demand gap analysis** — a PostGIS spatial join that, for every tract, counts rent-burdened households against affordable units within 1 km. Returns the worst-served tracts as a clickable list with sparkline bars.
1616
- **Production trends** — a stacked bar chart of units produced per year, broken down by income tier (Extremely Low → Middle Income → Other). Works on the five cities whose datasets include a project start or completion date; Chicago's feed has neither.
17+
- **Production vs published target** — for cities with a real, public housing commitment on paper (NYC's Housing New York 2.0, SF and LA's state-mandated RHNA cycles, DC's Bowser Housing Framework), a dual-line chart of cumulative units delivered vs the linear-pace trajectory to the goal. Every target links to its source. The page at `/methodology` documents how the comparison is computed.
1718

1819
## Stack
1920

@@ -33,6 +34,7 @@ Built as a portfolio piece around the question: what does it actually take to co
3334
| `/api/gap?city=…&radius=1000&limit=25` | Worst-served tracts, ordered by (burdened households / nearby affordable units). |
3435
| `/api/stakeholders?city=…&district=…` | The elected representative for that district. |
3536
| `/api/trends?city=…` | Units per year, broken down by income tier and construction type. |
37+
| `/api/progress?city=…` | Cumulative units delivered vs the city's published housing target (where one is on file). |
3638

3739
## Run locally
3840

app/api/progress/route.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// GET /api/progress?city=nyc
2+
//
3+
// Joins each city's published housing-production target (see
4+
// lib/targets.ts) with the cumulative units actually delivered per
5+
// year in the open dataset, so the frontend can chart promise vs
6+
// delivery on one axis.
7+
//
8+
// "Per year" uses whichever of start_date or completion_date is set,
9+
// matching the trends endpoint. Most plans count financings or starts;
10+
// where a city's dataset only has completions, that's noted in the
11+
// target's `notes` field and shown to the user.
12+
13+
import { NextResponse } from "next/server";
14+
import { db, hasDatabase } from "@/lib/db";
15+
import { targetForCity } from "@/lib/targets";
16+
17+
export const runtime = "nodejs";
18+
19+
interface YearRow {
20+
year: number;
21+
units: number;
22+
}
23+
24+
export async function GET(req: Request) {
25+
if (!hasDatabase()) {
26+
return NextResponse.json(
27+
{ error: "DATABASE_URL is not configured." },
28+
{ status: 503 },
29+
);
30+
}
31+
32+
const url = new URL(req.url);
33+
const cityId = (url.searchParams.get("city") ?? "nyc").toLowerCase();
34+
35+
const target = targetForCity(cityId);
36+
if (!target) {
37+
return NextResponse.json({
38+
cityId,
39+
target: null,
40+
cumulative: [],
41+
note: "No published housing-production target on file for this city.",
42+
});
43+
}
44+
45+
const sql = `
46+
WITH dated AS (
47+
SELECT
48+
EXTRACT(YEAR FROM COALESCE(start_date, completion_date))::int AS year,
49+
units_total
50+
FROM projects
51+
WHERE city_id = $1
52+
AND COALESCE(start_date, completion_date) IS NOT NULL
53+
)
54+
SELECT year, SUM(units_total)::int AS units
55+
FROM dated
56+
WHERE year IS NOT NULL
57+
AND year >= $2
58+
GROUP BY year
59+
ORDER BY year ASC;
60+
`;
61+
62+
try {
63+
const res = await db.query<YearRow>(sql, [cityId, target.baselineYear]);
64+
65+
// Walk every year from baseline → target so the chart has a clean,
66+
// gap-free x-axis even when the dataset misses a year.
67+
const byYear = new Map<number, number>();
68+
for (const r of res.rows) byYear.set(r.year, r.units);
69+
70+
const yearly: { year: number; units: number }[] = [];
71+
let runningTotal = 0;
72+
const cumulative: {
73+
year: number;
74+
units: number;
75+
cumulative: number;
76+
targetCumulative: number;
77+
}[] = [];
78+
const yearsTotal = target.targetYear - target.baselineYear;
79+
const perYearTarget = target.targetUnits / Math.max(yearsTotal, 1);
80+
81+
for (let y = target.baselineYear; y <= target.targetYear; y++) {
82+
const u = byYear.get(y) ?? 0;
83+
runningTotal += u;
84+
yearly.push({ year: y, units: u });
85+
cumulative.push({
86+
year: y,
87+
units: u,
88+
cumulative: runningTotal,
89+
targetCumulative: Math.round(perYearTarget * (y - target.baselineYear + 1)),
90+
});
91+
}
92+
93+
// Find the last year with actual data so the frontend can render
94+
// "as of YEAR, you are at X% of target" honestly.
95+
const lastDataYear =
96+
res.rows.length > 0 ? res.rows[res.rows.length - 1].year : target.baselineYear;
97+
const lastEntry =
98+
cumulative.find((c) => c.year === lastDataYear) ??
99+
cumulative[cumulative.length - 1];
100+
101+
return NextResponse.json({
102+
cityId,
103+
target,
104+
cumulative,
105+
yearly,
106+
lastDataYear,
107+
progress: {
108+
delivered: lastEntry?.cumulative ?? 0,
109+
expectedByNow: lastEntry?.targetCumulative ?? 0,
110+
pctOfFinalTarget: lastEntry
111+
? Math.round((lastEntry.cumulative / target.targetUnits) * 1000) / 10
112+
: 0,
113+
pctOfExpected: lastEntry && lastEntry.targetCumulative > 0
114+
? Math.round((lastEntry.cumulative / lastEntry.targetCumulative) * 1000) / 10
115+
: 0,
116+
},
117+
});
118+
} catch (e) {
119+
const msg = e instanceof Error ? e.message : "unknown db error";
120+
return NextResponse.json({ error: msg }, { status: 500 });
121+
}
122+
}

app/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ const jbMono = JetBrains_Mono({
1313
});
1414

1515
export const metadata: Metadata = {
16-
title: "groundwork — NYC affordable housing in flight",
16+
title: "groundwork — affordable housing across six U.S. cities",
1717
description:
18-
"Interactive map of every affordable housing project in New York City's HPD pipeline. Filter by borough, construction type, and unit count, and see what's actually being built in your neighborhood.",
18+
"Interactive map of ~6,500 affordable-housing projects across NYC, SF, LA, DC, Chicago, and Philadelphia. Compare cities, see rent-burden by tract, find underserved neighborhoods, chart production over time, and measure each city against its own published housing target.",
1919
};
2020

2121
export default function RootLayout({

0 commit comments

Comments
 (0)