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) {