Skip to content

Commit 8d702d2

Browse files
authored
feat(v0.6): content-freshness badges on every built page (#57) (#69)
Closes #57. New module `llmwiki.freshness` computes the age of a page from its frontmatter (last_updated → ended → started → date, in that order) and renders a color-coded pill: - green — updated ≤ 14 days ago - yellow — updated 15–60 days ago - red — updated > 60 days ago - unknown — no resolvable timestamp or clock skew (future) Thresholds are configurable via config.json:: { "freshness": { "green_days": 7, "yellow_days": 30 } } The relative-time formatter is compact (today / yesterday / N days / N weeks / N months / N years). All timestamps are normalised to naive UTC before subtraction so tz skew between session files and the build host cannot shift pages across buckets by accident. "Build now" is cached once per build so every page shows the same clock. Badges render: - in the session page hero strip, after "N min read" - on every project card (projects/index.html) — reflecting the newest session in that project - on every session card inside a project page 34 new tests in tests/test_freshness.py covering: - ISO datetime parsing (UTC, Z suffix, offset, date-only, empty, malformed) - Last-updated resolution priority - Relative-time formatting for all bucket boundaries - Exact boundary tests: 14 → green, 15 → yellow, 60 → yellow, 61 → red - Far past (1000 days = red) - Far future clock skew (-5 days = unknown) - Config loading: missing file, custom thresholds, inverted thresholds rejected, bad JSON tolerated, missing section kept defaults
1 parent 04250a1 commit 8d702d2

3 files changed

Lines changed: 488 additions & 0 deletions

File tree

llmwiki/build.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@
3333
import subprocess
3434
import sys
3535
from collections import defaultdict
36+
from datetime import datetime
3637
from pathlib import Path
3738
from typing import Any, Optional
3839

3940
import markdown
4041

4142
from llmwiki import REPO_ROOT
43+
from llmwiki.freshness import freshness_badge, load_freshness_config
4244

4345
# ─── paths ─────────────────────────────────────────────────────────────────
4446

@@ -213,6 +215,29 @@ def short_started(meta: dict[str, Any]) -> str:
213215
return s[:16].replace("T", " ")
214216

215217

218+
# ─── freshness (content staleness) ────────────────────────────────────────
219+
# Cached once per build so every page sees the same "now" and the same
220+
# thresholds. Populated lazily by render_freshness().
221+
_FRESHNESS_CONFIG: Optional[tuple[int, int]] = None
222+
_BUILD_NOW: Optional[datetime] = None
223+
224+
225+
def render_freshness(meta: dict[str, Any]) -> str:
226+
"""Render a freshness badge for a page's frontmatter using cached config.
227+
228+
Thresholds come from ``config.json`` (freshness.green_days /
229+
yellow_days) or the module defaults. Build-time "now" is cached the
230+
first call so the whole site renders with one consistent clock.
231+
"""
232+
global _FRESHNESS_CONFIG, _BUILD_NOW
233+
if _FRESHNESS_CONFIG is None:
234+
_FRESHNESS_CONFIG = load_freshness_config()
235+
if _BUILD_NOW is None:
236+
_BUILD_NOW = datetime.utcnow()
237+
green, yellow = _FRESHNESS_CONFIG
238+
return freshness_badge(meta, now=_BUILD_NOW, green_days=green, yellow_days=yellow)
239+
240+
216241
# ─── html template helpers ─────────────────────────────────────────────────
217242

218243
def page_head(title: str, description: str, css_prefix: str = "") -> str:
@@ -462,6 +487,7 @@ def render_session(
462487
if meta.get("tool_calls"):
463488
bits.append(f'{html.escape(str(meta["tool_calls"]))} tools')
464489
bits.append(f'<span class="muted">{reading_min} min read</span>')
490+
bits.append(render_freshness(meta))
465491
meta_strip = " · ".join(bits) if bits else ""
466492

467493
tools_list = get_tools_list(meta)
@@ -541,10 +567,12 @@ def card(p: Path, meta: dict[str, Any]) -> str:
541567
umsgs = meta.get("user_messages", "")
542568
tcalls = meta.get("tool_calls", "")
543569
href = f"../sessions/{project_slug}/{p.stem}.html"
570+
badge = render_freshness(meta)
544571
return f""" <a class="card" href="{href}">
545572
<div class="card-title">{html.escape(str(slug))}</div>
546573
<div class="card-meta">{html.escape(str(date))} · {html.escape(str(model))}</div>
547574
<div class="card-stats muted">{html.escape(str(umsgs))} messages · {html.escape(str(tcalls))} tool calls</div>
575+
<div class="card-badge">{badge}</div>
548576
</a>"""
549577

550578
cards_main = "\n".join(card(p, m) for p, m, _ in main_sessions)
@@ -607,10 +635,18 @@ def render_projects_index(
607635
for project, sessions in sorted(groups.items(), key=lambda x: -len(x[1])):
608636
main_count = sum(1 for p, _, _ in sessions if "subagent" not in p.name)
609637
sub_count = len(sessions) - main_count
638+
# Freshness reflects the newest session in the project.
639+
newest_meta = max(
640+
(m for _, m, _ in sessions),
641+
key=lambda m: str(m.get("ended") or m.get("started") or m.get("date") or ""),
642+
default={},
643+
)
644+
badge = render_freshness(newest_meta)
610645
cards.append(
611646
f""" <a class="card" href="{html.escape(project)}.html">
612647
<div class="card-title">{html.escape(project)}</div>
613648
<div class="card-meta">{main_count} main · {sub_count} sub-agent</div>
649+
<div class="card-badge">{badge}</div>
614650
</a>"""
615651
)
616652

@@ -1058,6 +1094,31 @@ def build_search_index(
10581094
.card-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 4px; color: var(--text); }
10591095
.card-meta { font-size: 0.82rem; color: var(--text-secondary); }
10601096
.card-stats { font-size: 0.78rem; margin-top: 6px; }
1097+
.card-badge { margin-top: 8px; }
1098+
1099+
/* Content-freshness badge (#57) */
1100+
.freshness {
1101+
display: inline-flex; align-items: center; gap: 6px;
1102+
padding: 2px 10px; border-radius: 999px;
1103+
font-size: 0.72rem; font-weight: 600; white-space: nowrap;
1104+
border: 1px solid;
1105+
}
1106+
.freshness::before {
1107+
content: ""; width: 6px; height: 6px; border-radius: 50%;
1108+
background: currentColor;
1109+
}
1110+
.fresh-green { color: #15803d; background: #dcfce7; border-color: #86efac; }
1111+
.fresh-yellow { color: #b45309; background: #fef3c7; border-color: #fcd34d; }
1112+
.fresh-red { color: #b91c1c; background: #fee2e2; border-color: #fca5a5; }
1113+
.fresh-unknown { color: #6b7280; background: #f3f4f6; border-color: #d1d5db; }
1114+
:root[data-theme="dark"] .fresh-green { color: #86efac; background: #052e16; border-color: #065f46; }
1115+
:root[data-theme="dark"] .fresh-yellow { color: #fcd34d; background: #3a2a06; border-color: #78350f; }
1116+
:root[data-theme="dark"] .fresh-red { color: #fca5a5; background: #3a0a0a; border-color: #7f1d1d; }
1117+
:root[data-theme="dark"] .fresh-unknown { color: #9ca3af; background: #1f2937; border-color: #374151; }
1118+
@media print {
1119+
.freshness { background: #fff !important; color: #000 !important; border-color: #ccc !important; }
1120+
.freshness::before { background: #000 !important; }
1121+
}
10611122
10621123
/* Sub-agent collapsible */
10631124
.sub-section { margin-top: 32px; }

llmwiki/freshness.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Content-freshness badges — v0.6 (#57).
2+
3+
Computes the age of a page (in days) from its frontmatter and returns a
4+
color-coded badge rendered as HTML. Ages are bucketed into:
5+
6+
- ``fresh-green`` — updated ≤ ``green_days`` ago (default 14)
7+
- ``fresh-yellow`` — updated ≤ ``yellow_days`` ago (default 60)
8+
- ``fresh-red`` — updated > ``yellow_days`` ago
9+
- ``fresh-unknown`` — no resolvable timestamp OR clock skew (future)
10+
11+
Thresholds can be overridden via ``config.json``::
12+
13+
{
14+
"freshness": {
15+
"green_days": 14,
16+
"yellow_days": 60
17+
}
18+
}
19+
20+
The badge text uses a compact relative-time formatter (today / yesterday /
21+
N days / N weeks / N months / N years). All timestamps are normalised to
22+
naive UTC before subtraction so timezone skew between session files and
23+
the build host cannot move pages across buckets by accident.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import html
29+
import json
30+
from datetime import datetime, timezone
31+
from pathlib import Path
32+
from typing import Any, Optional
33+
34+
from llmwiki import REPO_ROOT
35+
36+
DEFAULT_GREEN_DAYS = 14
37+
DEFAULT_YELLOW_DAYS = 60
38+
39+
40+
def load_freshness_config(repo_root: Path = REPO_ROOT) -> tuple[int, int]:
41+
"""Return ``(green_days, yellow_days)`` from ``config.json`` or defaults."""
42+
candidate = repo_root / "config.json"
43+
if not candidate.exists():
44+
return DEFAULT_GREEN_DAYS, DEFAULT_YELLOW_DAYS
45+
try:
46+
data = json.loads(candidate.read_text(encoding="utf-8"))
47+
except (json.JSONDecodeError, OSError):
48+
return DEFAULT_GREEN_DAYS, DEFAULT_YELLOW_DAYS
49+
fresh = data.get("freshness") or {}
50+
try:
51+
green = int(fresh.get("green_days", DEFAULT_GREEN_DAYS))
52+
yellow = int(fresh.get("yellow_days", DEFAULT_YELLOW_DAYS))
53+
except (TypeError, ValueError):
54+
return DEFAULT_GREEN_DAYS, DEFAULT_YELLOW_DAYS
55+
if green < 0 or yellow < green:
56+
return DEFAULT_GREEN_DAYS, DEFAULT_YELLOW_DAYS
57+
return green, yellow
58+
59+
60+
def parse_timestamp(value: Any) -> Optional[datetime]:
61+
"""Parse an ISO datetime or ``YYYY-MM-DD`` into a naive UTC datetime.
62+
63+
Returns ``None`` for empty, missing, or malformed values.
64+
"""
65+
if value is None:
66+
return None
67+
s = str(value).strip()
68+
if not s:
69+
return None
70+
# Try ISO datetime first (with or without timezone)
71+
if "T" in s or "+" in s[10:] or "Z" in s:
72+
clean = s.replace("Z", "+00:00")
73+
try:
74+
dt = datetime.fromisoformat(clean)
75+
except ValueError:
76+
return None
77+
if dt.tzinfo is not None:
78+
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
79+
return dt
80+
# Fall back to date-only
81+
try:
82+
return datetime.strptime(s[:10], "%Y-%m-%d")
83+
except ValueError:
84+
return None
85+
86+
87+
def resolve_last_updated(meta: dict[str, Any]) -> Optional[datetime]:
88+
"""Pick the newest timestamp available from a page's frontmatter.
89+
90+
Prefers ``last_updated`` (explicit), then ``ended``, ``started``, ``date``.
91+
"""
92+
for key in ("last_updated", "ended", "started", "date"):
93+
dt = parse_timestamp(meta.get(key))
94+
if dt is not None:
95+
return dt
96+
return None
97+
98+
99+
def format_relative_time(age_days: int) -> str:
100+
"""Compact human label for an age in days (never more than 2 words)."""
101+
if age_days < 0:
102+
return "unknown"
103+
if age_days == 0:
104+
return "today"
105+
if age_days == 1:
106+
return "yesterday"
107+
if age_days < 14:
108+
return f"{age_days} days ago"
109+
if age_days < 60:
110+
weeks = age_days // 7
111+
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
112+
if age_days < 365:
113+
months = age_days // 30
114+
return f"{months} month{'s' if months != 1 else ''} ago"
115+
years = age_days // 365
116+
return f"{years} year{'s' if years != 1 else ''} ago"
117+
118+
119+
def freshness_class(
120+
age_days: Optional[int],
121+
green_days: int = DEFAULT_GREEN_DAYS,
122+
yellow_days: int = DEFAULT_YELLOW_DAYS,
123+
) -> str:
124+
"""Return the CSS class for a given age. ``None`` or negative → unknown."""
125+
if age_days is None or age_days < 0:
126+
return "fresh-unknown"
127+
if age_days <= green_days:
128+
return "fresh-green"
129+
if age_days <= yellow_days:
130+
return "fresh-yellow"
131+
return "fresh-red"
132+
133+
134+
def freshness_badge(
135+
meta: dict[str, Any],
136+
now: Optional[datetime] = None,
137+
green_days: int = DEFAULT_GREEN_DAYS,
138+
yellow_days: int = DEFAULT_YELLOW_DAYS,
139+
) -> str:
140+
"""Render a freshness badge for a page given its frontmatter.
141+
142+
``now`` lets tests inject a deterministic clock; otherwise ``utcnow()``.
143+
"""
144+
dt = resolve_last_updated(meta)
145+
if dt is None:
146+
return (
147+
'<span class="freshness fresh-unknown" '
148+
'title="No last-updated timestamp">updated unknown</span>'
149+
)
150+
current = now if now is not None else datetime.utcnow()
151+
age = (current - dt).days
152+
cls = freshness_class(age, green_days, yellow_days)
153+
label = format_relative_time(age)
154+
iso = dt.strftime("%Y-%m-%d")
155+
return (
156+
f'<span class="freshness {cls}" '
157+
f'title="Last updated {html.escape(iso)}">updated {html.escape(label)}</span>'
158+
)

0 commit comments

Comments
 (0)