diff --git a/app/api.py b/app/api.py index ae12352..6975a5b 100644 --- a/app/api.py +++ b/app/api.py @@ -3,6 +3,7 @@ import re import uuid from contextlib import asynccontextmanager +from datetime import date, datetime, time, timezone from pathlib import Path from typing import List, Optional @@ -16,6 +17,7 @@ from app.auth import create_access_token, require_admin, verify_admin_password from app.db import get_session +from app.feed import months_to_collapse from app.images import ( ImageCommitError, ImageGenerationError, @@ -27,6 +29,10 @@ BreadcrumbBase, BreadcrumbCreateInput, BreadcrumbPublic, + Digest, + DigestPublic, + DigestType, + MonthSummary, Tag, TagCreate, TagWithCount, @@ -164,13 +170,29 @@ def create_theme( return theme +def _parse_month(month: str) -> tuple[date, date]: + """Return (start, next_month_start) for a YYYY-MM string.""" + try: + year_i, month_i = (int(x) for x in month.split("-")) + start = date(year_i, month_i, 1) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="month must be YYYY-MM") + if month_i == 12: + end = date(year_i + 1, 1, 1) + else: + end = date(year_i, month_i + 1, 1) + return start, end + + @router.get("/themes", response_model=list[ThemePublic]) def list_themes( session: Session = Depends(get_session), visibility: Optional[Visibility] = None, tag: Optional[str] = None, q: Optional[str] = None, - limit: int = Query(default=20, ge=1, le=100), + since: Optional[date] = None, + month: Optional[str] = None, + limit: int = Query(default=20, ge=1, le=500), offset: int = Query(default=0, ge=0), ): statement = select(Theme) @@ -192,13 +214,81 @@ def list_themes( ) ) + if since is not None: + since_dt = datetime.combine(since, time.min, tzinfo=timezone.utc) + statement = statement.where(Theme.created_at >= since_dt) + + if month is not None: + month_start, month_end = _parse_month(month) + start_dt = datetime.combine(month_start, time.min, tzinfo=timezone.utc) + end_dt = datetime.combine(month_end, time.min, tzinfo=timezone.utc) + statement = statement.where(Theme.created_at >= start_dt).where( + Theme.created_at < end_dt + ) + statement = statement.order_by(col(Theme.created_at).desc()) - statement = statement.offset(offset).limit(limit) + + # Scoped filters naturally bound their result sets; only paginate the + # unscoped "give me everything" call so we don't silently truncate + # a tag/search/month/since query. + is_scoped = any(v is not None for v in (tag, q, since, month)) + if not is_scoped: + statement = statement.offset(offset).limit(limit) themes = session.exec(statement).all() return themes +@router.get("/months", response_model=list[MonthSummary]) +def list_months(session: Session = Depends(get_session)): + """List past calendar months that should render as collapsed cards. + + A month is included iff every published theme in it falls outside the + 31-day rolling expanded window. Each entry includes theme count and the + monthly digest covering that month (if one exists). + """ + today = datetime.now(timezone.utc).date() + + theme_rows = session.exec( + select(Theme.created_at).where(Theme.visibility == Visibility.published) + ).all() + theme_dates = [dt.date() for dt in theme_rows] + + collapsed = months_to_collapse(today, theme_dates) + if not collapsed: + return [] + + # Count themes per (year, month) once. + counts: dict[tuple[int, int], int] = {} + for d in theme_dates: + key = (d.year, d.month) + counts[key] = counts.get(key, 0) + 1 + + # Fetch any monthly digest whose period_start lies in one of the collapsed + # months in a single query, then index by (year, month). + monthly_digests = session.exec( + select(Digest).where(Digest.digest_type == DigestType.monthly) + ).all() + digest_by_month: dict[tuple[int, int], Digest] = {} + for digest in monthly_digests: + key = (digest.period_start.year, digest.period_start.month) + digest_by_month[key] = digest + + return [ + MonthSummary( + year=year, + month=month, + theme_count=counts.get((year, month), 0), + monthly_digest=( + DigestPublic.model_validate(digest_by_month[(year, month)], from_attributes=True) + if (year, month) in digest_by_month + else None + ), + ) + for year, month in collapsed + ] + + @router.get("/themes/{theme_id}", response_model=ThemePublic) def get_theme( theme_id: int, diff --git a/app/feed.py b/app/feed.py new file mode 100644 index 0000000..6648976 --- /dev/null +++ b/app/feed.py @@ -0,0 +1,42 @@ +"""Feed grouping logic for the reader stream. + +The reader sees a rolling 31-day expanded window of recent content; +everything older is grouped by calendar month and presented collapsed +behind its monthly digest. A month collapses only if *every* theme in +it is older than the cutoff — so months that straddle the window stay +fully expanded, and users typically see 1–2 months' worth of content +uncollapsed at any time. +""" + +from datetime import date, timedelta + +EXPANDED_WINDOW_DAYS = 31 + + +def rolling_cutoff(today: date, window_days: int = EXPANDED_WINDOW_DAYS) -> date: + """The oldest date still inside the expanded window.""" + return today - timedelta(days=window_days) + + +def months_to_collapse( + today: date, + theme_dates: list[date], + window_days: int = EXPANDED_WINDOW_DAYS, +) -> list[tuple[int, int]]: + """Return (year, month) pairs for months that should render collapsed. + + A month is collapsed iff every theme date in it is strictly older than + `today - window_days`. Months with *any* theme inside the window stay + expanded. Result is sorted newest-first for stable feed order. + """ + cutoff = rolling_cutoff(today, window_days) + + dates_by_month: dict[tuple[int, int], list[date]] = {} + for d in theme_dates: + dates_by_month.setdefault((d.year, d.month), []).append(d) + + collapsed = [ + ym for ym, dates in dates_by_month.items() if max(dates) < cutoff + ] + collapsed.sort(reverse=True) + return collapsed diff --git a/app/models.py b/app/models.py index 30eb0cb..8d41519 100644 --- a/app/models.py +++ b/app/models.py @@ -269,6 +269,14 @@ class DigestCreate(SQLModel, table=False): period_end: date +class MonthSummary(SQLModel, table=False): + """Summary of a calendar month rendered as a collapsed card in the feed.""" + year: int + month: int + theme_count: int + monthly_digest: Optional[DigestPublic] = None + + # ---------- subscribers ---------- diff --git a/frontend/src/components/collapsed-month-card.tsx b/frontend/src/components/collapsed-month-card.tsx new file mode 100644 index 0000000..c9fc7a2 --- /dev/null +++ b/frontend/src/components/collapsed-month-card.tsx @@ -0,0 +1,113 @@ +import { useQuery } from "@tanstack/react-query" +import { ChevronDown, Sparkles } from "lucide-react" +import { ThemeSection } from "@/components/theme-section" +import { WeeklySummary } from "@/components/weekly-summary" +import { fetchThemes } from "@/lib/api" +import { buildFeed } from "@/lib/feed" +import type { DigestPublic, MonthSummary } from "@/lib/types" +import { cn, formatDateHeading } from "@/lib/utils" + +interface Props { + month: MonthSummary + digests: DigestPublic[] + isOpen: boolean + onToggle: () => void +} + +function formatMonthHeading(year: number, month: number): string { + // Local-time constructor — matches the formatter's local timezone so we + // don't get "January 2026" displayed for a February card in UTC-offset zones. + return new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + }).format(new Date(year, month - 1, 1)) +} + +function monthParam(year: number, month: number): string { + return `${year}-${String(month).padStart(2, "0")}` +} + +export function CollapsedMonthCard({ month, digests, isOpen, onToggle }: Props) { + const param = monthParam(month.year, month.month) + const heading = formatMonthHeading(month.year, month.month) + + const { data: themes, isLoading } = useQuery({ + queryKey: ["themes", { month: param, visibility: "published" }], + queryFn: () => fetchThemes({ visibility: "published", month: param }), + enabled: isOpen, + }) + + const weeklyDigests = digests.filter( + (d) => d.digest_type === "weekly" && d.period_start.startsWith(param), + ) + + const entries = isOpen ? buildFeed(themes ?? [], weeklyDigests) : [] + const themeWord = month.theme_count === 1 ? "theme" : "themes" + + return ( +
+ + + {isOpen && ( +
+ {month.monthly_digest && ( +
+

+ {month.monthly_digest.summary_md} +

+ + + +
+ )} + + {isLoading && ( +

Loading…

+ )} + + {entries.map((item) => + item.kind === "date-group" ? ( +
+

+ {formatDateHeading(item.themes[0].created_at)} +

+
+ {item.themes.map((theme) => ( + + ))} +
+
+ ) : ( + + ), + )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/digest-nav.tsx b/frontend/src/components/digest-nav.tsx index a6935ba..8adae60 100644 --- a/frontend/src/components/digest-nav.tsx +++ b/frontend/src/components/digest-nav.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react" +import { useNavigate } from "@tanstack/react-router" import type { DigestPublic } from "@/lib/types" function formatMonthLabel(periodStart: string): string { @@ -8,6 +9,7 @@ function formatMonthLabel(periodStart: string): string { } export function DigestNav({ digests }: { digests: DigestPublic[] }) { + const navigate = useNavigate() const monthly = useMemo( () => digests @@ -24,22 +26,34 @@ export function DigestNav({ digests }: { digests: DigestPublic[] }) { Monthly Digests ) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 894a7a8..6463cb7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,6 +5,7 @@ import type { DigestPublic, DigestType, GenerateImageResponse, + MonthSummary, TagWithCount, ThemeCreateInput, ThemePublic, @@ -22,6 +23,8 @@ export interface ThemeSearchParams { visibility?: Visibility tag?: string q?: string + since?: string // YYYY-MM-DD + month?: string // YYYY-MM limit?: number offset?: number } @@ -88,6 +91,10 @@ export function fetchTags(): Promise { return apiFetch("/api/tags", "tags") } +export function fetchMonths(): Promise { + return apiFetch("/api/months", "months") +} + // --------------------------------------------------------------------------- // Mutation helper (POST / PUT / DELETE) // --------------------------------------------------------------------------- diff --git a/frontend/src/lib/feed.ts b/frontend/src/lib/feed.ts new file mode 100644 index 0000000..84f6b4a --- /dev/null +++ b/frontend/src/lib/feed.ts @@ -0,0 +1,45 @@ +import type { DigestPublic, ThemePublic } from "@/lib/types" +import { dateKey } from "@/lib/utils" + +export type FeedItem = + | { kind: "date-group"; key: string; date: string; themes: ThemePublic[] } + | { kind: "weekly-summary"; key: string; date: string; digest: DigestPublic } + +/** + * Merge date-grouped themes and weekly digests into a single + * chronologically-sorted feed (newest first). + * + * `excludeWeeklyInMonths` suppresses weekly digests whose period_end falls + * in a listed YYYY-MM — used by the home feed to hide weekly summaries for + * months that render as collapsed cards (those surface on expand). + */ +export function buildFeed( + themes: ThemePublic[], + digests: DigestPublic[], + excludeWeeklyInMonths?: Set, +): FeedItem[] { + const groups = new Map() + 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]) + } + + const items: FeedItem[] = [] + for (const [key, groupThemes] of groups) { + items.push({ kind: "date-group", key, date: key, themes: groupThemes }) + } + for (const digest of digests) { + if (digest.digest_type !== "weekly") continue + if (excludeWeeklyInMonths?.has(digest.period_end.slice(0, 7))) continue + items.push({ + kind: "weekly-summary", + key: `summary-${digest.id}`, + date: digest.period_end, + digest, + }) + } + items.sort((a, b) => b.date.localeCompare(a.date)) + return items +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 9e9e0b2..c24ccd0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -39,6 +39,8 @@ export interface TagWithCount extends TagPublic { export interface StreamSearch { tag?: string q?: string + /** Comma-separated YYYY-MM keys of expanded past months. */ + open?: string } /** Input for POST /themes */ @@ -81,3 +83,11 @@ export interface DigestPublic { sent_at: string | null created_at: string } + +/** A past calendar month rendered collapsed in the feed. */ +export interface MonthSummary { + year: number + month: number // 1-12 + theme_count: number + monthly_digest: DigestPublic | null +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index c990c52..d0f850f 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,84 +1,105 @@ -import { createFileRoute, Link } from "@tanstack/react-router" +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { useQuery } from "@tanstack/react-query" import { X } from "lucide-react" import { ThemeSection } from "@/components/theme-section" import { TagBar } from "@/components/tag-bar" import { StreamSkeleton } from "@/components/stream-skeleton" import { WeeklySummary } from "@/components/weekly-summary" +import { CollapsedMonthCard } from "@/components/collapsed-month-card" import { DigestNav } from "@/components/digest-nav" -import { fetchDigests, fetchThemes } from "@/lib/api" -import type { DigestPublic, StreamSearch, ThemePublic } from "@/lib/types" -import { dateKey, formatDateHeading } from "@/lib/utils" +import { fetchDigests, fetchMonths, fetchThemes } from "@/lib/api" +import { buildFeed } from "@/lib/feed" +import type { MonthSummary, StreamSearch } from "@/lib/types" +import { formatDateHeading } from "@/lib/utils" + +const EXPANDED_WINDOW_DAYS = 31 export const Route = createFileRoute("/")({ component: ReaderStream, validateSearch: (search: Record): StreamSearch => ({ tag: typeof search.tag === "string" ? search.tag : undefined, q: typeof search.q === "string" ? search.q : undefined, + open: typeof search.open === "string" ? search.open : undefined, }), }) -type FeedItem = - | { kind: "date-group"; key: string; date: string; themes: ThemePublic[] } - | { kind: "weekly-summary"; key: string; date: string; digest: DigestPublic } +function rollingCutoffDate(): string { + const d = new Date() + d.setUTCDate(d.getUTCDate() - EXPANDED_WINDOW_DAYS) + return d.toISOString().slice(0, 10) +} /** - * Merge date-grouped themes and weekly digests into a single - * chronologically sorted feed (newest first). + * The earliest date whose themes belong in the expanded feed. + * + * If any months are collapsed, the expanded region starts on day 1 of the + * month immediately after the newest collapsed month. This keeps months + * that straddle the 31-day window fully visible (e.g. themes from earlier + * in March when today is late April). */ -function buildFeed(themes: ThemePublic[], digests: DigestPublic[]): FeedItem[] { - const items: FeedItem[] = [] - - // Group themes by date - const groups = new Map() - 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