diff --git a/README.md b/README.md index 8e0e9ec..4ed4a6c 100644 --- a/README.md +++ b/README.md @@ -1262,7 +1262,8 @@ that: `make web-serve` starts Postgres, migrates, and serves without reseeding. Already have Postgres running and migrated? `make api` starts just the server. -Pick a time window (presets or a duration like `2h`), then switch between the +Pick a time window — relative presets or a duration like `2h` (the default), or +switch to absolute UTC `from`/`to` datetimes — then switch between the **Explain**, **Timeline**, **Compare**, and **Ask** tabs. The **ingestion** dropdown in the top bar lists your 25 most recent completed ingestions (via `GET /v1/ingestions`) and defaults to the latest one, matching the CLI; pick a diff --git a/src/api/static/css/app.css b/src/api/static/css/app.css index 3138d82..ea79735 100644 --- a/src/api/static/css/app.css +++ b/src/api/static/css/app.css @@ -119,11 +119,26 @@ body { border-bottom: 1px solid var(--panel-border); } +.window-mode, .presets { display: flex; gap: 4px; } +.window-relative, +.window-absolute { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 12px; +} + +.window-relative[hidden], +.window-absolute[hidden] { + display: none; +} + +.mode-btn, .preset-btn { background: var(--bg); border: 1px solid var(--panel-border); @@ -135,6 +150,8 @@ body { font-size: 12px; } +.mode-btn.active, +.mode-btn:hover, .preset-btn.active, .preset-btn:hover { color: var(--accent); @@ -160,6 +177,11 @@ body { min-width: 90px; } +.window-form input[type="datetime-local"] { + min-width: 190px; + color-scheme: dark; +} + .question-label { flex: 1 1 260px; } diff --git a/src/api/static/js/app.js b/src/api/static/js/app.js index afbdba5..7b104c6 100644 --- a/src/api/static/js/app.js +++ b/src/api/static/js/app.js @@ -17,6 +17,29 @@ function fmtTime(iso) { } } +// datetime-local yields "YYYY-MM-DDTHH:MM" (seconds optional, no timezone). +// The API treats naive timestamps as UTC, so we emit explicit UTC ISO-8601. +const DATETIME_WINDOW_KEYS = { + from_time: true, + to_time: true, + window_a_from: true, + window_a_to: true, + window_b_from: true, + window_b_to: true, +}; + +function datetimeLocalToIso(value) { + const trimmed = String(value == null ? "" : value).trim(); + if (!trimmed) return ""; + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(trimmed)) { + return trimmed + ":00Z"; + } + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(trimmed)) { + return trimmed + "Z"; + } + return trimmed; +} + function confidenceBadge(confidence) { const label = confidence && typeof confidence === "object" ? confidence.label : confidence; const cls = label === "high" ? "badge-high" : label === "medium" || label === "medium-high" ? "badge-medium" : "badge-low"; @@ -90,6 +113,43 @@ function initTabs() { }); } +// ── Window mode (relative duration vs absolute from/to) ────────────────────── + +function applyWindowMode(form, mode) { + const next = mode === "absolute" ? "absolute" : "relative"; + form.dataset.windowMode = next; + + form.querySelectorAll(".mode-btn").forEach((btn) => { + const active = btn.dataset.mode === next; + btn.classList.toggle("active", active); + btn.setAttribute("aria-pressed", active ? "true" : "false"); + }); + + const relative = form.querySelector(".window-relative"); + const absolute = form.querySelector(".window-absolute"); + if (relative) { + relative.hidden = next !== "relative"; + relative.querySelectorAll("input, button").forEach((el) => { + el.disabled = next !== "relative"; + }); + } + if (absolute) { + absolute.hidden = next !== "absolute"; + absolute.querySelectorAll("input").forEach((el) => { + el.disabled = next !== "absolute"; + }); + } +} + +function initWindowModes() { + document.querySelectorAll(".window-form").forEach((form) => { + applyWindowMode(form, form.dataset.windowMode || "relative"); + form.querySelectorAll(".mode-btn").forEach((btn) => { + btn.addEventListener("click", () => applyWindowMode(form, btn.dataset.mode)); + }); + }); +} + // ── Presets ────────────────────────────────────────────────────────────────── function initPresets() { @@ -99,7 +159,7 @@ function initPresets() { btn.addEventListener("click", () => { form.querySelectorAll(".preset-btn").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); - sinceInput.value = btn.dataset.since; + if (sinceInput) sinceInput.value = btn.dataset.since; }); }); }); @@ -111,7 +171,13 @@ function formToBody(form) { const body = {}; new FormData(form).forEach((value, key) => { const trimmed = String(value).trim(); - if (trimmed !== "") body[key] = trimmed; + if (trimmed === "") return; + if (DATETIME_WINDOW_KEYS[key]) { + const iso = datetimeLocalToIso(trimmed); + if (iso) body[key] = iso; + return; + } + body[key] = trimmed; }); const jobId = selectedIngestionJobId(); @@ -146,6 +212,16 @@ function initForms() { resultEl.innerHTML = '

Loading…

'; try { + if ((form.dataset.windowMode || "relative") === "absolute") { + const missing = Array.from(form.querySelectorAll('input[type="datetime-local"]:not([disabled])')).some( + (input) => !String(input.value || "").trim() + ); + if (missing) { + resultEl.innerHTML = '

Pick from and to datetimes for the absolute window.

'; + return; + } + } + const resp = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -292,6 +368,7 @@ function renderAsk(el, data) { document.addEventListener("DOMContentLoaded", () => { initTabs(); + initWindowModes(); initPresets(); initForms(); loadIngestions(); diff --git a/src/api/templates/index.html b/src/api/templates/index.html index d465502..a2a82a1 100644 --- a/src/api/templates/index.html +++ b/src/api/templates/index.html @@ -28,14 +28,24 @@
-
-
- - - - + +
+ + +
+
+
+ + + + +
+ +
+ - @@ -47,14 +57,24 @@
- -
- - - - + +
+ + +
+
+
+ + + + +
+ +
+ - @@ -66,15 +86,27 @@
- -
- - - - + +
+ + +
+
+
+ + + + +
+ + +
+ - - @@ -86,14 +118,24 @@
- -
- - - - + +
+ + +
+
+
+ + + + +
+ +
+ -