Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 92 additions & 2 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -27,6 +29,10 @@
BreadcrumbBase,
BreadcrumbCreateInput,
BreadcrumbPublic,
Digest,
DigestPublic,
DigestType,
MonthSummary,
Tag,
TagCreate,
TagWithCount,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions app/feed.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------


Expand Down
113 changes: 113 additions & 0 deletions frontend/src/components/collapsed-month-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section id={`month-${param}`}>
<button
type="button"
onClick={onToggle}
aria-expanded={isOpen}
className="group flex w-full items-start gap-3 py-2 text-left transition-colors"
>
<ChevronDown
className={cn(
"mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isOpen ? "rotate-0" : "-rotate-90",
)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-lg font-semibold tracking-tight">{heading}</h2>
<span className="text-xs text-muted-foreground shrink-0">
{month.theme_count} {themeWord}
</span>
</div>
{!isOpen && month.monthly_digest && (
<p className="mt-2 text-sm leading-relaxed text-muted-foreground line-clamp-3">
{month.monthly_digest.summary_md}
</p>
)}
</div>
</button>

{isOpen && (
<div className="mt-4 space-y-8">
{month.monthly_digest && (
<div className="relative rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-3">
<p className="text-sm leading-relaxed text-muted-foreground">
{month.monthly_digest.summary_md}
</p>
<span title="AI-generated summary">
<Sparkles className="absolute bottom-2 right-3 h-3 w-3 text-muted-foreground/25" />
</span>
</div>
)}

{isLoading && (
<p className="text-sm text-muted-foreground italic">Loading…</p>
)}

{entries.map((item) =>
item.kind === "date-group" ? (
<section key={item.key}>
<h3 className="text-base font-semibold tracking-tight mb-4">
{formatDateHeading(item.themes[0].created_at)}
</h3>
<div className="space-y-8 pl-4 border-l-2 border-border">
{item.themes.map((theme) => (
<ThemeSection key={theme.id} theme={theme} />
))}
</div>
</section>
) : (
<WeeklySummary key={item.key} digest={item.digest} />
),
)}
</div>
)}
</section>
)
}
44 changes: 29 additions & 15 deletions frontend/src/components/digest-nav.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -8,6 +9,7 @@ function formatMonthLabel(periodStart: string): string {
}

export function DigestNav({ digests }: { digests: DigestPublic[] }) {
const navigate = useNavigate()
const monthly = useMemo(
() =>
digests
Expand All @@ -24,22 +26,34 @@ export function DigestNav({ digests }: { digests: DigestPublic[] }) {
Monthly Digests
</h3>
<nav className="flex flex-col gap-1">
{monthly.map((digest) => (
<a
key={digest.id}
href={`#digest-${digest.id}`}
onClick={(e) => {
const target = document.getElementById(`digest-${digest.id}`)
if (target) {
{monthly.map((digest) => {
const monthKey = digest.period_start.slice(0, 7) // YYYY-MM
return (
<a
key={digest.id}
href={`#month-${monthKey}`}
onClick={(e) => {
e.preventDefault()
target.scrollIntoView({ behavior: "smooth" })
}
}}
className="text-sm text-muted-foreground/70 hover:text-foreground transition-colors"
>
{formatMonthLabel(digest.period_start)}
</a>
))}
// Clear any tag/q filter and ensure this month is open, then
// scroll after the card renders.
navigate({
to: "/",
search: { open: monthKey },
}).then(() => {
requestAnimationFrame(() => {
const target =
document.getElementById(`month-${monthKey}`) ??
document.getElementById(`digest-${digest.id}`)
target?.scrollIntoView({ behavior: "smooth" })
})
})
}}
className="text-sm text-muted-foreground/70 hover:text-foreground transition-colors"
>
{formatMonthLabel(digest.period_start)}
</a>
)
})}
</nav>
</div>
)
Expand Down
Loading
Loading