From c210168d65ae2d31991aa0a9843d23ea4675cd37 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 14:14:59 +0200 Subject: [PATCH] tweak job runs --- AGENTS.md | 1 + repub/components.py | 2 +- repub/jobs.py | 22 ++++++++++++++- repub/pages/runs.py | 67 +++++++++++++++++++++++++++++++++++++------- repub/static/app.css | 4 +++ tests/test_web.py | 10 +++++++ 6 files changed, 94 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39c43e0..e77c250 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,5 +112,6 @@ uv run repub crawl -c repub.toml - Runtime ffmpeg availability is provided by the flake package and devshell. - Tests live under `tests/`. - `prompts/` is git ignored intentionally +- Never search the web for this repo. If an external resource, document, or reference is needed, stop and ask the user to provide it. - Treat the repo-root `republisher.db` as user-owned local state. Do not delete or reset it as part of routine testing or verification. - For automated tests or isolated verification, use a separate database path via `REPUBLISHER_DB_PATH` instead of mutating or removing the repo-root database. diff --git a/repub/components.py b/repub/components.py index 48af6d6..bcf79f7 100644 --- a/repub/components.py +++ b/repub/components.py @@ -403,7 +403,7 @@ def status_badge(*, label: str, tone: str) -> Renderable: "scheduled": "bg-sky-100 text-sky-800", "idle": "bg-slate-200 text-slate-700", "failed": "bg-rose-100 text-rose-800", - "done": "bg-amber-100 text-amber-800", + "done": "bg-emerald-100 text-emerald-800", } return h.span( class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}" diff --git a/repub/jobs.py b/repub/jobs.py index 6e306af..8d7de38 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -504,10 +504,11 @@ def _project_upcoming_job( "slug": job.source.slug, "job_id": job_id, "next_run": ( - next_run.strftime("%Y-%m-%d %H:%M UTC") + _humanize_future_time(reference_time, next_run) if next_run is not None else ("Running now" if running_execution is not None else "Not scheduled") ), + "next_run_at": next_run.isoformat() if next_run is not None else None, "schedule": " ".join( ( str(job.cron_minute), @@ -641,3 +642,22 @@ def _format_bytes(value: int) -> str: if value < 1024 * 1024 * 1024: return f"{value / (1024 * 1024):.1f} MB" return f"{value / (1024 * 1024 * 1024):.1f} GB" + + +def _humanize_future_time(reference_time: datetime, target_time: datetime) -> str: + delta_seconds = int(round((target_time - reference_time).total_seconds())) + if delta_seconds <= 0: + return "now" + + units = ( + ("day", 24 * 60 * 60), + ("hour", 60 * 60), + ("minute", 60), + ) + for label, size in units: + if delta_seconds >= size: + count = max(1, round(delta_seconds / size)) + suffix = "" if count == 1 else "s" + return f"in {count} {label}{suffix}" + + return f"in {delta_seconds} seconds" diff --git a/repub/pages/runs.py b/repub/pages/runs.py index c76f5c0..94acee7 100644 --- a/repub/pages/runs.py +++ b/repub/pages/runs.py @@ -70,9 +70,6 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: h.p(class_="font-medium text-slate-900")[ f"#{_text(execution, 'execution_id')}" ], - h.p(class_="mt-1 text-xs text-slate-500")[ - f"job {_text(execution, 'job_id')}" - ], ], h.div[ h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")], @@ -99,15 +96,26 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]: def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]: + next_run_at = _maybe_text(job, "next_run_at") + next_run_label: Node = h.p(class_="font-medium text-slate-900")[ + _text(job, "next_run") + ] + if next_run_at is not None: + next_run_label = h.time( + { + "data-next-run-at": next_run_at, + "title": next_run_at, + }, + datetime=next_run_at, + class_="font-medium text-slate-900", + )[_text(job, "next_run")] + return ( h.div[ h.div(class_="font-semibold text-slate-950")[_text(job, "source")], h.p(class_="mt-1 font-mono text-xs text-slate-500")[_text(job, "slug")], ], - h.div[ - h.p(class_="font-medium text-slate-900")[_text(job, "next_run")], - h.p(class_="mt-1 text-xs text-slate-500")[f"job {_text(job, 'job_id')}"], - ], + h.div[next_run_label,], h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")], status_badge( label=_text(job, "enabled_label"), @@ -147,9 +155,6 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]: h.p(class_="font-medium text-slate-900")[ f"#{_text(execution, 'execution_id')}" ], - h.p(class_="mt-1 text-xs text-slate-500")[ - f"job {_text(execution, 'job_id')}" - ], ], h.div[ h.p(class_="font-medium text-slate-900")[_text(execution, "ended_at")], @@ -232,6 +237,48 @@ def runs_page( ), rows=completed_rows, ), + h.script[ + """ +window.repubFormatNextRuns = window.repubFormatNextRuns || (() => { + const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + const absoluteFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + timeZoneName: 'short', + }); + const formatRelative = (targetDate) => { + const diffSeconds = Math.round((targetDate.getTime() - Date.now()) / 1000); + const units = [ + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ['second', 1], + ]; + for (const [unit, size] of units) { + if (Math.abs(diffSeconds) >= size || unit === 'second') { + return relativeFormatter.format(Math.round(diffSeconds / size), unit); + } + } + return relativeFormatter.format(0, 'second'); + }; + const format = () => { + document.querySelectorAll('time[data-next-run-at]').forEach((element) => { + const nextRunAt = element.getAttribute('data-next-run-at'); + if (!nextRunAt) return; + const targetDate = new Date(nextRunAt); + if (Number.isNaN(targetDate.getTime())) return; + element.textContent = formatRelative(targetDate); + element.title = absoluteFormatter.format(targetDate); + }); + }; + format(); + if (!window.repubNextRunTimer) { + window.repubNextRunTimer = window.setInterval(format, 30000); + } +}); +window.repubFormatNextRuns(); + """ + ], ), ) diff --git a/repub/static/app.css b/repub/static/app.css index c01e60b..dae085d 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -1,4 +1,8 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@view-transition { + navigation: auto; +} + @layer properties; @layer theme, base, components, utilities; @layer theme { diff --git a/tests/test_web.py b/tests/test_web.py index 51d469d..257f88b 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -4,6 +4,7 @@ import asyncio from pathlib import Path from typing import Any, cast +from repub.components import status_badge from repub.datastar import RefreshBroker, render_sse_event, render_stream from repub.model import ( Job, @@ -26,6 +27,13 @@ from repub.web import ( ) +def test_status_badge_uses_green_done_tone() -> None: + badge = str(status_badge(label="Succeeded", tone="done")) + + assert "bg-emerald-100 text-emerald-800" in badge + assert "Succeeded" in badge + + def test_root_get_serves_datastar_shim() -> None: async def run() -> None: client = create_app().test_client() @@ -618,6 +626,8 @@ def test_render_runs_shows_running_upcoming_and_completed_tables( assert "Completed job executions" in body assert "runs-render-source" in body assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body + assert "data-next-run-at" in body + assert "in " in body assert "Already running" not in body asyncio.run(run())