Dashboard: content + source-health; per-viewer local dates
* Date fix: introduce GOODNEWS_TZ (goodnews/localtime.py) so the brief's "today" rolls over in a pinned zone (Eastern) instead of UTC — robust to host-clock resets. The home page now formats the brief's date in each VISITOR's local timezone (from its UTC freshness stamp), so nobody ever sees "tomorrow." * Admin "Content served": articles live, fresh (7d), ingested (24h), summaries, active sources, today's brief size — queries.content_stats(). * Admin "Source health": per active source, the failure streak, last error, accepted contribution, and computed next-poll time (so backoff / "resting until" is visible), via queries.source_health() reusing the feeds backoff math. Failing sources sort to the top; times render in the viewer's zone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,17 @@
|
|||||||
|
|
||||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
||||||
|
|
||||||
|
// Show the brief's date in the VIEWER's local timezone (from its UTC freshness
|
||||||
|
// stamp), so everyone sees their own correct date — never "tomorrow."
|
||||||
|
function localDateLabel(b) {
|
||||||
|
const ts = b?.generated_at;
|
||||||
|
if (ts) {
|
||||||
|
const d = new Date(ts.replace(' ', 'T') + 'Z');
|
||||||
|
if (!isNaN(d)) return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
return b?.brief_date ?? '';
|
||||||
|
}
|
||||||
let filtersOn = $derived(prefsActive());
|
let filtersOn = $derived(prefsActive());
|
||||||
let currentMood = $derived(moods.find((m) => m.key === selected));
|
let currentMood = $derived(moods.find((m) => m.key === selected));
|
||||||
let currentTopic = $derived(topics.find((t) => t.key === selected));
|
let currentTopic = $derived(topics.find((t) => t.key === selected));
|
||||||
@@ -89,7 +100,7 @@
|
|||||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||||
);
|
);
|
||||||
let viewSubtitle = $derived(
|
let viewSubtitle = $derived(
|
||||||
selected === 'today' ? (brief?.brief_date ?? '')
|
selected === 'today' ? localDateLabel(brief)
|
||||||
: currentTag ? (tagFamily?.description ?? '')
|
: currentTag ? (tagFamily?.description ?? '')
|
||||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,15 @@
|
|||||||
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
|
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
|
||||||
catch { return s; }
|
catch { return s; }
|
||||||
}
|
}
|
||||||
|
// A UTC "YYYY-MM-DD HH:MM:SS" stamp → the viewer's local date + time.
|
||||||
|
function fwhen(s) {
|
||||||
|
if (!s) return 'now';
|
||||||
|
const d = new Date(s.replace(' ', 'T') + 'Z');
|
||||||
|
if (isNaN(d)) return s;
|
||||||
|
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
let healthy = $derived((stats?.sources ?? []).filter((s) => !s.failures).length);
|
||||||
|
let ailing = $derived((stats?.sources ?? []).filter((s) => s.failures > 0).length);
|
||||||
|
|
||||||
const SHARE_LABEL = {
|
const SHARE_LABEL = {
|
||||||
share_ub: 'Copied UB link',
|
share_ub: 'Copied UB link',
|
||||||
@@ -62,6 +71,20 @@
|
|||||||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
|
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if stats.content}
|
||||||
|
<section>
|
||||||
|
<h2>Content served</h2>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (last 7d)</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.content.added_24h}</span><span class="l">Ingested (24h)</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.content.summaries}</span><span class="l">Summaries</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.content.active_sources}</span><span class="l">Active sources</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Accounts</h2>
|
<h2>Accounts</h2>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
@@ -177,6 +200,29 @@
|
|||||||
{:else}<p class="muted">No data yet.</p>{/if}
|
{:else}<p class="muted">No data yet.</p>{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if stats.sources?.length}
|
||||||
|
<section>
|
||||||
|
<h2>Source health</h2>
|
||||||
|
<p class="sub2">{healthy} healthy · {ailing} backing off · {stats.sources.length} active</p>
|
||||||
|
<ul class="srclist">
|
||||||
|
{#each stats.sources as s (s.id)}
|
||||||
|
<li class:warn={s.failures > 0}>
|
||||||
|
<span class="sname" title={s.last_error || ''}>{s.name}</span>
|
||||||
|
<span class="sserved">{s.served}</span>
|
||||||
|
<span class="sstatus">
|
||||||
|
{#if s.failures > 0}
|
||||||
|
⚠ {s.failures} fail{s.failures > 1 ? 's' : ''} · resting until {fwhen(s.next_due_at)}
|
||||||
|
{:else}
|
||||||
|
✓ next poll {fwhen(s.next_due_at)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
|
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
|
||||||
{#if feedback.length}
|
{#if feedback.length}
|
||||||
@@ -243,6 +289,20 @@
|
|||||||
.legend .sw.visits { background: var(--accent-soft); }
|
.legend .sw.visits { background: var(--accent-soft); }
|
||||||
.legend .sw.opens { background: var(--accent); }
|
.legend .sw.opens { background: var(--accent); }
|
||||||
|
|
||||||
|
.sub2 { color: var(--muted); font-size: 0.84rem; margin: 0 0 12px; }
|
||||||
|
.legend2 { color: var(--muted); font-size: 0.76rem; margin: 10px 0 0; font-style: italic; }
|
||||||
|
ul.srclist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
ul.srclist li {
|
||||||
|
display: grid; grid-template-columns: 1fr auto auto; align-items: baseline; gap: 12px;
|
||||||
|
padding: 7px 10px; border-radius: 8px; font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
ul.srclist li:nth-child(odd) { background: var(--surface); }
|
||||||
|
ul.srclist li.warn { background: #fbeaea; }
|
||||||
|
ul.srclist .sname { color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
ul.srclist .sserved { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
ul.srclist .sstatus { color: var(--muted); font-size: 0.8rem; white-space: nowrap; }
|
||||||
|
ul.srclist li.warn .sstatus { color: #9a3b3b; }
|
||||||
|
|
||||||
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
|
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
|
||||||
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
|
from .localtime import local_today
|
||||||
from .paywall import is_paywalled
|
from .paywall import is_paywalled
|
||||||
|
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ def build_daily_brief(
|
|||||||
replace: bool = False,
|
replace: bool = False,
|
||||||
window_days: int = 3,
|
window_days: int = 3,
|
||||||
) -> int:
|
) -> int:
|
||||||
target_date = brief_date or date.today().isoformat()
|
target_date = brief_date or local_today()
|
||||||
|
|
||||||
# Compose the selection first so we can tell whether anything actually
|
# Compose the selection first so we can tell whether anything actually
|
||||||
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked
|
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked
|
||||||
|
|||||||
+3
-3
@@ -3,11 +3,11 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .briefs import build_daily_brief, show_brief
|
from .briefs import build_daily_brief, show_brief
|
||||||
from .db import connect, init_db
|
from .db import connect, init_db
|
||||||
|
from .localtime import local_today
|
||||||
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
|
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
|
||||||
from .enrich import enrich_brief_images
|
from .enrich import enrich_brief_images
|
||||||
from .summarize import generate_summary, get_summary
|
from .summarize import generate_summary, get_summary
|
||||||
@@ -300,7 +300,7 @@ def main() -> None:
|
|||||||
replace=args.replace,
|
replace=args.replace,
|
||||||
)
|
)
|
||||||
print(f"Built brief {brief_id}")
|
print(f"Built brief {brief_id}")
|
||||||
bdate = args.date or date.today().isoformat()
|
bdate = args.date or local_today()
|
||||||
found = enrich_brief_images(conn, bdate)
|
found = enrich_brief_images(conn, bdate)
|
||||||
if found:
|
if found:
|
||||||
print(f"Enriched {found} hero image(s)")
|
print(f"Enriched {found} hero image(s)")
|
||||||
@@ -445,7 +445,7 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
print(f"dedup: skipped ({exc})")
|
print(f"dedup: skipped ({exc})")
|
||||||
|
|
||||||
if not args.no_brief:
|
if not args.no_brief:
|
||||||
today = date.today().isoformat()
|
today = local_today()
|
||||||
try:
|
try:
|
||||||
brief_id = build_daily_brief(conn, brief_date=today, limit=7, replace=True)
|
brief_id = build_daily_brief(conn, brief_date=today, limit=7, replace=True)
|
||||||
found = enrich_brief_images(conn, today)
|
found = enrich_brief_images(conn, today)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""The site's canonical local day.
|
||||||
|
|
||||||
|
There is one daily brief, so "today" must resolve to a single timezone — not the
|
||||||
|
host clock (which is UTC on the server and was silently reset on a rebuild).
|
||||||
|
GOODNEWS_TZ pins it explicitly (default UTC) so a rebuild can't reintroduce the
|
||||||
|
"brief shows tomorrow's date in the evening" bug. This governs when the brief
|
||||||
|
rolls over and the canonical date on the server-rendered /today page; each
|
||||||
|
visitor's *displayed* date is formatted in their own browser timezone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
def site_tz() -> ZoneInfo:
|
||||||
|
name = os.environ.get("GOODNEWS_TZ", "UTC")
|
||||||
|
try:
|
||||||
|
return ZoneInfo(name)
|
||||||
|
except (ZoneInfoNotFoundError, ValueError):
|
||||||
|
return ZoneInfo("UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def local_now() -> datetime:
|
||||||
|
return datetime.now(site_tz())
|
||||||
|
|
||||||
|
|
||||||
|
def local_today() -> str:
|
||||||
|
"""The site's current calendar date (ISO yyyy-mm-dd) in GOODNEWS_TZ."""
|
||||||
|
return local_now().date().isoformat()
|
||||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
from .feeds import MAX_BACKOFF_MINUTES
|
||||||
|
|
||||||
# Composite ranking used everywhere a "best first" order is needed. Kept as one
|
# Composite ranking used everywhere a "best first" order is needed. Kept as one
|
||||||
# expression so brief, category feeds, and the API all rank identically.
|
# expression so brief, category feeds, and the API all rank identically.
|
||||||
RANK_SCORE_SQL = (
|
RANK_SCORE_SQL = (
|
||||||
@@ -197,6 +199,71 @@ def history(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[di
|
|||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def content_stats(conn: sqlite3.Connection) -> dict:
|
||||||
|
"""Corpus / serving health for the dashboard: how much good news is live."""
|
||||||
|
|
||||||
|
def scalar(sql, params=()):
|
||||||
|
return conn.execute(sql, params).fetchone()[0] or 0
|
||||||
|
|
||||||
|
served = scalar(
|
||||||
|
"SELECT COUNT(*) FROM article_scores s JOIN articles a ON a.id=s.article_id "
|
||||||
|
"WHERE s.accepted=1 AND a.duplicate_of IS NULL"
|
||||||
|
)
|
||||||
|
accepted_7d = scalar(
|
||||||
|
"SELECT COUNT(*) FROM article_scores s JOIN articles a ON a.id=s.article_id "
|
||||||
|
"WHERE s.accepted=1 AND a.duplicate_of IS NULL "
|
||||||
|
"AND COALESCE(a.published_at, a.discovered_at) >= datetime('now','-7 days')"
|
||||||
|
)
|
||||||
|
brief = conn.execute(
|
||||||
|
"SELECT brief_date, (SELECT COUNT(*) FROM daily_brief_items WHERE brief_id=daily_briefs.id) n "
|
||||||
|
"FROM daily_briefs ORDER BY brief_date DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"served": served,
|
||||||
|
"total": scalar("SELECT COUNT(*) FROM articles"),
|
||||||
|
"rejected": scalar("SELECT COUNT(*) FROM article_scores WHERE accepted=0"),
|
||||||
|
"accepted_7d": accepted_7d,
|
||||||
|
"added_24h": scalar("SELECT COUNT(*) FROM articles WHERE discovered_at >= datetime('now','-1 day')"),
|
||||||
|
"summaries": scalar("SELECT COUNT(*) FROM article_summaries"),
|
||||||
|
"active_sources": scalar("SELECT COUNT(*) FROM sources WHERE active=1"),
|
||||||
|
"total_sources": scalar("SELECT COUNT(*) FROM sources"),
|
||||||
|
"latest_brief_date": brief["brief_date"] if brief else None,
|
||||||
|
"latest_brief_size": brief["n"] if brief else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def source_health(conn: sqlite3.Connection) -> list[dict]:
|
||||||
|
"""Per active source: failure streak, last error, accepted contribution, and
|
||||||
|
the computed next-poll time (so the backoff/'resting until' state is visible).
|
||||||
|
|
||||||
|
next_due_at = last attempt + MIN(cap, interval * (1 + consecutive_failures)),
|
||||||
|
mirroring feeds.due_source_rows; NULL last attempt means "due now".
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id, s.name, s.default_category AS category, s.active,
|
||||||
|
s.consecutive_failures AS failures,
|
||||||
|
s.poll_interval_minutes AS interval_minutes,
|
||||||
|
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
|
||||||
|
(SELECT MAX(r.finished_at) FROM ingest_runs r
|
||||||
|
WHERE r.source_id = s.id AND r.finished_at IS NOT NULL) AS last_attempt,
|
||||||
|
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
|
||||||
|
WHERE a.source_id = s.id AND sc.accepted = 1 AND a.duplicate_of IS NULL) AS served,
|
||||||
|
datetime(
|
||||||
|
(SELECT MAX(r.finished_at) FROM ingest_runs r
|
||||||
|
WHERE r.source_id = s.id AND r.finished_at IS NOT NULL),
|
||||||
|
'+' || MIN(?, s.poll_interval_minutes * (1 + s.consecutive_failures)) || ' minutes'
|
||||||
|
) AS next_due_at
|
||||||
|
FROM sources s
|
||||||
|
WHERE s.active = 1
|
||||||
|
ORDER BY s.consecutive_failures DESC, served DESC, s.name
|
||||||
|
""",
|
||||||
|
(MAX_BACKOFF_MINUTES,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||||
"""Aggregate, non-personal usage stats for the admin dashboard."""
|
"""Aggregate, non-personal usage stats for the admin dashboard."""
|
||||||
since = f"-{days} days"
|
since = f"-{days} days"
|
||||||
@@ -296,6 +363,8 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"days": days,
|
"days": days,
|
||||||
|
"content": content_stats(conn),
|
||||||
|
"sources": source_health(conn),
|
||||||
"visitors": visitors,
|
"visitors": visitors,
|
||||||
"returning": loyalty.get("returning", 0),
|
"returning": loyalty.get("returning", 0),
|
||||||
"once": loyalty.get("once", 0),
|
"once": loyalty.get("once", 0),
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from goodnews.db import connect, init_db
|
||||||
|
from goodnews import queries, localtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_site_tz_from_env_and_fallback(monkeypatch):
|
||||||
|
monkeypatch.setenv("GOODNEWS_TZ", "America/New_York")
|
||||||
|
assert localtime.site_tz().key == "America/New_York"
|
||||||
|
assert localtime.local_now().utcoffset() is not None # tz-aware
|
||||||
|
# A bogus zone must not crash — fall back to UTC.
|
||||||
|
monkeypatch.setenv("GOODNEWS_TZ", "Not/AZone")
|
||||||
|
assert localtime.site_tz().key == "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(c):
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,active,default_category) VALUES (1,'Good','http://s/1',1,'science')")
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,active,consecutive_failures,poll_interval_minutes) "
|
||||||
|
"VALUES (2,'Flaky','http://s/2',1,5,60)")
|
||||||
|
# source 1: two accepted (one duplicate → not served), one rejected
|
||||||
|
for aid, dup in [(1, None), (2, None), (3, 1)]:
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,duplicate_of,published_at) "
|
||||||
|
"VALUES (?,1,?,?,?,?,datetime('now'))", (aid, f"u{aid}", f"T{aid}", f"h{aid}", dup))
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (1,1)")
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (2,0)")
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (3,1)") # duplicate → excluded
|
||||||
|
c.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_stats_counts_served_excluding_duplicates(tmp_path):
|
||||||
|
c = connect(str(tmp_path / "t.db")); init_db(c); _seed(c)
|
||||||
|
cs = queries.content_stats(c)
|
||||||
|
assert cs["served"] == 1 # only article 1 (2 is rejected, 3 is a duplicate)
|
||||||
|
assert cs["total"] == 3
|
||||||
|
assert cs["rejected"] == 1
|
||||||
|
assert cs["active_sources"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_health_orders_failing_first_and_computes_next_due(tmp_path):
|
||||||
|
c = connect(str(tmp_path / "t.db")); init_db(c); _seed(c)
|
||||||
|
c.execute("INSERT INTO ingest_runs (source_id, finished_at, status) "
|
||||||
|
"VALUES (2, datetime('now','-30 minutes'), 'failed')")
|
||||||
|
c.commit()
|
||||||
|
sh = queries.source_health(c)
|
||||||
|
assert [s["name"] for s in sh][0] == "Flaky" # failing source floats to top
|
||||||
|
flaky = next(s for s in sh if s["name"] == "Flaky")
|
||||||
|
assert flaky["failures"] == 5
|
||||||
|
# next_due = last_attempt + 60*(1+5)=360 min; attempt was 30m ago → still resting
|
||||||
|
assert flaky["next_due_at"] is not None
|
||||||
|
good = next(s for s in sh if s["name"] == "Good")
|
||||||
|
assert good["served"] == 1 and good["next_due_at"] is None # never attempted → due now
|
||||||
Reference in New Issue
Block a user