()
- for (const theme of themes) {
- const key = dateKey(theme.created_at)
- const list = groups.get(key)
- if (list) list.push(theme)
- else groups.set(key, [theme])
- }
-
- for (const [key, groupThemes] of groups) {
- items.push({
- kind: "date-group",
- key,
- date: key,
- themes: groupThemes,
- })
- }
-
- // Add digests keyed by their period_end (so they appear after that week's content)
- for (const digest of digests) {
- items.push({
- kind: "weekly-summary",
- key: `summary-${digest.id}`,
- date: digest.period_end,
- digest,
- })
- }
-
- // Sort newest first
- items.sort((a, b) => b.date.localeCompare(a.date))
-
- return items
+function expandedSinceDate(months: MonthSummary[]): string {
+ if (months.length === 0) return rollingCutoffDate()
+ // months is sorted newest-first from the API. JS Date handles Dec→Jan.
+ const newest = months[0]
+ return new Date(Date.UTC(newest.year, newest.month, 1))
+ .toISOString()
+ .slice(0, 10)
}
function ReaderStream() {
- const { tag, q } = Route.useSearch()
+ const { tag, q, open } = Route.useSearch()
+ const navigate = useNavigate()
+ const isFiltered = Boolean(tag || q)
+
+ const { data: months } = useQuery({
+ queryKey: ["months"],
+ queryFn: () => fetchMonths(),
+ enabled: !isFiltered,
+ })
+
+ // `since` depends on the months response: expanded feed starts on the
+ // first day after the newest collapsed month. Wait for months before
+ // firing the themes query (unless we're in a filtered view, which
+ // ignores the window entirely).
+ const since = months === undefined ? undefined : expandedSinceDate(months)
const { data: themes, isLoading, error } = useQuery({
- queryKey: ["themes", { visibility: "published", tag, q }],
- queryFn: () => fetchThemes({ visibility: "published", tag, q }),
+ queryKey: isFiltered
+ ? ["themes", { visibility: "published", tag, q }]
+ : ["themes", { visibility: "published", since }],
+ queryFn: () =>
+ fetchThemes(
+ isFiltered
+ ? { visibility: "published", tag, q }
+ : { visibility: "published", since },
+ ),
+ enabled: isFiltered || since !== undefined,
})
- // Only fetch digests when not filtering
const { data: digests } = useQuery({
queryKey: ["digests"],
queryFn: () => fetchDigests(),
- enabled: !tag && !q,
+ enabled: !isFiltered,
})
- const feed = buildFeed(themes ?? [], digests ?? [])
+ const openKeys = new Set(open?.split(",").filter(Boolean) ?? [])
+ const collapsedMonthKeys = new Set(
+ (months ?? []).map((m) => monthKey(m)),
+ )
+ const feed = buildFeed(themes ?? [], digests ?? [], collapsedMonthKeys)
+
+ const toggleMonth = (key: string) => {
+ const next = new Set(openKeys)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ navigate({
+ to: "/",
+ search: (prev) => ({
+ ...prev,
+ open: next.size === 0 ? undefined : Array.from(next).join(","),
+ }),
+ })
+ }
return (
@@ -97,18 +118,24 @@ function ReaderStream() {
- {(tag || q) &&
}
+ {isFiltered &&
}
{isLoading &&
}
{error &&
Error: {error.message}
}
- {themes && themes.length === 0 && (
+ {themes && themes.length === 0 && !isFiltered && (!months || months.length === 0) && (
- {tag || q
- ? "No breadcrumbs along this path."
- : "The trail is quiet. No breadcrumbs have been dropped here yet."}
+ The trail is quiet. No breadcrumbs have been dropped here yet.
+
+
+ )}
+
+ {themes && themes.length === 0 && isFiltered && (
+
+
+ No breadcrumbs along this path.
)}
@@ -133,11 +160,32 @@ function ReaderStream() {
)}
)}
+
+ {!isFiltered && months && months.length > 0 && (
+
+ {months.map((m) => {
+ const key = monthKey(m)
+ return (
+ toggleMonth(key)}
+ />
+ )
+ })}
+
+ )}
)
}
+function monthKey(m: MonthSummary): string {
+ return `${m.year}-${String(m.month).padStart(2, "0")}`
+}
+
function ActiveFilters({ tag, q }: { tag?: string; q?: string }) {
return (
diff --git a/frontend/vite.preview.config.ts b/frontend/vite.preview.config.ts
new file mode 100644
index 0000000..3f3ff9c
--- /dev/null
+++ b/frontend/vite.preview.config.ts
@@ -0,0 +1,26 @@
+import path from "path"
+import { defineConfig } from "vite"
+import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
+import tailwindcss from "@tailwindcss/vite"
+import react from "@vitejs/plugin-react"
+
+export default defineConfig({
+ plugins: [
+ TanStackRouterVite({ target: "react" }),
+ tailwindcss(),
+ react(),
+ ],
+ resolve: {
+ alias: { "@": path.resolve(__dirname, "./src") },
+ },
+ server: {
+ port: 5180,
+ strictPort: true,
+ proxy: {
+ "/api": {
+ target: "http://localhost:8101",
+ changeOrigin: true,
+ },
+ },
+ },
+})
diff --git a/scripts/seed_preview.py b/scripts/seed_preview.py
new file mode 100644
index 0000000..6d4e9e8
--- /dev/null
+++ b/scripts/seed_preview.py
@@ -0,0 +1,109 @@
+"""Seed the local SQLite with realistic data to preview the rolling-window feed.
+
+Writes themes across April (recent + window-straddling), March (straddling),
+and February (fully collapsed), plus a monthly digest for February.
+"""
+
+from datetime import date, datetime, timedelta, timezone
+
+from sqlmodel import Session, SQLModel
+
+from app.db import engine
+from app.models import (
+ Breadcrumb,
+ Digest,
+ DigestStatus,
+ DigestType,
+ Tag,
+ Theme,
+ Visibility,
+)
+
+
+def _dt(days_ago: int, hours: int = 12) -> datetime:
+ return datetime.now(timezone.utc) - timedelta(days=days_ago, hours=-hours)
+
+
+def main() -> None:
+ SQLModel.metadata.create_all(engine)
+
+ with Session(engine) as session:
+ # Clean slate — this is a preview DB.
+ for model in (Breadcrumb, Theme, Digest, Tag):
+ for row in session.query(model).all():
+ session.delete(row)
+ session.commit()
+
+ tag_seed = Tag(name="seed")
+ tag_reflection = Tag(name="reflection")
+ tag_walking = Tag(name="walking")
+ session.add_all([tag_seed, tag_reflection, tag_walking])
+ session.flush()
+
+ def add_theme(body: str, days_ago: int, tags: list[Tag]) -> Theme:
+ t = Theme(
+ body_md=body,
+ visibility=Visibility.published,
+ created_at=_dt(days_ago),
+ )
+ t.tags = tags
+ session.add(t)
+ session.flush()
+ return t
+
+ # Current week (fully in window)
+ add_theme("# This week\n\nA fresh trail.", 1, [tag_seed])
+ add_theme("# Thinking out loud\n\nAn afternoon walk thought.", 3, [tag_walking])
+
+ # ~2 weeks back (in window)
+ add_theme("# Middle April\n\nNotes from mid-month.", 14, [tag_reflection])
+
+ # Crossing the cutoff — March 30-ish (inside 31-day window)
+ add_theme("# Late March\n\nStraddles the window edge.", 26, [tag_reflection])
+
+ # Early March (outside window but same month as a theme inside — stays expanded)
+ add_theme("# Early March\n\nBeginning of March.", 50, [tag_walking])
+
+ # February (fully outside window — collapses)
+ add_theme("# February seed\n\nOne of the 'lost' February themes.", 70, [tag_seed])
+ add_theme("# February reflection\n\nAnother February entry.", 75, [tag_reflection])
+ add_theme("# Early February\n\nJust after the new year inertia.", 85, [tag_seed])
+
+ # January (also fully outside window — collapses)
+ add_theme("# January thought\n\nNew-year energy.", 100, [tag_reflection])
+
+ # Monthly digest for February
+ today = date.today()
+ feb_month_year = today.year if today.month > 2 else today.year - 1
+ session.add(
+ Digest(
+ title="February 2026 in review",
+ summary_md=(
+ "A month spent on quiet reflection, long walks, and seeding ideas. "
+ "The throughline: attention is the first gift you give to a thought."
+ ),
+ digest_type=DigestType.monthly,
+ period_start=date(feb_month_year, 2, 1),
+ period_end=date(feb_month_year, 2, 28),
+ status=DigestStatus.published,
+ )
+ )
+
+ # One weekly digest inside February (reveals on expand)
+ session.add(
+ Digest(
+ title="Week of Feb 8",
+ summary_md="A week of walking, reading Kastrup, and thinking about attention.",
+ digest_type=DigestType.weekly,
+ period_start=date(feb_month_year, 2, 8),
+ period_end=date(feb_month_year, 2, 14),
+ status=DigestStatus.published,
+ )
+ )
+
+ session.commit()
+ print("Seeded preview DB.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_api_months.py b/tests/test_api_months.py
new file mode 100644
index 0000000..8db663d
--- /dev/null
+++ b/tests/test_api_months.py
@@ -0,0 +1,129 @@
+"""API tests for the rolling-window feed endpoints: GET /api/months and
+the `since` / `month` query params on GET /api/themes."""
+
+from datetime import date, datetime, timedelta, timezone
+
+from app.models import Digest, DigestStatus, DigestType, Theme, Visibility
+
+
+def _make_theme(session, body: str, created_at: datetime, published: bool = True) -> Theme:
+ theme = Theme(
+ body_md=body,
+ visibility=Visibility.published if published else Visibility.draft,
+ created_at=created_at,
+ )
+ session.add(theme)
+ session.flush()
+ session.refresh(theme)
+ return theme
+
+
+def test_list_months_empty_when_all_content_is_recent(client, session):
+ now = datetime.now(timezone.utc)
+ _make_theme(session, "recent", now - timedelta(days=5))
+ session.commit()
+
+ response = client.get("/api/months")
+ assert response.status_code == 200
+ assert response.json() == []
+
+
+def test_list_months_returns_old_months_with_counts(client, session):
+ now = datetime.now(timezone.utc)
+ # Two themes in a month that's definitely fully outside the 31-day window.
+ old = now - timedelta(days=120)
+ _make_theme(session, "old 1", old)
+ _make_theme(session, "old 2", old)
+ _make_theme(session, "recent", now - timedelta(days=2))
+ session.commit()
+
+ response = client.get("/api/months")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) == 1
+ entry = data[0]
+ assert entry["year"] == old.year
+ assert entry["month"] == old.month
+ assert entry["theme_count"] == 2
+ assert entry["monthly_digest"] is None
+
+
+def test_list_months_skips_draft_themes(client, session):
+ now = datetime.now(timezone.utc)
+ old = now - timedelta(days=120)
+ _make_theme(session, "draft", old, published=False)
+ session.commit()
+
+ response = client.get("/api/months")
+ assert response.status_code == 200
+ assert response.json() == []
+
+
+def test_list_months_attaches_monthly_digest(client, session):
+ now = datetime.now(timezone.utc)
+ old = now - timedelta(days=120)
+ _make_theme(session, "old", old)
+
+ digest = Digest(
+ title=f"{old.strftime('%B')} summary",
+ summary_md="Summary of the month",
+ digest_type=DigestType.monthly,
+ period_start=date(old.year, old.month, 1),
+ period_end=date(old.year, old.month, 28),
+ status=DigestStatus.published,
+ )
+ session.add(digest)
+ session.commit()
+
+ response = client.get("/api/months")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) == 1
+ assert data[0]["monthly_digest"] is not None
+ assert data[0]["monthly_digest"]["digest_type"] == "monthly"
+ assert data[0]["monthly_digest"]["summary_md"] == "Summary of the month"
+
+
+def test_themes_since_param_lifts_the_cap(client, session):
+ # Create 25 themes within the last week — more than the default 20 cap.
+ now = datetime.now(timezone.utc)
+ for i in range(25):
+ _make_theme(session, f"theme {i}", now - timedelta(hours=i))
+ session.commit()
+
+ since = (now - timedelta(days=7)).date().isoformat()
+ response = client.get(f"/api/themes?since={since}")
+ assert response.status_code == 200
+ assert len(response.json()) == 25
+
+
+def test_themes_month_param_returns_only_that_month(client, session):
+ now = datetime.now(timezone.utc)
+ old = now - timedelta(days=120)
+ _make_theme(session, "in month", old)
+ _make_theme(session, "other month", old - timedelta(days=60))
+ _make_theme(session, "recent", now - timedelta(days=2))
+ session.commit()
+
+ month_str = f"{old.year:04d}-{old.month:02d}"
+ response = client.get(f"/api/themes?month={month_str}")
+ assert response.status_code == 200
+ bodies = {t["body_md"] for t in response.json()}
+ assert bodies == {"in month"}
+
+
+def test_themes_month_param_rejects_invalid_format(client):
+ response = client.get("/api/themes?month=2026")
+ assert response.status_code == 400
+
+
+def test_themes_without_scope_still_paginates(client, session):
+ now = datetime.now(timezone.utc)
+ for i in range(25):
+ _make_theme(session, f"theme {i}", now - timedelta(hours=i))
+ session.commit()
+
+ response = client.get("/api/themes")
+ assert response.status_code == 200
+ # Default cap is 20 when no scoped filter is present.
+ assert len(response.json()) == 20
diff --git a/tests/test_feed.py b/tests/test_feed.py
new file mode 100644
index 0000000..db768c0
--- /dev/null
+++ b/tests/test_feed.py
@@ -0,0 +1,66 @@
+from datetime import date
+
+from app.feed import EXPANDED_WINDOW_DAYS, months_to_collapse, rolling_cutoff
+
+
+def test_rolling_cutoff_is_31_days_before_today():
+ assert rolling_cutoff(date(2026, 4, 24)) == date(2026, 3, 24)
+
+
+def test_month_with_theme_inside_window_stays_expanded():
+ # today=2026-04-24, cutoff=2026-03-24. March has a theme on 2026-03-30,
+ # which is inside the window — so March must NOT collapse even though
+ # it also has themes from Mar 1-22.
+ today = date(2026, 4, 24)
+ theme_dates = [
+ date(2026, 4, 10),
+ date(2026, 3, 30), # inside window
+ date(2026, 3, 5), # outside window
+ ]
+ assert months_to_collapse(today, theme_dates) == []
+
+
+def test_month_entirely_outside_window_collapses():
+ today = date(2026, 4, 24)
+ theme_dates = [
+ date(2026, 4, 10),
+ date(2026, 2, 28),
+ date(2026, 2, 1),
+ ]
+ assert months_to_collapse(today, theme_dates) == [(2026, 2)]
+
+
+def test_multiple_collapsed_months_sorted_newest_first():
+ today = date(2026, 4, 24)
+ theme_dates = [
+ date(2026, 4, 1),
+ date(2026, 2, 15),
+ date(2026, 1, 3),
+ date(2025, 12, 20),
+ ]
+ assert months_to_collapse(today, theme_dates) == [
+ (2026, 2),
+ (2026, 1),
+ (2025, 12),
+ ]
+
+
+def test_boundary_theme_on_cutoff_stays_expanded():
+ # "Last 31 days" is inclusive: today minus 31d = 31 days ago, still inside.
+ today = date(2026, 4, 24)
+ theme_dates = [date(2026, 3, 24)]
+ assert months_to_collapse(today, theme_dates) == []
+
+
+def test_boundary_theme_one_day_older_than_cutoff_collapses():
+ today = date(2026, 4, 24)
+ theme_dates = [date(2026, 3, 23)]
+ assert months_to_collapse(today, theme_dates) == [(2026, 3)]
+
+
+def test_no_themes_returns_empty():
+ assert months_to_collapse(date(2026, 4, 24), []) == []
+
+
+def test_window_is_31_days():
+ assert EXPANDED_WINDOW_DAYS == 31