diff --git a/AGENTS.md b/AGENTS.md
index 14b61fe4..ac647865 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -140,6 +140,44 @@ Migration value baseline (merge-to-live wall-clock, detection exemplars, rollbac
Characterization: `node --test test/live_url_smoke.test.mjs test/post_flip_checks.test.mjs`. Operator flip procedure lives outside this public tree.
+## Hearing participation (one owner, list + detail)
+
+Meetings list cards and notice permalinks share one derivation:
+`normalizeHearing` / `normalizeHearingRow` → `participation.links` →
+`participationLinksHTML` in `site/index.html`. Strip trailing punctuation
+**before** dedupe (body often has `https://…hearings,` and `https://…hearings`);
+one outbound affordance per notice. NYCIDA board URL labels as **IDA meetings page**
+(the deepest public target those notices publish). Characterization:
+`node --test test/ida_notice_defects.test.mjs`. Captures:
+`python3 tools/capture_ida_notice_defects.py`.
+
+## Contract lifecycle category gate
+
+`isContractLifecycleEligible` — Procurement section or Solicitation/Award/Intent
+types only. Hearings, Agency Rules, Property Disposition, and Changes in Personnel
+never mount contract lifecycle / OCP / PIN gap modules (wrong-universe). Subsidy and
+meeting-outcomes keep their own eligibility helpers. Characterization:
+`test/ida_notice_defects.test.mjs`, `test/lifecycle_coherence_field_cases.test.mjs`.
+
+## Subsidy lifecycle (NYCIDA / Build NYC)
+
+Endpoint `GET /subsidy-lifecycle?id=` (`worker/src/subsidy_lifecycle.mjs`). The
+EDC documents page is often Cloudflare-blocked to edge fetch (HTTP 403 / challenge
+HTML) — treat as feed failure, do **not** permanently D1-cache `source_status:
+unavailable`. When the feed fails, `projectFromIdaNotice` derives a hearing-stage
+join from the City Record IDA hearing notice (company names, event date). Keep
+honest unavailable copy only when the feed is down **and** no notice-derived
+hearing applies. Schema safety net: `ensureSubsidySchema` (migration
+`0005_subsidy_lifecycle.sql`).
+
+**Age-aware gap kinds** (temporal sibling of paid / verified_zero / unavailable):
+`subsidyGapKind` → `too_soon` | `not_published` | (worker) `unavailable`. Lag table
+`SUBSIDY_STAGE_EXPECT_LAG_DAYS` (board ~60d, closing ~180d, project_record ~90d).
+Demo/backtest notices must be **aged** (2022–2024 hearings) so later stages read
+`not_published`, not “could not reach.” Young hearings use “check back” copy.
+Characterization: `test/subsidy_lifecycle.test.mjs`, `test/ida_notice_defects.test.mjs`.
+Aged demo ids: `20220525018`, `20231004016`, `20240617012`.
+
## Checkbook Contracts row identity
Checkbook's Contracts domain returns **multiple rows per `prime_contract_id`** (one Prime Vendor row with amounts, plus Sub Vendor / expense-category slices with $0 on prime fields). Lifecycle assembly collapses rows with `aggregateContractsById` before `classifyStage` — one distinct id = matched; ≥2 distinct ids = ambiguous. Field case: notice `20231222103` / `CT107120248803393`. Do not count raw Contracts rows as separate contracts. Spending rows stay uncollapsed (many payments per contract is normal). Pure lib: `worker/src/lib/checkbook_lifecycle.mjs`.
diff --git a/docs/screenshots/ida-notice-defects/_subsidy_after.html b/docs/screenshots/ida-notice-defects/_subsidy_after.html
new file mode 100644
index 00000000..42cf4646
--- /dev/null
+++ b/docs/screenshots/ida-notice-defects/_subsidy_after.html
@@ -0,0 +1,35 @@
+
+
+IDA subsidy after
+
+
CityScroll
+
✓ After · #notice/20250227021 · one IDA meetings link · no contract modules · subsidy from City Record hearing
Linked project: IDA March 20th, 2025 Public Hearing Notice · company NYM 145 Wolcott LLC · stage Hearing.
+
+
Application
2025-02-27
+
→
+
Hearing
2025-03-20
+
→
+
Board decision
—
+
The city does not publish this — it would appear on Build NYC project documents if released.
+
+
Stages join City Record notices to Build NYC and NYC Industrial Development Agency project records when a public match exists. Hearing stage here is from the City Record notice when the Build NYC feed is unreachable.
+
diff --git a/docs/screenshots/ida-notice-defects/after-meetings-list-1440.png b/docs/screenshots/ida-notice-defects/after-meetings-list-1440.png
new file mode 100644
index 00000000..99765c37
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/after-meetings-list-1440.png differ
diff --git a/docs/screenshots/ida-notice-defects/after-meetings-list-390.png b/docs/screenshots/ida-notice-defects/after-meetings-list-390.png
new file mode 100644
index 00000000..3af6b067
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/after-meetings-list-390.png differ
diff --git a/docs/screenshots/ida-notice-defects/after-notice-actions-1440.png b/docs/screenshots/ida-notice-defects/after-notice-actions-1440.png
new file mode 100644
index 00000000..eef7aff7
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/after-notice-actions-1440.png differ
diff --git a/docs/screenshots/ida-notice-defects/after-notice-actions-390.png b/docs/screenshots/ida-notice-defects/after-notice-actions-390.png
new file mode 100644
index 00000000..4498cf0f
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/after-notice-actions-390.png differ
diff --git a/docs/screenshots/ida-notice-defects/after-notice-modules-1440.png b/docs/screenshots/ida-notice-defects/after-notice-modules-1440.png
new file mode 100644
index 00000000..eef7aff7
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/after-notice-modules-1440.png differ
diff --git a/docs/screenshots/ida-notice-defects/after-notice-modules-390.png b/docs/screenshots/ida-notice-defects/after-notice-modules-390.png
new file mode 100644
index 00000000..4498cf0f
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/after-notice-modules-390.png differ
diff --git a/docs/screenshots/ida-notice-defects/after-subsidy-shell-1440.png b/docs/screenshots/ida-notice-defects/after-subsidy-shell-1440.png
new file mode 100644
index 00000000..37b4d850
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/after-subsidy-shell-1440.png differ
diff --git a/docs/screenshots/ida-notice-defects/after-subsidy-shell-390.png b/docs/screenshots/ida-notice-defects/after-subsidy-shell-390.png
new file mode 100644
index 00000000..e3d2e017
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/after-subsidy-shell-390.png differ
diff --git a/docs/screenshots/ida-notice-defects/before-meetings-list-1440.png b/docs/screenshots/ida-notice-defects/before-meetings-list-1440.png
new file mode 100644
index 00000000..2f368a91
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/before-meetings-list-1440.png differ
diff --git a/docs/screenshots/ida-notice-defects/before-meetings-list-390.png b/docs/screenshots/ida-notice-defects/before-meetings-list-390.png
new file mode 100644
index 00000000..3d14fcfe
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/before-meetings-list-390.png differ
diff --git a/docs/screenshots/ida-notice-defects/before-notice-actions-1440.png b/docs/screenshots/ida-notice-defects/before-notice-actions-1440.png
new file mode 100644
index 00000000..3fc4e60b
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/before-notice-actions-1440.png differ
diff --git a/docs/screenshots/ida-notice-defects/before-notice-actions-390.png b/docs/screenshots/ida-notice-defects/before-notice-actions-390.png
new file mode 100644
index 00000000..35e20c81
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/before-notice-actions-390.png differ
diff --git a/docs/screenshots/ida-notice-defects/before-notice-modules-1440.png b/docs/screenshots/ida-notice-defects/before-notice-modules-1440.png
new file mode 100644
index 00000000..3fc4e60b
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/before-notice-modules-1440.png differ
diff --git a/docs/screenshots/ida-notice-defects/before-notice-modules-390.png b/docs/screenshots/ida-notice-defects/before-notice-modules-390.png
new file mode 100644
index 00000000..35e20c81
Binary files /dev/null and b/docs/screenshots/ida-notice-defects/before-notice-modules-390.png differ
diff --git a/site/hearing_location.js b/site/hearing_location.js
index c0b55d3d..5c577946 100644
--- a/site/hearing_location.js
+++ b/site/hearing_location.js
@@ -227,17 +227,51 @@ function normalizeHearingRow(row) {
published_at: row.start_date || null, decides: hearingDecision(row, body),
affects: audience ? [audience[1]] : [], affected_area: hearingAffectedArea(row),
venue: hearingVenue(row),
- participation: {
- links: hearingUnique(body.match(HEARING_URL_RE) || []).slice(0, 8).map(function (url) {
- return { label: /\b(?:zoom|webex|teams|meet\.google)\b/i.test(url) ? "Join online" : "Participation link", url: url.replace(/[.,;]+$/, "") };
- }),
- emails: hearingUnique(Array.from(body.matchAll(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi)).map(function (match) { return match[0]; })).slice(0, 4),
- phones: hearingUnique(Array.from(body.matchAll(/(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}/g)).map(function (match) { return match[0]; })).slice(0, 4),
- source_url: source,
- },
+ participation: hearingParticipationFromBody(body, source),
source_url: source, description: body.slice(0, 1200),
};
}
+// Hand-synced with worker/src/lib/hearings.mjs participationFromRow: strip trailing
+// punctuation before dedupe so "…hearings," and "…hearings" collapse; one affordance.
+function hearingNormalizeParticipationUrl(url) {
+ return String(url || "").replace(/[.,;:)\]]+$/g, "").trim();
+}
+function hearingParticipationUrlKey(url) {
+ try {
+ var parsed = new URL(url);
+ return (parsed.host + parsed.pathname).toLowerCase().replace(/\/$/, "");
+ } catch (e) {
+ return String(url || "").toLowerCase().replace(/\/$/, "");
+ }
+}
+function hearingParticipationLabel(url) {
+ if (/\b(?:zoom|webex|teams|meet\.google)\b/i.test(url)) return "Join online";
+ if (/nycida-board-meetings-public-hearings/i.test(url) || /edc\.nyc\/nycida(?:[/?#]|$)/i.test(url)) {
+ return "IDA meetings page";
+ }
+ return "Participation link";
+}
+function hearingParticipationFromBody(body, source) {
+ var cleaned = (String(body || "").match(HEARING_URL_RE) || []).map(hearingNormalizeParticipationUrl).filter(Boolean);
+ var byKey = Object.create(null);
+ cleaned.forEach(function (url) {
+ var key = hearingParticipationUrlKey(url);
+ if (!key || byKey[key]) return;
+ byKey[key] = { label: hearingParticipationLabel(url), url: url };
+ });
+ var ranked = Object.keys(byKey).map(function (k) { return byKey[k]; }).sort(function (a, b) {
+ var aJoin = a.label === "Join online" ? 0 : 1;
+ var bJoin = b.label === "Join online" ? 0 : 1;
+ if (aJoin !== bJoin) return aJoin - bJoin;
+ return b.url.length - a.url.length;
+ });
+ return {
+ links: ranked.slice(0, 1),
+ emails: hearingUnique(Array.from(String(body || "").matchAll(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi)).map(function (match) { return match[0]; })).slice(0, 4),
+ phones: hearingUnique(Array.from(String(body || "").matchAll(/(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}/g)).map(function (match) { return match[0]; })).slice(0, 4),
+ source_url: source,
+ };
+}
function hearingMatchesArea(record, filter) {
var f = filter || {}, area = record.affected_area || {}, borough = String(f.borough || "").toLowerCase();
var neighborhood = String(f.neighborhood || "").trim().toLowerCase();
diff --git a/site/i18n.js b/site/i18n.js
index 13518b67..7afc7399 100644
--- a/site/i18n.js
+++ b/site/i18n.js
@@ -403,6 +403,7 @@ const STRINGS = {
read_official_notice: "Read official notice",
join_online: "Join online",
participation_link: "Participation link",
+ ida_meetings_page: "IDA meetings page",
email_in_notice: "Email listed in notice",
map_venue: "Map venue",
venue_label: "Venue",
@@ -968,6 +969,11 @@ const STRINGS = {
subsidy_outcome_html: "Outcome: {outcome}",
subsidy_outcome_unknown_html: "The city does not publish this outcome — it would appear on the Build NYC project documents if released.",
subsidy_stage_unmatched_html: "Not yet shown here — {stage} records live in {source}.",
+ // Age-aware gaps: too_soon vs not_published vs unavailable (fetch). Temporal sibling of
+ // paid / verified_zero / unavailable on the Checkbook payment path.
+ subsidy_stage_too_soon_html: "Hearing was {date} — {stage} records typically appear within about {weeks} weeks. Check back. Too recent to treat as a permanent gap.",
+ subsidy_stage_not_published_html: "The city does not publish this {stage} record on {source} — it would appear there if released.",
+ subsidy_join_too_soon_html: "Hearing was {date} — IDA project records for “{title}” usually appear within about {weeks} weeks of the hearing. Check back. Too recent to treat as a permanent gap.",
subsidy_source_build_nyc: "Build NYC and NYC Industrial Development Agency",
subsidy_source_unavailable_html: "Could not reach {source} to check the subsidy timeline for this notice.",
subsidy_unmatched_html: "The city does not publish a linked subsidy project for “{title}” — {reason}",
diff --git a/site/i18n/lang/ar.js b/site/i18n/lang/ar.js
index 6ec9f227..80fd821a 100644
--- a/site/i18n/lang/ar.js
+++ b/site/i18n/lang/ar.js
@@ -276,6 +276,7 @@
read_official_notice: "قراءة الإشعار الرسمي",
join_online: "الانضمام عبر الإنترنت",
participation_link: "رابط المشاركة",
+ ida_meetings_page: "صفحة اجتماعات IDA",
email_in_notice: "البريد الوارد في الإشعار",
map_venue: "خريطة مكان الجلسة",
venue_label: "مكان الجلسة",
@@ -636,6 +637,9 @@
subsidy_outcome_html: "النتيجة: {outcome}",
subsidy_outcome_unknown_html: "المدينة لا تنشر هذه النتيجة — ستظهر في مستندات مشروع Build NYC إذا نُشرت.",
subsidy_stage_unmatched_html: "لم يُعرض هنا بعد — سجلات {stage} موجودة في {source}.",
+ subsidy_stage_too_soon_html: "كانت الجلسة في {date} — تظهر سجلات {stage} عادةً خلال نحو {weeks} أسابيع. راجع لاحقًا. أبكر من أن تُعامل كفجوة دائمة.",
+ subsidy_stage_not_published_html: "المدينة لا تنشر سجل {stage} هذا على {source} — سيظهر هناك إذا نُشر.",
+ subsidy_join_too_soon_html: "كانت الجلسة في {date} — سجلات مشاريع IDA لـ “{title}” تظهر عادةً خلال نحو {weeks} أسابيع بعد الجلسة. راجع لاحقًا. أبكر من أن تُعامل كفجوة دائمة.",
subsidy_source_build_nyc: "Build NYC و NYC Industrial Development Agency",
subsidy_source_unavailable_html: "تعذّر الوصول إلى {source} للتحقق من الجدول الزمني للدعم لهذا الإشعار.",
subsidy_unmatched_html: "المدينة لا تنشر مشروع دعم مرتبطًا بـ “{title}” — {reason}",
diff --git a/site/i18n/lang/bn.js b/site/i18n/lang/bn.js
index 4e929a3c..a4d52360 100644
--- a/site/i18n/lang/bn.js
+++ b/site/i18n/lang/bn.js
@@ -273,6 +273,7 @@
read_official_notice: "সরকারি বিজ্ঞপ্তি পড়ুন",
join_online: "অনলাইনে যোগ দিন",
participation_link: "অংশগ্রহণের লিংক",
+ ida_meetings_page: "IDA সভার পৃষ্ঠা",
email_in_notice: "বিজ্ঞপ্তির ইমেইল",
map_venue: "সভাস্থলের মানচিত্র",
venue_label: "সভাস্থল",
@@ -629,6 +630,9 @@
subsidy_outcome_html: "ফলাফল: {outcome}",
subsidy_outcome_unknown_html: "শহর এই ফলাফল প্রকাশ করে না — প্রকাশিত হলে এটি Build NYC প্রকল্পের নথিতে থাকত।",
subsidy_stage_unmatched_html: "এখানে এখনও দেখানো হয়নি — {stage} রেকর্ড {source}-এ আছে।",
+ subsidy_stage_too_soon_html: "শুনানি হয়েছিল {date} — {stage} রেকর্ড সাধারণত প্রায় {weeks} সপ্তাহের মধ্যে আসে। পরে দেখুন। স্থায়ী ফাঁক ধরতে এখনও তাড়াতাড়ি।",
+ subsidy_stage_not_published_html: "শহর {source}-এ এই {stage} রেকর্ড প্রকাশ করে না — প্রকাশিত হলে সেখানে থাকত।",
+ subsidy_join_too_soon_html: "শুনানি হয়েছিল {date} — “{title}”-এর IDA প্রকল্প রেকর্ড সাধারণত শুনানির প্রায় {weeks} সপ্তাহের মধ্যে আসে। পরে দেখুন। স্থায়ী ফাঁক ধরতে এখনও তাড়াতাড়ি।",
subsidy_source_build_nyc: "Build NYC এবং NYC Industrial Development Agency",
subsidy_source_unavailable_html: "এই বিজ্ঞপ্তির ভর্তুকি সময়রেখা যাচাই করতে {source}-এ পৌঁছানো যায়নি।",
subsidy_unmatched_html: "শহর “{title}”-এর জন্য কোনো সংযুক্ত ভর্তুকি প্রকল্প প্রকাশ করে না — {reason}",
diff --git a/site/i18n/lang/es.js b/site/i18n/lang/es.js
index f0f0c63e..14b07a1e 100644
--- a/site/i18n/lang/es.js
+++ b/site/i18n/lang/es.js
@@ -273,6 +273,7 @@
read_official_notice: "Leer aviso oficial",
join_online: "Participar en línea",
participation_link: "Enlace para participar",
+ ida_meetings_page: "Página de reuniones de la IDA",
email_in_notice: "Correo del aviso",
map_venue: "Mapa del lugar",
venue_label: "Lugar",
@@ -629,6 +630,9 @@
subsidy_outcome_html: "Resultado: {outcome}",
subsidy_outcome_unknown_html: "La ciudad no publica este resultado — aparecería en los documentos del proyecto Build NYC si se publicara.",
subsidy_stage_unmatched_html: "Aún no se muestra aquí — los registros de {stage} están en {source}.",
+ subsidy_stage_too_soon_html: "Hearing was {date} — los registros de {stage} suelen aparecer en unas {weeks} semanas. Vuelva a consultar. es demasiado reciente para tratarlo como un vacío permanente.",
+ subsidy_stage_not_published_html: "La ciudad no publica este registro de {stage} en {source} — aparecería allí si se publicara.",
+ subsidy_join_too_soon_html: "La audiencia fue el {date} — los registros de proyectos de la IDA para “{title}” suelen aparecer en unas {weeks} semanas tras la audiencia. Vuelva a consultar. es demasiado reciente para tratarlo como un vacío permanente.",
subsidy_source_build_nyc: "Build NYC y la Agencia de Desarrollo Industrial de NYC",
subsidy_source_unavailable_html: "No se pudo acceder a {source} para verificar la línea de tiempo del subsidio de este aviso.",
subsidy_unmatched_html: "La ciudad no publica un proyecto de subsidio vinculado para “{title}” — {reason}",
diff --git a/site/i18n/lang/fr.js b/site/i18n/lang/fr.js
index 0142f565..a77c61aa 100644
--- a/site/i18n/lang/fr.js
+++ b/site/i18n/lang/fr.js
@@ -302,6 +302,7 @@
read_official_notice: "Lire l’avis officiel",
join_online: "Participer en ligne",
participation_link: "Lien de participation",
+ ida_meetings_page: "Page des réunions de l’IDA",
email_in_notice: "Courriel indiqué",
map_venue: "Carte du lieu",
venue_label: "Lieu",
@@ -711,6 +712,9 @@
subsidy_outcome_html: "Résultat : {outcome}",
subsidy_outcome_unknown_html: "La ville ne publie pas ce résultat — il apparaîtrait sur les documents du projet Build NYC s'il était publié.",
subsidy_stage_unmatched_html: "Pas encore affiché ici — les dossiers de {stage} sont dans {source}.",
+ subsidy_stage_too_soon_html: "Audience le {date} — les dossiers de {stage} apparaissent en général dans un délai d’environ {weeks} semaines. Revenez plus tard. trop récent pour un écart permanent.",
+ subsidy_stage_not_published_html: "La ville ne publie pas ce dossier de {stage} sur {source} — il y figurerait s’il était publié.",
+ subsidy_join_too_soon_html: "Audience le {date} — les dossiers de projet IDA pour « {title} » apparaissent en général dans un délai d’environ {weeks} semaines après l’audience. Revenez plus tard. trop récent pour un écart permanent.",
subsidy_source_build_nyc: "Build NYC et NYC Industrial Development Agency",
subsidy_source_unavailable_html: "Impossible d'atteindre {source} pour vérifier le calendrier de subvention de cet avis.",
subsidy_unmatched_html: "La ville ne publie pas de projet de subvention lié pour « {title} » — {reason}",
diff --git a/site/i18n/lang/ht.js b/site/i18n/lang/ht.js
index cb6e8889..42543d65 100644
--- a/site/i18n/lang/ht.js
+++ b/site/i18n/lang/ht.js
@@ -272,6 +272,7 @@
read_official_notice: "Li avi ofisyèl la",
join_online: "Patisipe sou entènèt",
participation_link: "Lyen pou patisipe",
+ ida_meetings_page: "Paj reyinyon IDA",
email_in_notice: "Imèl nan avi a",
map_venue: "Kat kote a",
venue_label: "Kote odyans lan",
@@ -628,6 +629,9 @@
subsidy_outcome_html: "Rezilta: {outcome}",
subsidy_outcome_unknown_html: "Vil la pa pibliye rezilta sa a — li t ap parèt nan dokiman pwojè Build NYC si yo te pibliye l.",
subsidy_stage_unmatched_html: "Pokò a pa montre isit la — dosye {stage} yo nan {source}.",
+ subsidy_stage_too_soon_html: "Odyans lan te {date} — dosye {stage} anjeneral parèt nan apeprè {weeks} semèn. Tcheke pita. twò resan pou trete kòm yon twou pèmanan.",
+ subsidy_stage_not_published_html: "Vil la pa pibliye dosye {stage} sa a sou {source} — li t ap parèt la si yo te pibliye l.",
+ subsidy_join_too_soon_html: "Odyans lan te {date} — dosye pwojè IDA pou “{title}” anjeneral parèt nan apeprè {weeks} semèn apre odyans lan. Tcheke pita. twò resan pou trete kòm yon twou pèmanan.",
subsidy_source_build_nyc: "Build NYC ak NYC Industrial Development Agency",
subsidy_source_unavailable_html: "Pa t kapab rive jwenn {source} pou verifye delè sibvansyon avi sa a.",
subsidy_unmatched_html: "Vil la pa pibliye yon pwojè sibvansyon ki lye pou “{title}” — {reason}",
diff --git a/site/i18n/lang/ko.js b/site/i18n/lang/ko.js
index 71f4c682..da55ec62 100644
--- a/site/i18n/lang/ko.js
+++ b/site/i18n/lang/ko.js
@@ -273,6 +273,7 @@
read_official_notice: "공식 공고 읽기",
join_online: "온라인 참여",
participation_link: "참여 링크",
+ ida_meetings_page: "IDA 회의 페이지",
email_in_notice: "공고의 이메일",
map_venue: "장소 지도",
venue_label: "개최 장소",
@@ -629,6 +630,9 @@
subsidy_outcome_html: "결과: {outcome}",
subsidy_outcome_unknown_html: "시는 이 결과를 공개하지 않습니다 — 공개되면 Build NYC 프로젝트 문서에 나타납니다.",
subsidy_stage_unmatched_html: "아직 여기 표시되지 않음 — {stage} 기록은 {source}에 있습니다.",
+ subsidy_stage_too_soon_html: "공청회 일자 {date} — {stage} 기록은 보통 약 {weeks}주 안에 나옵니다. 나중에 다시 확인하세요. 영구 공백으로 보기엔 너무 이릅니다.",
+ subsidy_stage_not_published_html: "시 당국은 {source}에 이 {stage} 기록을 공개하지 않습니다 — 공개되면 그곳에 나타날 것입니다.",
+ subsidy_join_too_soon_html: "공청회 일자 {date} — “{title}”의 IDA 프로젝트 기록은 보통 공청회 후 약 {weeks}주 안에 나옵니다. 나중에 다시 확인하세요. 영구 공백으로 보기엔 너무 이릅니다.",
subsidy_source_build_nyc: "Build NYC 및 NYC Industrial Development Agency",
subsidy_source_unavailable_html: "이 공고의 보조금 일정을 확인하기 위해 {source}에 연결할 수 없습니다.",
subsidy_unmatched_html: "시는 “{title}”에 연결된 보조금 프로젝트를 공개하지 않습니다 — {reason}",
diff --git a/site/i18n/lang/pl.js b/site/i18n/lang/pl.js
index 55d9bfb9..ed37be8f 100644
--- a/site/i18n/lang/pl.js
+++ b/site/i18n/lang/pl.js
@@ -281,6 +281,7 @@
read_official_notice: "Przeczytaj oficjalne ogłoszenie",
join_online: "Dołącz online",
participation_link: "Link do udziału",
+ ida_meetings_page: "Strona posiedzeń IDA",
email_in_notice: "E-mail z ogłoszenia",
map_venue: "Mapa miejsca",
venue_label: "Miejsce",
@@ -647,6 +648,9 @@
subsidy_outcome_html: "Wynik: {outcome}",
subsidy_outcome_unknown_html: "Miasto nie publikuje tego wyniku — pojawiłby się w dokumentach projektu Build NYC, gdyby został opublikowany.",
subsidy_stage_unmatched_html: "Jeszcze nie pokazane tutaj — wpisy etapu {stage} są w {source}.",
+ subsidy_stage_too_soon_html: "Wysłuchanie było {date} — zapisy {stage} zwykle pojawiają się w ciągu ok. {weeks} tyg. Sprawdź później. za wcześnie na stałą lukę.",
+ subsidy_stage_not_published_html: "Miasto nie publikuje tego zapisu {stage} w {source} — pojawiłby się tam po udostępnieniu.",
+ subsidy_join_too_soon_html: "Wysłuchanie było {date} — zapisy projektów IDA dla „{title}” zwykle pojawiają się w ciągu ok. {weeks} tyg. po wysłuchaniu. Sprawdź później. za wcześnie na stałą lukę.",
subsidy_source_build_nyc: "Build NYC i NYC Industrial Development Agency",
subsidy_source_unavailable_html: "Nie udało się dotrzeć do {source}, aby sprawdzić harmonogram dotacji dla tego ogłoszenia.",
subsidy_unmatched_html: "Miasto nie publikuje powiązanego projektu dotacyjnego dla „{title}” — {reason}",
diff --git a/site/i18n/lang/ru.js b/site/i18n/lang/ru.js
index d41db3b7..cec0b9e8 100644
--- a/site/i18n/lang/ru.js
+++ b/site/i18n/lang/ru.js
@@ -273,6 +273,7 @@
read_official_notice: "Читать официальное объявление",
join_online: "Присоединиться онлайн",
participation_link: "Ссылка для участия",
+ ida_meetings_page: "Страница заседаний IDA",
email_in_notice: "Эл. почта из объявления",
map_venue: "Место на карте",
venue_label: "Место проведения",
@@ -639,6 +640,9 @@
subsidy_outcome_html: "Исход: {outcome}",
subsidy_outcome_unknown_html: "Город не публикует этот итог — при публикации он появился бы в документах проекта Build NYC.",
subsidy_stage_unmatched_html: "Пока не показано здесь — записи этапа «{stage}» есть в {source}.",
+ subsidy_stage_too_soon_html: "Слушание было {date} — записи {stage} обычно появляются примерно через {weeks} нед. Проверьте позже. слишком рано считать это постоянным пробелом.",
+ subsidy_stage_not_published_html: "Город не публикует эту запись {stage} в {source} — она появилась бы там при выпуске.",
+ subsidy_join_too_soon_html: "Слушание было {date} — проектные записи IDA для «{title}» обычно появляются примерно через {weeks} нед. после слушания. Проверьте позже. слишком рано считать это постоянным пробелом.",
subsidy_source_build_nyc: "Build NYC и NYC Industrial Development Agency",
subsidy_source_unavailable_html: "Не удалось связаться с {source}, чтобы проверить график субсидии для этого извещения.",
subsidy_unmatched_html: "Город не публикует связанный субсидионный проект для «{title}» — {reason}",
diff --git a/site/i18n/lang/ur.js b/site/i18n/lang/ur.js
index 7b1705f5..35205481 100644
--- a/site/i18n/lang/ur.js
+++ b/site/i18n/lang/ur.js
@@ -276,6 +276,7 @@
read_official_notice: "سرکاری نوٹس پڑھیں",
join_online: "آن لائن شامل ہوں",
participation_link: "شرکت کا لنک",
+ ida_meetings_page: "IDA میٹنگز کا صفحہ",
email_in_notice: "نوٹس میں درج ای میل",
map_venue: "اجلاس کی جگہ کا نقشہ",
venue_label: "اجلاس کی جگہ",
@@ -636,6 +637,9 @@
subsidy_outcome_html: "نتیجہ: {outcome}",
subsidy_outcome_unknown_html: "شہر یہ نتیجہ شائع نہیں کرتا — اگر جاری ہو تو Build NYC پروجیکٹ دستاویزات میں ظاہر ہوتا۔",
subsidy_stage_unmatched_html: "ابھی یہاں نہیں دکھایا گیا — {stage} ریکارڈز {source} میں ہیں۔",
+ subsidy_stage_too_soon_html: "سماعت {date} کو ہوئی — {stage} ریکارڈ عام طور پر تقریباً {weeks} ہفتوں میں آتے ہیں۔ بعد میں دیکھیں۔ مستقل خلا سمجھنے کے لیے بہت جلد ہے۔",
+ subsidy_stage_not_published_html: "شہر {source} پر یہ {stage} ریکارڈ شائع نہیں کرتا — اگر شائع ہوتا تو وہاں ہوتا۔",
+ subsidy_join_too_soon_html: "سماعت {date} کو ہوئی — “{title}” کے IDA پروجیکٹ ریکارڈ عام طور پر سماعت کے تقریباً {weeks} ہفتوں میں آتے ہیں۔ بعد میں دیکھیں۔ مستقل خلا سمجھنے کے لیے بہت جلد ہے۔",
subsidy_source_build_nyc: "Build NYC اور NYC Industrial Development Agency",
subsidy_source_unavailable_html: "اس نوٹس کی سبسڈی ٹائم لائن جانچنے کے لیے {source} تک رسائی نہ ہو سکی۔",
subsidy_unmatched_html: "شہر “{title}” کے لیے مربوط سبسڈی پروجیکٹ شائع نہیں کرتا — {reason}",
diff --git a/site/i18n/lang/zh-Hans.js b/site/i18n/lang/zh-Hans.js
index 02411c52..1162c812 100644
--- a/site/i18n/lang/zh-Hans.js
+++ b/site/i18n/lang/zh-Hans.js
@@ -273,6 +273,7 @@
read_official_notice: "阅读官方公告",
join_online: "在线参加",
participation_link: "参与链接",
+ ida_meetings_page: "IDA 会议页面",
email_in_notice: "公告中的电子邮件",
map_venue: "查看会场地图",
venue_label: "会场",
@@ -629,6 +630,9 @@
subsidy_outcome_html: "结果:{outcome}",
subsidy_outcome_unknown_html: "市政府未公布此项结果 — 如发布将出现在 Build NYC 项目文件中。",
subsidy_stage_unmatched_html: "此处尚未显示 — {stage} 记录在 {source}。",
+ subsidy_stage_too_soon_html: "听证会日期为 {date} — {stage} 记录通常在约 {weeks} 周内出现。请稍后再查;时间太近,不宜当作永久缺失。",
+ subsidy_stage_not_published_html: "市政府未在 {source} 发布此 {stage} 记录 — 如发布将出现在那里。",
+ subsidy_join_too_soon_html: "听证会日期为 {date} — “{title}” 的 IDA 项目记录通常在听证后约 {weeks} 周出现。请稍后再查;时间太近,不宜当作永久缺失。",
subsidy_source_build_nyc: "Build NYC 与纽约市工业发展局",
subsidy_source_unavailable_html: "无法访问 {source} 以核对本公告的补贴时间线。",
subsidy_unmatched_html: "市政府未为“{title}”公布关联的补贴项目 — {reason}",
diff --git a/site/index.html b/site/index.html
index 3b59f33d..29f33aff 100644
--- a/site/index.html
+++ b/site/index.html
@@ -2624,12 +2624,16 @@
Preview
}
function renderDetail(r, chain, stats){
const pending = chain === null; // first paint from the in-memory record; chain/stats hydrate in
+ // Same participation derivation as the meetings list (normalizeHearingRow → one link).
+ const detailHearing=isMeetingOutcomesEligible(r)?normalizeHearingRow(r):null;
+ const detailParticipation=detailHearing?participationLinksHTML(detailHearing):"";
let html = `
statements in the established "no record found" register, never blank. Each stage links to
its authoritative source page (City Record or Checkbook NYC). OCP Recent Contract Awards
(qyyg-4tf5) rides the same precomputed payload as an award side-car for date/amount
- corroboration — disagreements name both sources. */
+ corroboration — disagreements name both sources.
+
+ Category-aware dispatch: only procurement notices mount this module. Hearings, rules,
+ property disposition, and staffing are wrong-universe for PIN/Checkbook/OCP gaps — leave
+ the slot empty rather than stacking "not found" cards on a public hearing. */
+
+// Contract lifecycle (PIN, Checkbook, OCP awards) is meaningful only for procurement.
+// Section is authoritative when present; notice type covers paths that omit section_name.
+function isContractLifecycleEligible(r){
+ if(!r) return false;
+ const section=String(r.section_name||"");
+ if(section==="Procurement") return true;
+ if(section==="Public Hearings and Meetings") return false;
+ if(section==="Agency Rules") return false;
+ if(section==="Property Disposition") return false;
+ if(section==="Changes in Personnel") return false;
+ return /^(Solicitation|Award|Intent to Award)$/.test(r.type_of_notice_description||"");
+}
const CHECKBOOK_SEARCH_URL = "https://www.checkbooknyc.com/contract_search";
const CHECKBOOK_SPENDING_URL = "https://www.checkbooknyc.com/spending_search";
const CHECKBOOK_SMART_SEARCH = "https://www.checkbooknyc.com/smart_search/citywide";
@@ -6294,8 +6331,14 @@
${t("career_outcomes_heading")}
// Fetches the precomputed lifecycle from the worker and renders the timeline + dollars
// panel. Fail-soft on network: says nothing rather than inventing a gap. The read model is
// fully precomputed server-side (worker/src/checkbook_lifecycle.mjs) — no live upstream.
+// Category gate first: non-procurement notices never fetch or paint contract modules.
async function loadLifecycle(r, el, dollarsEl){
if((!el && !dollarsEl) || !r.request_id) return;
+ if(!isContractLifecycleEligible(r)){
+ if(el) el.innerHTML = "";
+ if(dollarsEl) dollarsEl.innerHTML = "";
+ return;
+ }
let data = null;
try{
const resp = await workerFetch("/contract-lifecycle?id=" + encodeURIComponent(r.request_id), null, 8000);
@@ -6344,7 +6387,42 @@
return true;
}
-const NOTICE_SELECT = SELECT + ",event_date,street_address_1,section_name";
+// Extra description/printout fields so participation URL extraction matches the meetings list.
+const NOTICE_SELECT = SELECT + ",event_date,street_address_1,section_name,additional_description_2,additional_description_3,other_info_2,other_info_3,printout_1,printout_2,printout_3,building_name,city,state,zip_code";
// watch: the {lens, filter} parseWatchParam() extracted from this link's own "?w=" (w12-12) --
// null for a plain "#notice/" link (unchanged behavior). When present, the title/evidence/
// echo below render exactly as they would if the reader had run the watch themselves.
@@ -7896,6 +7996,9 @@
${t(titleKey)}
? `${title.slice(0,ev.index)}${title.slice(ev.index, ev.index+ev.term.length)}${title.slice(ev.index+ev.term.length)}`
: title;
const watchChips = watch ? watchChipsFor(watch.lens, watch.filter) : [];
+ // Same one-owner participation link as meetings list cards (how-do-I-attend on the permalink).
+ const noticeHearing=isMeetingOutcomesEligible(r)?normalizeHearingRow(r):null;
+ const noticeParticipation=noticeHearing?participationLinksHTML(noticeHearing):"";
box.innerHTML = `
Linked project: IDA March 20th, 2025 Public Hearing Notice · company NYM 145 Wolcott LLC · stage Hearing.
+
+
Application
2025-02-27
+
→
+
Hearing
2025-03-20
+
→
+
Board decision
—
+
The city does not publish this — it would appear on Build NYC project documents if released.
+
+
Stages join City Record notices to Build NYC and NYC Industrial Development Agency project records when a public match exists. Hearing stage here is from the City Record notice when the Build NYC feed is unreachable.
+
+"""
+
+
+def capture_subsidy_after(browser) -> None:
+ shell = OUT / "_subsidy_after.html"
+ shell.parent.mkdir(parents=True, exist_ok=True)
+ shell.write_text(SUBSIDY_AFTER_SHELL, encoding="utf-8")
+ for width, height in VIEWPORTS:
+ context = browser.new_context(viewport={"width": width, "height": height})
+ page = context.new_page()
+ page.goto(shell.as_uri(), wait_until="load")
+ shot(page, OUT / f"after-subsidy-shell-{width}.png")
+ context.close()
+
+
+def main() -> None:
+ OUT.mkdir(parents=True, exist_ok=True)
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ print("Capturing production (before)…")
+ capture_live(browser, PROD, "before")
+ print("Capturing local site (after)…")
+ with StaticServer() as base:
+ # Give the server a beat
+ time.sleep(0.2)
+ capture_live(browser, base, "after")
+ print("Capturing subsidy after shell…")
+ capture_subsidy_after(browser)
+ browser.close()
+ print(f"Wrote screenshots under {OUT.relative_to(ROOT)}")
+ for path in sorted(OUT.glob("*.png")):
+ print(f" {path.name} {path.stat().st_size} bytes")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/worker/src/lib/hearings.mjs b/worker/src/lib/hearings.mjs
index ff36a876..1bd1f92c 100644
--- a/worker/src/lib/hearings.mjs
+++ b/worker/src/lib/hearings.mjs
@@ -168,11 +168,50 @@ function decisionSummary(row, body) {
return title || "The notice does not give a short plain-language summary.";
}
+// Strip trailing punctuation that the body regex often captures (e.g. URL + ",").
+// Dedupe runs on the cleaned form so "…hearings," and "…hearings" collapse to one link.
+function normalizeParticipationUrl(url) {
+ return String(url || "").replace(/[.,;:)\]]+$/g, "").trim();
+}
+
+function participationUrlKey(url) {
+ try {
+ const parsed = new URL(url);
+ return `${parsed.host}${parsed.pathname}`.toLowerCase().replace(/\/$/, "");
+ } catch {
+ return String(url || "").toLowerCase().replace(/\/$/, "");
+ }
+}
+
+// Honest labels: meeting-join platforms vs generic board calendars vs other.
+// The common NYCIDA notice only publishes the board-meetings landing page — that is
+// the deepest public target for those hearings, so label it as such rather than a
+// vague "Participation link".
+function participationLabel(url) {
+ if (/\b(?:zoom|webex|teams|meet\.google)\b/i.test(url)) return "Join online";
+ if (/nycida-board-meetings-public-hearings/i.test(url) || /edc\.nyc\/nycida(?:[/?#]|$)/i.test(url)) {
+ return "IDA meetings page";
+ }
+ return "Participation link";
+}
+
+// One outbound participation affordance per notice: prefer a live join URL, else the
+// most specific cleaned URL the body published (longest path wins among equals).
function participationFromRow(row, body, sourceUrl) {
- const links = unique(body.match(URL_RE) || []).slice(0, 8).map((url) => ({
- label: /\b(?:zoom|webex|teams|meet\.google)\b/i.test(url) ? "Join online" : "Participation link",
- url: url.replace(/[.,;]+$/, ""),
- }));
+ const cleaned = (body.match(URL_RE) || []).map(normalizeParticipationUrl).filter(Boolean);
+ const byKey = new Map();
+ for (const url of cleaned) {
+ const key = participationUrlKey(url);
+ if (!key || byKey.has(key)) continue;
+ byKey.set(key, { label: participationLabel(url), url });
+ }
+ const ranked = [...byKey.values()].sort((a, b) => {
+ const aJoin = a.label === "Join online" ? 0 : 1;
+ const bJoin = b.label === "Join online" ? 0 : 1;
+ if (aJoin !== bJoin) return aJoin - bJoin;
+ return b.url.length - a.url.length;
+ });
+ const links = ranked.slice(0, 1);
const emails = unique([...body.matchAll(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi)].map((match) => match[0])).slice(0, 4);
const phones = unique([...body.matchAll(/(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}/g)].map((match) => match[0])).slice(0, 4);
return {
diff --git a/worker/src/lib/subsidy_lifecycle.mjs b/worker/src/lib/subsidy_lifecycle.mjs
index 0c930445..8a7a3e02 100644
--- a/worker/src/lib/subsidy_lifecycle.mjs
+++ b/worker/src/lib/subsidy_lifecycle.mjs
@@ -287,6 +287,8 @@ export function matchProjectToNotice(notice, projects = []) {
let bestScore = 0;
for (const project of projects) {
+ // Synthetic City Record-derived rows are a fallback, never preferred over a feed match.
+ if (String(project.project_id || "").startsWith("city-record:")) continue;
const titleScore = overlapScore(noticeTitle, project.project_name);
const companyScore = noticeCompany && project.company
? overlapScore(noticeCompany, project.company) * 0.8
@@ -300,16 +302,172 @@ export function matchProjectToNotice(notice, projects = []) {
return bestScore >= 0.35 ? best : null;
}
+// When the Build NYC document feed is unreachable (or has no row for this hearing), derive a
+// hearing-stage project from the City Record notice itself. IDA public-hearing notices are the
+// public hearing stage — they list companies and applications. This is not a substitute for
+// board/closing/compliance documents; those stay explicit unknowns until the feed recovers.
+// Typical lag after a hearing before later Build NYC / IDA stages are public enough
+// that absence is "not published" rather than "too soon". Conservative generic weeks —
+// not measured publication stats. Temporal sibling of paid / verified_zero / unavailable.
+export const SUBSIDY_STAGE_EXPECT_LAG_DAYS = {
+ board_decision: 60, // ~one board cycle after the hearing
+ closing: 180, // closing packages often land months later
+ compliance: 400, // first annual compliance window
+ project_record: 90, // whole-project join after hearing
+};
+
+export function daysSinceIso(iso, asOf = new Date()) {
+ const start = String(iso || "").slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(start)) return null;
+ const a = Date.UTC(+start.slice(0, 4), +start.slice(5, 7) - 1, +start.slice(8, 10));
+ const end = asOf instanceof Date ? asOf : new Date(asOf);
+ if (Number.isNaN(end.valueOf())) return null;
+ const b = Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate());
+ return Math.floor((b - a) / 86400000);
+}
+
+export function lagWeeksForStage(stage) {
+ const days = SUBSIDY_STAGE_EXPECT_LAG_DAYS[stage] ?? SUBSIDY_STAGE_EXPECT_LAG_DAYS.project_record;
+ return Math.max(1, Math.round(days / 7));
+}
+
+// Three honest gap states for subsidy slots (when not matched):
+// too_soon — typical publication lag has not elapsed
+// not_published — lag elapsed; treat as class-(b) absence
+// unavailable — feed/fetch failure (set by the worker path, not here)
+export function subsidyGapKind({ stage = "project_record", anchorDate = null, asOf = new Date(), matched = false } = {}) {
+ if (matched) return null;
+ const days = daysSinceIso(anchorDate, asOf);
+ if (days == null) return "not_published";
+ const lag = SUBSIDY_STAGE_EXPECT_LAG_DAYS[stage] ?? SUBSIDY_STAGE_EXPECT_LAG_DAYS.project_record;
+ if (days < lag) return "too_soon";
+ return "not_published";
+}
+
+export function subsidyAnchorDate(notice = {}, lifecycle = null) {
+ const fromTimeline = (lifecycle?.timeline || []).find(
+ (e) => e && e.stage === STAGE_HEARING && e.date,
+ );
+ return toDate(
+ fromTimeline?.date
+ || notice.event_date
+ || notice.start_date
+ || null,
+ );
+}
+
+export function isIdaHearingNotice(notice = {}) {
+ const agency = String(notice.agency_name || notice.agency || "");
+ const title = String(notice.short_title || notice.title || "");
+ const type = String(notice.type_of_notice_description || notice.notice_type || "");
+ const section = String(notice.section_name || notice.source_section || "");
+ const body = plainText([
+ notice.additional_description_1,
+ notice.additional_description_2,
+ notice.additional_description_3,
+ notice.other_info_1,
+ ].filter(Boolean).join(" "));
+ const ida = /industrial development|nycida|build nyc|economic development corporation/i.test(
+ `${agency} ${title} ${body}`,
+ ) || /\bIDA\b/.test(title);
+ if (!ida) return false;
+ const hearingType = /hearing|public hearing|meeting/i.test(`${type} ${title}`);
+ const hearingSection = section === "Public Hearings and Meetings"
+ || (section === "Agency Rules" && /public hearing/i.test(type));
+ return hearingType || hearingSection || !!notice.event_date;
+}
+
+export function projectFromIdaNotice(notice = {}) {
+ if (!isIdaHearingNotice(notice)) return null;
+ const requestId = String(notice.request_id || "").trim();
+ if (!requestId) return null;
+ const body = plainText([
+ notice.additional_description_1,
+ notice.additional_description_2,
+ notice.additional_description_3,
+ notice.other_info_1,
+ notice.other_info_2,
+ notice.other_info_3,
+ ].filter(Boolean).join(" "));
+ const companies = unique(
+ [...body.matchAll(/Company Name\s*:\s*([^,(\n]{3,100})/gi)]
+ .map((match) => plainText(match[1]))
+ .filter(Boolean),
+ );
+ const company = companies[0] || "";
+ const cityRecordUrl = `https://a856-cityrecord.nyc.gov/RequestDetail/${encodeURIComponent(requestId)}`;
+ const hearingDate = toDate(notice.event_date || notice.start_date);
+ return {
+ request_id: requestId,
+ project_id: `city-record:${requestId}`,
+ project_name: plainText(notice.short_title || notice.title || `IDA hearing ${requestId}`),
+ company,
+ project_address: "",
+ location_text: body.slice(0, 400),
+ bbl: null,
+ bbls: [],
+ requested_benefit: moneyState(null),
+ estimated_cost: moneyState(null),
+ application: {
+ date: toDate(notice.start_date),
+ status: companies.length ? "filed" : "",
+ url: cityRecordUrl,
+ },
+ hearing: {
+ date: hearingDate,
+ status: hearingDate ? "held" : "scheduled",
+ venue: "",
+ url: cityRecordUrl,
+ },
+ board_decision: { date: null, outcome: "", body: "", url: "" },
+ closing: { date: null, status: "", amount: null, url: "" },
+ compliance: { year: "", date: null, status: "", url: "" },
+ _derived_from: "city-record-hearing",
+ };
+}
+
+function withGapKinds(timeline, anchorDate) {
+ return (timeline || []).map((entry) => {
+ if (!entry || entry.status === "matched") return entry;
+ return {
+ ...entry,
+ gap_kind: subsidyGapKind({
+ stage: entry.stage,
+ anchorDate,
+ matched: false,
+ }),
+ };
+ });
+}
+
export function assembleSubsidyLifecycle(notices = [], projects = []) {
return (notices || []).map((notice = {}) => {
- const matched = matchProjectToNotice(notice, projects);
+ let matched = matchProjectToNotice(notice, projects);
if (!matched) {
+ // Prefer an explicit synthetic row from the caller; otherwise derive from the notice.
+ matched = (projects || []).find(
+ (p) => p && p.request_id === String(notice.request_id || "").trim()
+ && String(p.project_id || "").startsWith("city-record:"),
+ ) || projectFromIdaNotice(notice);
+ }
+ const anchor = subsidyAnchorDate(notice, null);
+ if (!matched) {
+ const bareTimeline = STAGES.map((stage) => ({
+ stage,
+ status: UNKNOWN,
+ date: null,
+ official_action: stage === STAGE_APPLICATION ? "application_review" : stage === STAGE_HEARING ? "public_hearing" : stage === STAGE_BOARD_DECISION ? "board_decision" : stage === STAGE_CLOSING ? "closing" : "annual_compliance",
+ outcome: UNKNOWN,
+ source: docState(),
+ detail: null,
+ }));
return {
request_id: String(notice.request_id || "").trim() || null,
project: null,
stage: STAGE_APPLICATION,
join: {
matched: false,
+ gap_kind: subsidyGapKind({ stage: "project_record", anchorDate: anchor, matched: false }),
reason: "No matching NYCIDA/Build NYC project record was linked to this notice from public sources.",
},
company: {
@@ -336,21 +494,20 @@ export function assembleSubsidyLifecycle(notices = [], projects = []) {
closing: docState(),
compliance: docState(),
},
- timeline: STAGES.map((stage) => ({
- stage,
- status: UNKNOWN,
- date: null,
- official_action: stage === STAGE_APPLICATION ? "application_review" : stage === STAGE_HEARING ? "public_hearing" : stage === STAGE_BOARD_DECISION ? "board_decision" : stage === STAGE_CLOSING ? "closing" : "annual_compliance",
- outcome: UNKNOWN,
- source: docState(),
- detail: null,
- })),
+ timeline: withGapKinds(bareTimeline, anchor),
};
}
const place = buildPlace(notice, matched);
- const timeline = buildTimeline(matched);
+ const hearingAnchor = matched.hearing?.date || anchor || subsidyAnchorDate(notice, null);
+ const timeline = withGapKinds(buildTimeline(matched), hearingAnchor);
const current = resolveStage(matched);
+ const fromCityRecord = matched._derived_from === "city-record-hearing"
+ || String(matched.project_id || "").startsWith("city-record:");
+ const joinSource = fromCityRecord ? "City Record" : "Build NYC";
+ const joinMethod = fromCityRecord
+ ? "city-record-hearing"
+ : (matched.request_id ? "request_id" : "title_or_company_overlap");
return {
request_id: String(notice.request_id || "").trim() || null,
project: {
@@ -361,14 +518,15 @@ export function assembleSubsidyLifecycle(notices = [], projects = []) {
stage: current,
join: {
matched: true,
- method: matched.request_id ? "request_id" : "title_or_company_overlap",
- source: "Build NYC",
+ method: joinMethod,
+ source: joinSource,
project_reference: matched.project_id || matched.request_id || null,
+ anchor_date: hearingAnchor || null,
},
company: {
status: matched.company ? "matched" : UNKNOWN,
value: matched.company || null,
- source: matched.company ? "Build NYC" : null,
+ source: matched.company ? joinSource : null,
},
place: {
status: place.status,
diff --git a/worker/src/subsidy_lifecycle.mjs b/worker/src/subsidy_lifecycle.mjs
index 8d632803..5bf267f9 100644
--- a/worker/src/subsidy_lifecycle.mjs
+++ b/worker/src/subsidy_lifecycle.mjs
@@ -48,22 +48,61 @@ function parseEmbeddedProjectJson(text) {
return [];
}
+// Cloudflare challenge HTML is not a project feed — treat as source failure so callers can
+// fall back to City Record notice derivation instead of caching an empty "ok" feed forever.
+function looksLikeBotChallenge(text) {
+ return /just a moment|cf-browser-verification|challenge-platform|_cf_chl|cdn-cgi\/challenge/i
+ .test(String(text || ""));
+}
+
async function fetchSubsidyProjects() {
let text;
try {
- const r = await fetch(SUBSIDY_SOURCE, { headers: { Accept: "application/json, text/html" } });
+ const r = await fetch(SUBSIDY_SOURCE, {
+ headers: {
+ Accept: "application/json, text/html",
+ "User-Agent": "CityScrollBot/1.0 (+https://cityscroll.org; subsidy-lifecycle)",
+ },
+ });
if (!r.ok) return { projects: [], ok: false, sourceError: `Feed returned ${r.status}` };
text = await r.text();
} catch (error) {
return { projects: [], ok: false, sourceError: String(error?.message || error) };
}
+ if (looksLikeBotChallenge(text)) {
+ return { projects: [], ok: false, sourceError: "Feed blocked by bot challenge" };
+ }
+
const jsonRows = parseJsonishText(text);
const scriptRows = jsonRows.length ? jsonRows : parseEmbeddedProjectJson(text);
const projects = parseNYCIDAProjects(Array.isArray(scriptRows) ? scriptRows : []);
+ // Empty body with no project JSON is a soft failure when the page is the docs landing
+ // shell rather than a machine-readable feed (common when the host changes markup).
+ if (!projects.length && !jsonRows.length && !scriptRows.length) {
+ return { projects: [], ok: false, sourceError: "Feed returned no project records" };
+ }
return { projects, ok: true };
}
+// Runtime safety net if migration 0005 was not applied (same posture as PASSPort D1).
+export async function ensureSubsidySchema(env) {
+ if (!env?.DB) return;
+ try {
+ await env.DB.prepare(`
+ CREATE TABLE IF NOT EXISTS subsidy_lifecycle (
+ request_id TEXT PRIMARY KEY,
+ agency TEXT,
+ lifecycle TEXT,
+ computed_at TEXT
+ )
+ `).run();
+ await env.DB.prepare(
+ "CREATE INDEX IF NOT EXISTS idx_subsidy_lifecycle_agency ON subsidy_lifecycle(agency)",
+ ).run();
+ } catch { /* non-fatal */ }
+}
+
export async function fetchNoticeRow(env, requestId) {
if (env.DB) {
try {
@@ -87,11 +126,15 @@ export async function fetchNoticeRow(env, requestId) {
async function cacheGet(env, requestId) {
if (!env.DB) return null;
try {
+ await ensureSubsidySchema(env);
const row = await env.DB.prepare(
"SELECT lifecycle FROM subsidy_lifecycle WHERE request_id = ?",
).bind(requestId).first();
if (row && row.lifecycle) {
const lifecycle = JSON.parse(row.lifecycle);
+ // Never serve a permanently cached feed-unavailable row — recompute so City Record
+ // derivation or a recovered feed can replace the operational error.
+ if (lifecycle && lifecycle.source_status === "unavailable") return null;
if (lifecycle && Array.isArray(lifecycle.timeline)) return lifecycle;
}
} catch { /* miss */ }
@@ -99,8 +142,11 @@ async function cacheGet(env, requestId) {
}
async function cachePut(env, requestId, agency, lifecycle) {
- if (!env.DB) return;
+ if (!env.DB || !lifecycle) return;
+ // Do not materialize unavailable — genuine transient failures must not stick forever.
+ if (lifecycle.source_status === "unavailable") return;
try {
+ await ensureSubsidySchema(env);
await env.DB.prepare(
`INSERT OR REPLACE INTO subsidy_lifecycle (request_id, agency, lifecycle, computed_at)
VALUES (?, ?, ?, ?)`,
@@ -112,12 +158,24 @@ export async function computeLifecycle(env, requestId, noticeRow) {
const notice = noticeRow === undefined ? await fetchNoticeRow(env, requestId) : noticeRow;
if (!notice) return { lifecycle: null, ok: false, sourceUnavailable: false };
const { projects, ok } = await fetchSubsidyProjects();
- if (!ok) {
- const fallback = assembleSubsidyLifecycle([notice], [])[0] ?? null;
- if (fallback) fallback.source_status = "unavailable";
- return { lifecycle: fallback, ok: false, sourceUnavailable: true };
+ // assembleSubsidyLifecycle derives a City Record hearing project for IDA notices when
+ // the Build NYC feed is empty or blocked — that is a real public join, not "unavailable".
+ const [lifecycle] = assembleSubsidyLifecycle([notice], ok ? projects : []);
+ if (!lifecycle) return { lifecycle: null, ok: false, sourceUnavailable: !ok };
+ if (!ok && !lifecycle.join?.matched) {
+ // Feed down and no notice-derived hearing row → honest operational unavailable.
+ lifecycle.source_status = "unavailable";
+ return { lifecycle, ok: false, sourceUnavailable: true };
+ }
+ // Feed down but City Record hearing matched: partial source, not a blank failure.
+ if (!ok && lifecycle.join?.matched) {
+ lifecycle.source_status = "ok";
+ lifecycle.join = {
+ ...lifecycle.join,
+ feed_status: "unavailable",
+ feed_note: "Build NYC document feed unreachable; hearing stage from City Record notice.",
+ };
}
- const [lifecycle] = assembleSubsidyLifecycle([notice], projects);
return { lifecycle, ok: true, sourceUnavailable: false };
}
@@ -126,8 +184,7 @@ export async function getOrCompute(env, requestId) {
if (cached) return { lifecycle: cached, ok: true, sourceUnavailable: cached.source_status === "unavailable" };
const { lifecycle, ok, sourceUnavailable } = await computeLifecycle(env, requestId);
- // Cache matched and explicit-gap rows so the notice detail always gets a structured
- // lifecycle (join.matched false, source_status unavailable, etc.) — never a blank.
+ // Cache matched and explicit taxonomy-gap rows; never unavailable (see cachePut).
if (lifecycle) {
const row = await fetchNoticeRow(env, requestId).catch(() => null);
await cachePut(env, requestId, row && row.agency_name, lifecycle);