diff --git a/algorithm-catalog/data/events.json b/algorithm-catalog/data/events.json index 259f30c8..1cf84a6d 100644 --- a/algorithm-catalog/data/events.json +++ b/algorithm-catalog/data/events.json @@ -199,7 +199,6 @@ "name": "Hurricane Isaias October 2026", "stacName": "202610_Hurricane_Isaias", "start": "2026-10-09", - "end": "2026-10-09", "hazards": ["TropicalCyclone", "Flood"], "locations": ["Florida", "Alabama"], "places": ["US-FL", "US-AL"], diff --git a/algorithm-catalog/scripts/e2e.mjs b/algorithm-catalog/scripts/e2e.mjs index 54b4c609..c14ccb65 100644 --- a/algorithm-catalog/scripts/e2e.mjs +++ b/algorithm-catalog/scripts/e2e.mjs @@ -222,6 +222,17 @@ await page.goto(BASE, { waitUntil: "networkidle" }); await page.locator("button", { hasText: /event/i }).first().click(); await page.waitForTimeout(300); check("imported events are listed (8)", (await page.locator(".ecard").count()) === 8); +check( + "an event with no end date reads as ongoing", + /9 Oct 2026 – ongoing/.test(await page.locator(".ecard", { hasText: "Hurricane Isaias" }).innerText()), +); +await page.locator('input[type="date"]').first().fill("2026-10-15"); +await page.waitForTimeout(300); +check( + "an ongoing event still matches a later date filter", + (await page.locator(".ecard", { hasText: "Hurricane Isaias" }).count()) === 1, +); +await page.locator("button", { hasText: "Reset" }).click(); await page.locator(".ecard", { hasText: "Hurricane Isaias October 2026" }).click(); await page.locator('[role="tab"]', { hasText: "Page preview" }).click(); const preview = await page.locator(".page-preview").innerText(); @@ -234,6 +245,7 @@ await page.locator("button", { hasText: "Edit page" }).click(); await page.waitForTimeout(400); check("Edit page opens Submit prefilled", (await page.locator("#req-name").inputValue()) === "Hurricane Isaias October 2026"); check("…with the event's places as chips", (await page.locator(".place-chip").count()) === 2); +check("…and the end date blank (optional — ongoing)", (await page.locator("#req-end").inputValue()) === ""); await page.locator("#req-subtitle").fill('As Hurricane Isaias made landfall, NASA "activated" to support FEMA.'); await page.locator("button.req-haz", { hasText: /^Respond$/ }).click(); await page.locator("#req-intro").fill("Hurricane Isaias brought storm surge to Florida and Alabama.\n\nNASA is sharing maps."); @@ -257,7 +269,10 @@ const opened = await page.evaluate(() => window.__opened[0] || ""); const doc = opened ? JSON.parse(new URL(opened).searchParams.get("value"))[0] : {}; check( "Open PR carries the page + places in the request file", - doc.page?.intro?.startsWith("Hurricane Isaias") && doc.page?.needs?.[0] === "respond" && doc.event?.places?.length === 2, + doc.page?.intro?.startsWith("Hurricane Isaias") && + doc.page?.needs?.[0] === "respond" && + doc.event?.places?.length === 2 && + !("end" in (doc.event ?? {})), opened ? new URL(opened).searchParams.get("filename") : "no window.open", ); diff --git a/algorithm-catalog/scripts/import_xlsx.py b/algorithm-catalog/scripts/import_xlsx.py index cb95d869..f9d65d8e 100644 --- a/algorithm-catalog/scripts/import_xlsx.py +++ b/algorithm-catalog/scripts/import_xlsx.py @@ -288,16 +288,16 @@ def main(argv: list[str] | None = None) -> int: report.append(f"{where} {len(external)} layer(s) have no catalog algorithm — kept as externalLayers: {', '.join(external)}") start = excel_date(row["F"]) if row.get("F") else "" - end = excel_date(row["G"]) if row.get("G") else start - if not row.get("G"): - report.append(f"{where} no end date in the sheet — set end = start ({start}).") + end = excel_date(row["G"]) if row.get("G") else "" + if not end: + report.append(f"{where} no end date in the sheet — imported as ongoing (end is optional).") ev = { "id": stac, "name": name, "stacName": stac, "start": start, - "end": end, + **({"end": end} if end else {}), "hazards": hz, "locations": [*(gaz.label(i) for i in places), *free], } diff --git a/algorithm-catalog/scripts/rules_parity_test.py b/algorithm-catalog/scripts/rules_parity_test.py index 958f4aa5..1bf6669e 100644 --- a/algorithm-catalog/scripts/rules_parity_test.py +++ b/algorithm-catalog/scripts/rules_parity_test.py @@ -234,6 +234,8 @@ def check_inline_regexes() -> None: # --- date ordering ----------------------------------------------------------------------------- ("date-order", ("2025-01-07", "2025-01-31"), True, None, "start before end"), ("date-order", ("2025-01-07", "2025-01-07"), True, None, "single-day event"), + ("date-order", ("2026-10-09", ""), True, None, "no end date — the event is ongoing (end is optional)"), + ("date-order", ("2026-10-09", "2026-13-01"), False, "format", "an end date, if given, must still be valid"), ("date-order", ("2025-01-31", "2025-01-07"), False, "order", "end before start"), # --- bbox -------------------------------------------------------------------------------------- ("bbox", "", True, None, "bbox is optional"), diff --git a/algorithm-catalog/scripts/validate_data.py b/algorithm-catalog/scripts/validate_data.py index a553d82d..9b1d9b8f 100644 --- a/algorithm-catalog/scripts/validate_data.py +++ b/algorithm-catalog/scripts/validate_data.py @@ -266,16 +266,15 @@ def _is(h: dict[str, Any]) -> bool: def check_dates(start: str, end: str) -> list[Violation]: - """ISO shape + ordering. Upstream only checks shape; start<=end is our own catalog invariant.""" + """ISO shape + ordering. Upstream only checks shape; start<=end is our own catalog invariant. + The END date is OPTIONAL: blank means the event is still ongoing. Mirrors rules.ts checkDates.""" out: list[Violation] = [] if not start: out.append(_err("start", "required", "Start date is required.")) elif not matches(_ISO_DATE, start): out.append(_err("start", "format", f'Start date "{start}" must be YYYY-MM-DD.')) - if not end: - out.append(_err("end", "required", "End date is required.")) - elif not matches(_ISO_DATE, end): + if end and not matches(_ISO_DATE, end): out.append(_err("end", "format", f'End date "{end}" must be YYYY-MM-DD.')) if start and end and matches(_ISO_DATE, start) and matches(_ISO_DATE, end) and start > end: diff --git a/algorithm-catalog/src/data.ts b/algorithm-catalog/src/data.ts index 2fedc3d6..8dcdfc2c 100644 --- a/algorithm-catalog/src/data.ts +++ b/algorithm-catalog/src/data.ts @@ -242,8 +242,10 @@ export function formatDate(iso: string): string { return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]} ${m[1]}`; } -export function formatRange(start: string, end: string): string { +/** A missing end date means the event is still ongoing. */ +export function formatRange(start: string, end?: string): string { if (!start && !end) return "no dates"; + if (!end) return `${formatDate(start)} – ongoing`; if (start === end) return formatDate(start); return `${formatDate(start)} – ${formatDate(end)}`; } diff --git a/algorithm-catalog/src/filters/filter.ts b/algorithm-catalog/src/filters/filter.ts index 5d6abd7d..068d57ea 100644 --- a/algorithm-catalog/src/filters/filter.ts +++ b/algorithm-catalog/src/filters/filter.ts @@ -164,7 +164,7 @@ export function applyEvent(f: Filters, u: Universe, ev: ActivationEvent | null): eventId: ev.id, hazards: new Set(known.length > 0 ? known : u.hazards), start: ev.start, - end: ev.end, + end: ev.end ?? "", aoi: ev.locations[0] ?? "", }; } @@ -229,9 +229,9 @@ export function eventHaystack( .toLowerCase(); } -/** Two windows overlap unless one ends before the other begins. */ -function overlaps(evStart: string, evEnd: string, start: string, end: string): boolean { - if (start && evEnd < start) return false; +/** Two windows overlap unless one ends before the other begins. No end = ongoing, never ended. */ +function overlaps(evStart: string, evEnd: string | undefined, start: string, end: string): boolean { + if (start && evEnd && evEnd < start) return false; if (end && evStart > end) return false; return true; } diff --git a/algorithm-catalog/src/pageExport.ts b/algorithm-catalog/src/pageExport.ts index ad121849..cad13749 100644 --- a/algorithm-catalog/src/pageExport.ts +++ b/algorithm-catalog/src/pageExport.ts @@ -66,7 +66,7 @@ export function pageView(ev: PageEvent, algorithms: Algorithm[], hazards: Hazard hazards: ev.hazards.map((h) => hazardLabel(h, hazards)), region: ev.page?.region?.trim() ? [ev.page.region.trim()] : ev.locations, startDate: ev.start, - endDate: ev.end, + endDate: ev.end ?? "", govLink: ev.page?.govLink ?? "", paragraphs: (ev.page?.intro ?? "").split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean), stacName: ev.stacName, diff --git a/algorithm-catalog/src/request/RequestForm.tsx b/algorithm-catalog/src/request/RequestForm.tsx index 1ecc397d..1b600802 100644 --- a/algorithm-catalog/src/request/RequestForm.tsx +++ b/algorithm-catalog/src/request/RequestForm.tsx @@ -97,7 +97,9 @@ function deriveInput(input: AlgorithmInput, d: RequestDraft, tokens: string[]): return { input, value: d.start, source: "start date" }; } if (has("end") || n === "date_to" || n === "to_date") { - return { input, value: d.end, source: "end date" }; + return d.end + ? { input, value: d.end, source: "end date" } + : { input, value: "", source: "no end date — the event is ongoing; set by the operator at run time" }; } if (has("bbox") || has("bound") || has("extent")) { return { input, value: d.bbox, source: "bounding box" }; @@ -460,7 +462,9 @@ export default function RequestForm({ algorithms, hazards, events }: Props) {
- + patch({ end: e.target.value })} onBlur={() => touch("end")} /> +
Leave blank while the event is ongoing.
diff --git a/algorithm-catalog/src/request/drafts.ts b/algorithm-catalog/src/request/drafts.ts index fdf5c639..3097e3ba 100644 --- a/algorithm-catalog/src/request/drafts.ts +++ b/algorithm-catalog/src/request/drafts.ts @@ -23,7 +23,7 @@ export interface RequestDraft { name: string; // human-readable, e.g. "California Wildfires January 2025" stacName: string; // YYYYMM_Hazard_Location, e.g. "202501_Fire_CA" start: string; // YYYY-MM-DD - end: string; // YYYY-MM-DD + end: string; // YYYY-MM-DD — optional; blank = ongoing bbox: string; // "min_lon,min_lat,max_lon,max_lat" — optional mgrsTiles: string; // optional, space-separated Sentinel-2 MGRS tiles, e.g. "T17RLN T17RLM" wrs2Tiles: string; // optional, space-separated Landsat WRS-2 path/rows, e.g. "171035" @@ -140,7 +140,7 @@ export function draftToRequest(d: RequestDraft, ts: string): AlgorithmRequest { name: d.name.trim(), stacName: d.stacName.trim(), start: d.start, - end: d.end, + ...(d.end ? { end: d.end } : {}), hazards: [...d.hazards], locations: allLocations(d), }, @@ -193,7 +193,7 @@ export function requestToDraft(r: AlgorithmRequest): RequestDraft { d.name = r.event.name; d.stacName = r.event.stacName; d.start = r.event.start; - d.end = r.event.end; + d.end = r.event.end ?? ""; d.bbox = r.event.bbox ?? ""; d.mgrsTiles = (r.event.tiles?.mgrs ?? []).join(" "); d.wrs2Tiles = (r.event.tiles?.wrs2 ?? []).join(" "); diff --git a/algorithm-catalog/src/rules.ts b/algorithm-catalog/src/rules.ts index 9c077e63..ce587f6c 100644 --- a/algorithm-catalog/src/rules.ts +++ b/algorithm-catalog/src/rules.ts @@ -214,15 +214,18 @@ export function checkStacName(value: string, hazards: Hazard[] = [], selected: s return out; } -/** ISO shape + ordering. Upstream only checks shape; start<=end is our own catalog invariant. */ +/** + * ISO shape + ordering. Upstream only checks shape; start<=end is our own catalog invariant. + * The END date is OPTIONAL: blank means the event is still ongoing (the portal itself records only + * a startDate). Only a malformed or out-of-order end is an error. + */ export function checkDates(start: string, end: string): Violation[] { const out: Violation[] = []; if (!start) out.push(err("start", "required", "Start date is required.")); else if (!ISO_DATE_RE.test(start)) out.push(err("start", "format", `Start date "${start}" must be YYYY-MM-DD.`)); - if (!end) out.push(err("end", "required", "End date is required.")); - else if (!ISO_DATE_RE.test(end)) out.push(err("end", "format", `End date "${end}" must be YYYY-MM-DD.`)); + if (end && !ISO_DATE_RE.test(end)) out.push(err("end", "format", `End date "${end}" must be YYYY-MM-DD.`)); if (start && end && ISO_DATE_RE.test(start) && ISO_DATE_RE.test(end) && start > end) { out.push(err("end", "order", `End date ${end} is before start date ${start}.`)); diff --git a/algorithm-catalog/src/types.ts b/algorithm-catalog/src/types.ts index bf892254..beed9bb7 100644 --- a/algorithm-catalog/src/types.ts +++ b/algorithm-catalog/src/types.ts @@ -135,7 +135,8 @@ export interface ActivationEvent { name: string; // human-readable, visible on the website stacName: string; // YYYYMM_Hazard_Location start: string; // "YYYY-MM-DD" - end: string; // "YYYY-MM-DD" + /** "YYYY-MM-DD", or absent while the event is ongoing. */ + end?: string; hazards: string[]; // hazard ids /** * Display strings, shown as the page's "Region". The first `places.length` of them are the @@ -201,7 +202,7 @@ export interface AlgorithmRequest { name: string; stacName: string; start: string; - end: string; + end?: string; // absent = ongoing hazards: string[]; locations: string[]; places?: string[]; diff --git a/docs/ALGORITHM_CATALOG.md b/docs/ALGORITHM_CATALOG.md index 802c601c..856ad208 100644 --- a/docs/ALGORITHM_CATALOG.md +++ b/docs/ALGORITHM_CATALOG.md @@ -77,6 +77,7 @@ hand-kept `hazard_event_info.xlsx`; the Submit form's **2 · Page content** sect | US-gov link | "What the U.S. government is doing" | `page.govLink` (https only) | | Intro paragraph | Overview | `page.intro` (blank line = paragraph) | | Start date | Start Date | `start` | +| End date | — (the portal records none) | `end` — **optional**; blank = ongoing (shown "– ongoing", never filtered out) | | Need/Theme | Need | `page.needs` — the portal's theme **ids** `prepare/respond/recover/resilience` | | Hazards | Hazard / categories | `hazards` → `hazards.json` `portal` (its `CONTENT_CATEGORIES`) | | Locations | Region | `locations` (+ `places`), or `page.region` override |