Add Reddit monitoring bot — backend, frontend, and Docker config

Python/FastAPI backend with PostgreSQL for collecting Reddit data via
public .json endpoints. React/Vite dashboard for analytics. Docker Compose
setup with API and worker services connecting to shared PostgreSQL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 19:29:58 -05:00
parent aaa240dbf0
commit bc2203524f
76 changed files with 7570 additions and 0 deletions

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Database (shared PostgreSQL instance)
DATABASE_URL=postgresql+asyncpg://reddit:changeme@localhost:5432/reddit_monitor
# Reddit scraping
REDDIT_USER_AGENT=reddit-monitor:v1.0
# Subreddits to auto-add on first worker startup (comma-separated)
SEED_SUBREDDITS=
# Daily digest generation hour (UTC, 0-23)
DIGEST_HOUR_UTC=23
# AI summaries (stub — enable later)
AI_SUMMARY_ENABLED=false

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
*.egg
.venv/
venv/
# Environment
.env
# IDE
.vscode/
.idea/
# Node
node_modules/
frontend/dist/
# Docker
.docker/
# Alembic
alembic/versions/__pycache__/
# OS
.DS_Store
Thumbs.db

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# Stage 1: Build React frontend
FROM node:20-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Python runtime
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ backend/
COPY alembic/ alembic/
COPY alembic.ini .
# Copy frontend build output
COPY --from=frontend /app/frontend/dist/ static/
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && uvicorn backend.main:app --host 0.0.0.0 --port 8000"]

11
Dockerfile.worker Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ backend/
COPY alembic/ alembic/
COPY alembic.ini .
CMD ["python", "-m", "backend.worker.main"]

36
alembic.ini Normal file
View File

@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

56
alembic/env.py Normal file
View File

@@ -0,0 +1,56 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from backend.config import settings
from backend.models import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

0
backend/__init__.py Normal file
View File

18
backend/config.py Normal file
View File

@@ -0,0 +1,18 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://reddit:changeme@localhost:5432/reddit_monitor"
reddit_user_agent: str = "reddit-monitor:v1.0"
seed_subreddits: str = ""
digest_hour_utc: int = 23
ai_summary_enabled: bool = False
@property
def database_url_sync(self) -> str:
return self.database_url.replace("+asyncpg", "+psycopg2")
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()

18
backend/database.py Normal file
View File

@@ -0,0 +1,18 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
from collections.abc import AsyncGenerator
from backend.config import settings
engine = create_async_engine(
settings.database_url,
pool_size=5,
max_overflow=10,
pool_recycle=3600,
)
async_session = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
yield session

39
backend/main.py Normal file
View File

@@ -0,0 +1,39 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from backend.database import engine
from backend.routers import health, subreddits, posts, comments, authors, analytics, digests, summaries
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await engine.dispose()
app = FastAPI(title="Reddit Monitor", lifespan=lifespan)
# API routes
app.include_router(health.router, prefix="/api/v1")
app.include_router(subreddits.router, prefix="/api/v1")
app.include_router(posts.router, prefix="/api/v1")
app.include_router(comments.router, prefix="/api/v1")
app.include_router(authors.router, prefix="/api/v1")
app.include_router(analytics.router, prefix="/api/v1")
app.include_router(digests.router, prefix="/api/v1")
app.include_router(summaries.router, prefix="/api/v1")
# SPA static file serving (only when frontend is built)
import os
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
if os.path.isdir(static_dir):
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
return FileResponse(os.path.join(static_dir, "index.html"))

View File

@@ -0,0 +1,19 @@
from backend.models.base import Base
from backend.models.subreddit import MonitoredSubreddit
from backend.models.author import Author
from backend.models.post import Post
from backend.models.comment import Comment
from backend.models.metric_snapshot import MetricSnapshot
from backend.models.daily_digest import DailyDigest
from backend.models.summary import Summary
__all__ = [
"Base",
"MonitoredSubreddit",
"Author",
"Post",
"Comment",
"MetricSnapshot",
"DailyDigest",
"Summary",
]

23
backend/models/author.py Normal file
View File

@@ -0,0 +1,23 @@
from datetime import datetime, timezone
from sqlalchemy import String, Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.models.base import Base
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
total_posts: Mapped[int] = mapped_column(Integer, default=0)
total_comments: Mapped[int] = mapped_column(Integer, default=0)
posts: Mapped[list["Post"]] = relationship(back_populates="author") # noqa: F821
comments: Mapped[list["Comment"]] = relationship(back_populates="author") # noqa: F821

5
backend/models/base.py Normal file
View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

34
backend/models/comment.py Normal file
View File

@@ -0,0 +1,34 @@
from datetime import datetime, timezone
from sqlalchemy import String, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.models.base import Base
class Comment(Base):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(primary_key=True)
reddit_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"), nullable=False, index=True)
parent_comment_id: Mapped[int | None] = mapped_column(
ForeignKey("comments.id"), index=True
)
author_id: Mapped[int | None] = mapped_column(ForeignKey("authors.id"), index=True)
body: Mapped[str] = mapped_column(nullable=False)
score: Mapped[int] = mapped_column(Integer, default=0)
created_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
collected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
post: Mapped["Post"] = relationship(back_populates="comments") # noqa: F821
author: Mapped["Author | None"] = relationship(back_populates="comments") # noqa: F821
parent_comment: Mapped["Comment | None"] = relationship(
remote_side="Comment.id", foreign_keys=[parent_comment_id]
)

View File

@@ -0,0 +1,22 @@
from datetime import date, datetime, timezone
from sqlalchemy import Date, DateTime, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.models.base import Base
class DailyDigest(Base):
__tablename__ = "daily_digests"
id: Mapped[int] = mapped_column(primary_key=True)
subreddit_id: Mapped[int] = mapped_column(
ForeignKey("monitored_subreddits.id"), nullable=False
)
digest_date: Mapped[date] = mapped_column(Date, nullable=False)
content: Mapped[str] = mapped_column(nullable=False)
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON)
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
subreddit: Mapped["MonitoredSubreddit"] = relationship(back_populates="daily_digests") # noqa: F821

View File

@@ -0,0 +1,23 @@
from datetime import datetime, timezone
from sqlalchemy import Integer, Float, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.models.base import Base
class MetricSnapshot(Base):
__tablename__ = "metric_snapshots"
__table_args__ = (
Index("ix_metric_snapshots_post_snapshot", "post_id", "snapshot_at"),
)
id: Mapped[int] = mapped_column(primary_key=True)
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"), nullable=False)
score: Mapped[int] = mapped_column(Integer, nullable=False)
num_comments: Mapped[int] = mapped_column(Integer, nullable=False)
upvote_ratio: Mapped[float | None] = mapped_column(Float)
snapshot_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
post: Mapped["Post"] = relationship(back_populates="metric_snapshots") # noqa: F821

42
backend/models/post.py Normal file
View File

@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, Integer, Float, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.models.base import Base
class Post(Base):
__tablename__ = "posts"
__table_args__ = (
Index("ix_posts_subreddit_created", "subreddit_id", "created_utc"),
)
id: Mapped[int] = mapped_column(primary_key=True)
reddit_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
subreddit_id: Mapped[int] = mapped_column(ForeignKey("monitored_subreddits.id"), index=True)
author_id: Mapped[int | None] = mapped_column(ForeignKey("authors.id"), index=True)
title: Mapped[str] = mapped_column(nullable=False)
selftext: Mapped[str | None] = mapped_column()
url: Mapped[str | None] = mapped_column()
permalink: Mapped[str | None] = mapped_column()
flair: Mapped[str | None] = mapped_column(String(255))
score: Mapped[int] = mapped_column(Integer, default=0, index=True)
upvote_ratio: Mapped[float | None] = mapped_column(Float)
num_comments: Mapped[int] = mapped_column(Integer, default=0)
is_self: Mapped[bool | None] = mapped_column(Boolean)
over_18: Mapped[bool] = mapped_column(Boolean, default=False)
hot_rank: Mapped[int | None] = mapped_column(Integer)
created_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
collected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
subreddit: Mapped["MonitoredSubreddit"] = relationship(back_populates="posts") # noqa: F821
author: Mapped["Author | None"] = relationship(back_populates="posts") # noqa: F821
comments: Mapped[list["Comment"]] = relationship(back_populates="post") # noqa: F821
metric_snapshots: Mapped[list["MetricSnapshot"]] = relationship(back_populates="post") # noqa: F821

View File

@@ -0,0 +1,28 @@
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.models.base import Base
class MonitoredSubreddit(Base):
__tablename__ = "monitored_subreddits"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column()
subscribers: Mapped[int | None] = mapped_column(Integer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
posts: Mapped[list["Post"]] = relationship(back_populates="subreddit") # noqa: F821
daily_digests: Mapped[list["DailyDigest"]] = relationship(back_populates="subreddit") # noqa: F821
summaries: Mapped[list["Summary"]] = relationship(back_populates="subreddit") # noqa: F821

25
backend/models/summary.py Normal file
View File

@@ -0,0 +1,25 @@
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.models.base import Base
class Summary(Base):
__tablename__ = "summaries"
id: Mapped[int] = mapped_column(primary_key=True)
subreddit_id: Mapped[int] = mapped_column(
ForeignKey("monitored_subreddits.id"), nullable=False
)
summary_type: Mapped[str] = mapped_column(String(50), nullable=False)
content: Mapped[str | None] = mapped_column()
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON)
period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
provider: Mapped[str | None] = mapped_column(String(100))
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
subreddit: Mapped["MonitoredSubreddit"] = relationship(back_populates="summaries") # noqa: F821

View File

View File

@@ -0,0 +1,61 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
from backend.services import analytics_service
router = APIRouter(prefix="/analytics", tags=["analytics"])
@router.get("/engagement")
async def engagement(
subreddit_id: int | None = None,
granularity: str = Query("day", pattern="^(hour|day|week)$"),
since: datetime | None = None,
until: datetime | None = None,
db: AsyncSession = Depends(get_db),
):
return await analytics_service.get_engagement(db, subreddit_id, granularity, since, until)
@router.get("/top-posts")
async def top_posts(
subreddit_id: int | None = None,
metric: str = Query("score", pattern="^(score|num_comments)$"),
since: datetime | None = None,
until: datetime | None = None,
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
):
return await analytics_service.get_top_posts(db, subreddit_id, metric, since, until, limit)
@router.get("/top-authors")
async def top_authors(
subreddit_id: int | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
):
return await analytics_service.get_top_authors(db, subreddit_id, since, until, limit)
@router.get("/subreddit-summary")
async def subreddit_summary(
since: datetime | None = None,
until: datetime | None = None,
db: AsyncSession = Depends(get_db),
):
return await analytics_service.get_subreddit_summary(db, since, until)
@router.get("/flair-distribution")
async def flair_distribution(
subreddit_id: int = Query(...),
since: datetime | None = None,
until: datetime | None = None,
db: AsyncSession = Depends(get_db),
):
return await analytics_service.get_flair_distribution(db, subreddit_id, since, until)

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
from backend.services import author_service
router = APIRouter(prefix="/authors", tags=["authors"])
@router.get("")
async def list_authors(
subreddit_id: int | None = None,
sort_by: str = Query("total_comments", pattern="^(total_posts|total_comments)$"),
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
since: datetime | None = None,
until: datetime | None = None,
page: int = Query(1, ge=1),
per_page: int = Query(25, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
authors, total = await author_service.list_authors(
db, subreddit_id, sort_by, sort_order, since, until, page, per_page
)
return {
"data": authors,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if per_page else 0,
}
@router.get("/{author_id}")
async def get_author(author_id: int, db: AsyncSession = Depends(get_db)):
author = await author_service.get_author(db, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
return author

View File

@@ -0,0 +1,33 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
from backend.services import comment_service
router = APIRouter(prefix="/comments", tags=["comments"])
@router.get("")
async def list_comments(
post_id: int | None = None,
subreddit_id: int | None = None,
author: str | None = None,
sort_by: str = Query("created_utc", pattern="^(created_utc|score)$"),
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
since: datetime | None = None,
until: datetime | None = None,
page: int = Query(1, ge=1),
per_page: int = Query(25, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
comments, total = await comment_service.list_comments(
db, post_id, subreddit_id, author, sort_by, sort_order, since, until, page, per_page
)
return {
"data": comments,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if per_page else 0,
}

View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
from backend.models.daily_digest import DailyDigest
from backend.models.subreddit import MonitoredSubreddit
router = APIRouter(prefix="/digests", tags=["digests"])
@router.get("")
async def list_digests(
subreddit_id: int | None = None,
page: int = Query(1, ge=1),
per_page: int = Query(25, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(DailyDigest, MonitoredSubreddit.name)
.join(MonitoredSubreddit)
.order_by(DailyDigest.digest_date.desc())
)
if subreddit_id:
stmt = stmt.where(DailyDigest.subreddit_id == subreddit_id)
stmt = stmt.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(stmt)
digests = []
for digest, sub_name in result.all():
data = {c.name: getattr(digest, c.name) for c in digest.__table__.columns}
data["subreddit_name"] = sub_name
digests.append(data)
return {"data": digests, "page": page, "per_page": per_page}
@router.get("/{digest_id}")
async def get_digest(digest_id: int, db: AsyncSession = Depends(get_db)):
stmt = (
select(DailyDigest, MonitoredSubreddit.name)
.join(MonitoredSubreddit)
.where(DailyDigest.id == digest_id)
)
result = await db.execute(stmt)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="Digest not found")
digest, sub_name = row
data = {c.name: getattr(digest, c.name) for c in digest.__table__.columns}
data["subreddit_name"] = sub_name
return data

16
backend/routers/health.py Normal file
View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
router = APIRouter()
@router.get("/health")
async def health_check(db: AsyncSession = Depends(get_db)):
try:
await db.execute(text("SELECT 1"))
return {"status": "ok", "db": "connected"}
except Exception:
return {"status": "degraded", "db": "disconnected"}

64
backend/routers/posts.py Normal file
View File

@@ -0,0 +1,64 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
from backend.schemas.post import PostResponse, PostDetailResponse
from backend.services import post_service
from backend.models.metric_snapshot import MetricSnapshot
from sqlalchemy import select
router = APIRouter(prefix="/posts", tags=["posts"])
@router.get("")
async def list_posts(
subreddit_id: int | None = None,
author: str | None = None,
flair: str | None = None,
sort_by: str = Query("created_utc", pattern="^(created_utc|score|num_comments)$"),
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
since: datetime | None = None,
until: datetime | None = None,
page: int = Query(1, ge=1),
per_page: int = Query(25, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
posts, total = await post_service.list_posts(
db, subreddit_id, author, flair, sort_by, sort_order, since, until, page, per_page
)
return {
"data": posts,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if per_page else 0,
}
@router.get("/{post_id}")
async def get_post(post_id: int, db: AsyncSession = Depends(get_db)):
post = await post_service.get_post(db, post_id)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@router.get("/{post_id}/snapshots")
async def get_post_snapshots(post_id: int, db: AsyncSession = Depends(get_db)):
stmt = (
select(MetricSnapshot)
.where(MetricSnapshot.post_id == post_id)
.order_by(MetricSnapshot.snapshot_at.asc())
)
result = await db.execute(stmt)
snapshots = result.scalars().all()
return [
{
"score": s.score,
"num_comments": s.num_comments,
"upvote_ratio": s.upvote_ratio,
"snapshot_at": s.snapshot_at,
}
for s in snapshots
]

View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
from backend.schemas.subreddit import SubredditCreate, SubredditUpdate, SubredditResponse
from backend.services import subreddit_service
router = APIRouter(prefix="/subreddits", tags=["subreddits"])
@router.get("", response_model=list[SubredditResponse])
async def list_subreddits(db: AsyncSession = Depends(get_db)):
return await subreddit_service.list_subreddits(db)
@router.post("", response_model=SubredditResponse, status_code=201)
async def create_subreddit(body: SubredditCreate, db: AsyncSession = Depends(get_db)):
sub = await subreddit_service.create_subreddit(db, body.name)
data = {c.name: getattr(sub, c.name) for c in sub.__table__.columns}
data["post_count"] = 0
return data
@router.get("/{subreddit_id}", response_model=SubredditResponse)
async def get_subreddit(subreddit_id: int, db: AsyncSession = Depends(get_db)):
sub = await subreddit_service.get_subreddit(db, subreddit_id)
if not sub:
raise HTTPException(status_code=404, detail="Subreddit not found")
return sub
@router.patch("/{subreddit_id}", response_model=SubredditResponse)
async def update_subreddit(
subreddit_id: int, body: SubredditUpdate, db: AsyncSession = Depends(get_db)
):
sub = await subreddit_service.update_subreddit(db, subreddit_id, body.is_active)
if not sub:
raise HTTPException(status_code=404, detail="Subreddit not found")
result = await subreddit_service.get_subreddit(db, subreddit_id)
return result
@router.delete("/{subreddit_id}", status_code=204)
async def delete_subreddit(subreddit_id: int, db: AsyncSession = Depends(get_db)):
deleted = await subreddit_service.delete_subreddit(db, subreddit_id)
if not deleted:
raise HTTPException(status_code=404, detail="Subreddit not found")

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
router = APIRouter(prefix="/summaries", tags=["summaries"])
@router.get("")
async def list_summaries():
return {"data": [], "message": "AI summaries not yet configured"}
@router.get("/{summary_id}")
async def get_summary(summary_id: int):
return {"detail": "AI summaries not yet configured"}

View File

View File

@@ -0,0 +1,54 @@
from datetime import datetime, date
from pydantic import BaseModel
class EngagementPoint(BaseModel):
period: str
posts: int
comments: int
avg_score: float
class TopPost(BaseModel):
id: int
title: str
score: int
num_comments: int
author_name: str | None
subreddit_name: str
created_utc: datetime
permalink: str | None
class TopAuthor(BaseModel):
id: int
username: str
post_count: int
comment_count: int
total_activity: int
class SubredditSummary(BaseModel):
subreddit_id: int
subreddit_name: str
total_posts: int
total_comments: int
avg_score: float
top_flair: str | None
class FlairCount(BaseModel):
flair: str | None
count: int
class DigestResponse(BaseModel):
id: int
subreddit_id: int
subreddit_name: str | None = None
digest_date: date
content: str
metadata_: dict | None = None
generated_at: datetime
model_config = {"from_attributes": True}

13
backend/schemas/author.py Normal file
View File

@@ -0,0 +1,13 @@
from datetime import datetime
from pydantic import BaseModel
class AuthorResponse(BaseModel):
id: int
username: str
first_seen_at: datetime
last_seen_at: datetime
total_posts: int
total_comments: int
model_config = {"from_attributes": True}

19
backend/schemas/common.py Normal file
View File

@@ -0,0 +1,19 @@
from datetime import datetime
from pydantic import BaseModel
class PaginationParams(BaseModel):
page: int = 1
per_page: int = 25
class PaginatedResponse(BaseModel):
total: int
page: int
per_page: int
pages: int
class TimeRangeParams(BaseModel):
since: datetime | None = None
until: datetime | None = None

45
backend/schemas/post.py Normal file
View File

@@ -0,0 +1,45 @@
from datetime import datetime
from pydantic import BaseModel
class PostResponse(BaseModel):
id: int
reddit_id: str
subreddit_id: int
subreddit_name: str | None = None
author_id: int | None
author_name: str | None = None
title: str
selftext: str | None
url: str | None
permalink: str | None
flair: str | None
score: int
upvote_ratio: float | None
num_comments: int
is_self: bool | None
over_18: bool
hot_rank: int | None
created_utc: datetime
collected_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class PostDetailResponse(PostResponse):
comments: list["CommentResponse"] = []
class CommentResponse(BaseModel):
id: int
reddit_id: str
post_id: int
parent_comment_id: int | None
author_id: int | None
author_name: str | None = None
body: str
score: int
created_utc: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,24 @@
from datetime import datetime
from pydantic import BaseModel
class SubredditCreate(BaseModel):
name: str
class SubredditUpdate(BaseModel):
is_active: bool | None = None
class SubredditResponse(BaseModel):
id: int
name: str
display_name: str | None
description: str | None
subscribers: int | None
is_active: bool
created_at: datetime
updated_at: datetime
post_count: int = 0
model_config = {"from_attributes": True}

View File

View File

@@ -0,0 +1,231 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, func, case, text
from sqlalchemy.ext.asyncio import AsyncSession
from backend.models.post import Post
from backend.models.comment import Comment
from backend.models.author import Author
from backend.models.subreddit import MonitoredSubreddit
async def get_engagement(
db: AsyncSession,
subreddit_id: int | None = None,
granularity: str = "day",
since: datetime | None = None,
until: datetime | None = None,
) -> list[dict]:
if not since:
since = datetime.now(timezone.utc) - timedelta(days=30)
if not until:
until = datetime.now(timezone.utc)
trunc = func.date_trunc(granularity, Post.created_utc)
stmt = select(
trunc.label("period"),
func.count(Post.id).label("posts"),
func.coalesce(func.avg(Post.score), 0).label("avg_score"),
).where(Post.created_utc >= since, Post.created_utc <= until)
if subreddit_id:
stmt = stmt.where(Post.subreddit_id == subreddit_id)
stmt = stmt.group_by("period").order_by("period")
result = await db.execute(stmt)
# Get comment counts per period
comment_trunc = func.date_trunc(granularity, Comment.created_utc)
comment_stmt = (
select(
comment_trunc.label("period"),
func.count(Comment.id).label("comments"),
)
.join(Post)
.where(Comment.created_utc >= since, Comment.created_utc <= until)
)
if subreddit_id:
comment_stmt = comment_stmt.where(Post.subreddit_id == subreddit_id)
comment_stmt = comment_stmt.group_by("period")
comment_result = await db.execute(comment_stmt)
comment_map = {str(r.period): r.comments for r in comment_result}
return [
{
"period": str(r.period),
"posts": r.posts,
"comments": comment_map.get(str(r.period), 0),
"avg_score": round(float(r.avg_score), 1),
}
for r in result
]
async def get_top_posts(
db: AsyncSession,
subreddit_id: int | None = None,
metric: str = "score",
since: datetime | None = None,
until: datetime | None = None,
limit: int = 10,
) -> list[dict]:
if not since:
since = datetime.now(timezone.utc) - timedelta(days=7)
stmt = (
select(Post, MonitoredSubreddit.name, Author.username)
.join(MonitoredSubreddit)
.outerjoin(Author)
.where(Post.created_utc >= since)
)
if until:
stmt = stmt.where(Post.created_utc <= until)
if subreddit_id:
stmt = stmt.where(Post.subreddit_id == subreddit_id)
sort_col = Post.score if metric == "score" else Post.num_comments
stmt = stmt.order_by(sort_col.desc()).limit(limit)
result = await db.execute(stmt)
return [
{
"id": post.id,
"title": post.title,
"score": post.score,
"num_comments": post.num_comments,
"author_name": author_name,
"subreddit_name": sub_name,
"created_utc": post.created_utc,
"permalink": post.permalink,
}
for post, sub_name, author_name in result.all()
]
async def get_top_authors(
db: AsyncSession,
subreddit_id: int | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 10,
) -> list[dict]:
if not since:
since = datetime.now(timezone.utc) - timedelta(days=7)
post_count = (
select(func.count(Post.id))
.where(Post.author_id == Author.id, Post.created_utc >= since)
)
comment_count = (
select(func.count(Comment.id))
.where(Comment.author_id == Author.id, Comment.created_utc >= since)
)
if until:
post_count = post_count.where(Post.created_utc <= until)
comment_count = comment_count.where(Comment.created_utc <= until)
if subreddit_id:
post_count = post_count.where(Post.subreddit_id == subreddit_id)
comment_count = comment_count.join(Post).where(Post.subreddit_id == subreddit_id)
pc = post_count.correlate(Author).scalar_subquery().label("post_count")
cc = comment_count.correlate(Author).scalar_subquery().label("comment_count")
stmt = (
select(Author, pc, cc)
.order_by((pc + cc).desc())
.limit(limit)
)
result = await db.execute(stmt)
return [
{
"id": author.id,
"username": author.username,
"post_count": pc or 0,
"comment_count": cc or 0,
"total_activity": (pc or 0) + (cc or 0),
}
for author, pc, cc in result.all()
]
async def get_subreddit_summary(
db: AsyncSession,
since: datetime | None = None,
until: datetime | None = None,
) -> list[dict]:
if not since:
since = datetime.now(timezone.utc) - timedelta(days=7)
stmt = (
select(
MonitoredSubreddit.id,
MonitoredSubreddit.name,
func.count(Post.id).label("total_posts"),
func.coalesce(func.avg(Post.score), 0).label("avg_score"),
)
.outerjoin(Post, (Post.subreddit_id == MonitoredSubreddit.id) & (Post.created_utc >= since))
.where(MonitoredSubreddit.is_active == True) # noqa: E712
.group_by(MonitoredSubreddit.id)
.order_by(MonitoredSubreddit.name)
)
if until:
stmt = stmt.where(Post.created_utc <= until)
result = await db.execute(stmt)
summaries = []
for sub_id, sub_name, total_posts, avg_score in result.all():
# Get comment count
cc = await db.execute(
select(func.count(Comment.id))
.join(Post)
.where(Post.subreddit_id == sub_id, Comment.created_utc >= since)
)
comment_count = cc.scalar() or 0
# Top flair
flair_stmt = (
select(Post.flair, func.count(Post.id).label("cnt"))
.where(Post.subreddit_id == sub_id, Post.created_utc >= since, Post.flair.isnot(None))
.group_by(Post.flair)
.order_by(func.count(Post.id).desc())
.limit(1)
)
flair_result = await db.execute(flair_stmt)
top_flair_row = flair_result.first()
summaries.append({
"subreddit_id": sub_id,
"subreddit_name": sub_name,
"total_posts": total_posts,
"total_comments": comment_count,
"avg_score": round(float(avg_score), 1),
"top_flair": top_flair_row[0] if top_flair_row else None,
})
return summaries
async def get_flair_distribution(
db: AsyncSession,
subreddit_id: int,
since: datetime | None = None,
until: datetime | None = None,
) -> list[dict]:
if not since:
since = datetime.now(timezone.utc) - timedelta(days=30)
stmt = (
select(Post.flair, func.count(Post.id).label("count"))
.where(Post.subreddit_id == subreddit_id, Post.created_utc >= since)
.group_by(Post.flair)
.order_by(func.count(Post.id).desc())
)
if until:
stmt = stmt.where(Post.created_utc <= until)
result = await db.execute(stmt)
return [{"flair": flair, "count": count} for flair, count in result.all()]

View File

@@ -0,0 +1,79 @@
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.models.author import Author
from backend.models.post import Post
from backend.models.comment import Comment
async def list_authors(
db: AsyncSession,
subreddit_id: int | None = None,
sort_by: str = "total_comments",
sort_order: str = "desc",
since: datetime | None = None,
until: datetime | None = None,
page: int = 1,
per_page: int = 25,
) -> tuple[list[dict], int]:
base = select(Author)
if subreddit_id or since or until:
# Need to compute activity counts with filters
post_count = (
select(func.count(Post.id))
.where(Post.author_id == Author.id)
)
comment_count = (
select(func.count(Comment.id))
.where(Comment.author_id == Author.id)
)
if subreddit_id:
post_count = post_count.where(Post.subreddit_id == subreddit_id)
comment_count = comment_count.join(Post).where(Post.subreddit_id == subreddit_id)
if since:
post_count = post_count.where(Post.created_utc >= since)
comment_count = comment_count.where(Comment.created_utc >= since)
if until:
post_count = post_count.where(Post.created_utc <= until)
comment_count = comment_count.where(Comment.created_utc <= until)
base = select(
Author,
post_count.correlate(Author).scalar_subquery().label("filtered_posts"),
comment_count.correlate(Author).scalar_subquery().label("filtered_comments"),
)
else:
base = select(Author)
count_stmt = select(func.count()).select_from(base.subquery())
total = (await db.execute(count_stmt)).scalar() or 0
sort_col = getattr(Author, sort_by, Author.total_comments)
if sort_order == "asc":
base = base.order_by(sort_col.asc())
else:
base = base.order_by(sort_col.desc())
base = base.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(base)
authors = []
for row in result.all():
if isinstance(row, tuple):
author = row[0]
else:
author = row
data = {c.name: getattr(author, c.name) for c in author.__table__.columns}
authors.append(data)
return authors, total
async def get_author(db: AsyncSession, author_id: int) -> dict | None:
author = await db.get(Author, author_id)
if not author:
return None
return {c.name: getattr(author, c.name) for c in author.__table__.columns}

View File

@@ -0,0 +1,57 @@
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.models.comment import Comment
from backend.models.post import Post
from backend.models.author import Author
async def list_comments(
db: AsyncSession,
post_id: int | None = None,
subreddit_id: int | None = None,
author: str | None = None,
sort_by: str = "created_utc",
sort_order: str = "desc",
since: datetime | None = None,
until: datetime | None = None,
page: int = 1,
per_page: int = 25,
) -> tuple[list[dict], int]:
base = select(Comment, Author.username).outerjoin(Author).join(Post)
filters = []
if post_id:
filters.append(Comment.post_id == post_id)
if subreddit_id:
filters.append(Post.subreddit_id == subreddit_id)
if author:
filters.append(Author.username == author)
if since:
filters.append(Comment.created_utc >= since)
if until:
filters.append(Comment.created_utc <= until)
if filters:
base = base.where(*filters)
count_stmt = select(func.count()).select_from(base.subquery())
total = (await db.execute(count_stmt)).scalar() or 0
sort_col = getattr(Comment, sort_by, Comment.created_utc)
if sort_order == "asc":
base = base.order_by(sort_col.asc())
else:
base = base.order_by(sort_col.desc())
base = base.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(base)
comments = []
for comment, author_name in result.all():
data = {c.name: getattr(comment, c.name) for c in comment.__table__.columns}
data["author_name"] = author_name
comments.append(data)
return comments, total

View File

@@ -0,0 +1,102 @@
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from backend.models.post import Post
from backend.models.subreddit import MonitoredSubreddit
from backend.models.author import Author
from backend.models.comment import Comment
async def list_posts(
db: AsyncSession,
subreddit_id: int | None = None,
author: str | None = None,
flair: str | None = None,
sort_by: str = "created_utc",
sort_order: str = "desc",
since: datetime | None = None,
until: datetime | None = None,
page: int = 1,
per_page: int = 25,
) -> tuple[list[dict], int]:
base = select(Post, MonitoredSubreddit.name, Author.username).join(
MonitoredSubreddit
).outerjoin(Author)
filters = []
if subreddit_id:
filters.append(Post.subreddit_id == subreddit_id)
if flair:
filters.append(Post.flair == flair)
if since:
filters.append(Post.created_utc >= since)
if until:
filters.append(Post.created_utc <= until)
if author:
filters.append(Author.username == author)
if filters:
base = base.where(*filters)
# Count
count_stmt = select(func.count()).select_from(base.subquery())
total = (await db.execute(count_stmt)).scalar() or 0
# Sort
sort_col = getattr(Post, sort_by, Post.created_utc)
if sort_order == "asc":
base = base.order_by(sort_col.asc())
else:
base = base.order_by(sort_col.desc())
# Paginate
base = base.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(base)
rows = result.all()
posts = []
for post, sub_name, author_name in rows:
data = {c.name: getattr(post, c.name) for c in post.__table__.columns}
data["subreddit_name"] = sub_name
data["author_name"] = author_name
posts.append(data)
return posts, total
async def get_post(db: AsyncSession, post_id: int) -> dict | None:
stmt = (
select(Post, MonitoredSubreddit.name, Author.username)
.join(MonitoredSubreddit)
.outerjoin(Author)
.where(Post.id == post_id)
)
result = await db.execute(stmt)
row = result.first()
if not row:
return None
post, sub_name, author_name = row
data = {c.name: getattr(post, c.name) for c in post.__table__.columns}
data["subreddit_name"] = sub_name
data["author_name"] = author_name
# Get comments
comment_stmt = (
select(Comment, Author.username)
.outerjoin(Author)
.where(Comment.post_id == post_id)
.order_by(Comment.created_utc.asc())
)
comment_result = await db.execute(comment_stmt)
comments = []
for comment, c_author in comment_result.all():
c_data = {c.name: getattr(comment, c.name) for c in comment.__table__.columns}
c_data["author_name"] = c_author
comments.append(c_data)
data["comments"] = comments
return data

View File

@@ -0,0 +1,75 @@
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.models.subreddit import MonitoredSubreddit
from backend.models.post import Post
async def list_subreddits(db: AsyncSession) -> list[dict]:
stmt = (
select(
MonitoredSubreddit,
func.count(Post.id).label("post_count"),
)
.outerjoin(Post, Post.subreddit_id == MonitoredSubreddit.id)
.group_by(MonitoredSubreddit.id)
.order_by(MonitoredSubreddit.name)
)
result = await db.execute(stmt)
rows = result.all()
out = []
for sub, post_count in rows:
data = {c.name: getattr(sub, c.name) for c in sub.__table__.columns}
data["post_count"] = post_count
out.append(data)
return out
async def get_subreddit(db: AsyncSession, subreddit_id: int) -> dict | None:
stmt = (
select(
MonitoredSubreddit,
func.count(Post.id).label("post_count"),
)
.outerjoin(Post, Post.subreddit_id == MonitoredSubreddit.id)
.where(MonitoredSubreddit.id == subreddit_id)
.group_by(MonitoredSubreddit.id)
)
result = await db.execute(stmt)
row = result.first()
if not row:
return None
sub, post_count = row
data = {c.name: getattr(sub, c.name) for c in sub.__table__.columns}
data["post_count"] = post_count
return data
async def create_subreddit(db: AsyncSession, name: str) -> MonitoredSubreddit:
sub = MonitoredSubreddit(name=name.lower().strip())
db.add(sub)
await db.commit()
await db.refresh(sub)
return sub
async def update_subreddit(
db: AsyncSession, subreddit_id: int, is_active: bool | None = None
) -> MonitoredSubreddit | None:
sub = await db.get(MonitoredSubreddit, subreddit_id)
if not sub:
return None
if is_active is not None:
sub.is_active = is_active
await db.commit()
await db.refresh(sub)
return sub
async def delete_subreddit(db: AsyncSession, subreddit_id: int) -> bool:
sub = await db.get(MonitoredSubreddit, subreddit_id)
if not sub:
return False
sub.is_active = False
await db.commit()
return True

View File

View File

View File

@@ -0,0 +1,140 @@
import logging
from datetime import datetime, timezone, timedelta, date
from sqlalchemy import select, func, create_engine
from sqlalchemy.orm import sessionmaker
from backend.config import settings
from backend.models.subreddit import MonitoredSubreddit
from backend.models.post import Post
from backend.models.comment import Comment
from backend.models.author import Author
from backend.models.daily_digest import DailyDigest
logger = logging.getLogger(__name__)
_engine = create_engine(settings.database_url_sync, pool_size=2, pool_recycle=3600)
SyncSession = sessionmaker(_engine)
def generate_daily_digests():
"""Generate daily digest for each active subreddit."""
yesterday = date.today() - timedelta(days=1)
day_start = datetime(yesterday.year, yesterday.month, yesterday.day, tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
with SyncSession() as db:
subs = db.execute(
select(MonitoredSubreddit).where(MonitoredSubreddit.is_active == True) # noqa: E712
).scalars().all()
for sub in subs:
# Check if digest already exists
existing = db.execute(
select(DailyDigest).where(
DailyDigest.subreddit_id == sub.id,
DailyDigest.digest_date == yesterday,
)
).scalar_one_or_none()
if existing:
continue
# Gather stats
post_count = db.execute(
select(func.count(Post.id)).where(
Post.subreddit_id == sub.id,
Post.created_utc >= day_start,
Post.created_utc < day_end,
)
).scalar() or 0
comment_count = db.execute(
select(func.count(Comment.id))
.join(Post)
.where(
Post.subreddit_id == sub.id,
Comment.created_utc >= day_start,
Comment.created_utc < day_end,
)
).scalar() or 0
# Top posts by score
top_posts = db.execute(
select(Post.title, Post.score, Post.num_comments, Post.permalink)
.where(
Post.subreddit_id == sub.id,
Post.created_utc >= day_start,
Post.created_utc < day_end,
)
.order_by(Post.score.desc())
.limit(5)
).all()
# Top authors
top_authors = db.execute(
select(Author.username, func.count(Comment.id).label("cnt"))
.join(Comment, Comment.author_id == Author.id)
.join(Post, Comment.post_id == Post.id)
.where(
Post.subreddit_id == sub.id,
Comment.created_utc >= day_start,
Comment.created_utc < day_end,
)
.group_by(Author.username)
.order_by(func.count(Comment.id).desc())
.limit(5)
).all()
avg_score = db.execute(
select(func.avg(Post.score)).where(
Post.subreddit_id == sub.id,
Post.created_utc >= day_start,
Post.created_utc < day_end,
)
).scalar()
# Build markdown digest
lines = [
f"# r/{sub.name} — Daily Digest for {yesterday}",
"",
f"**Posts:** {post_count} | **Comments:** {comment_count} | **Avg Score:** {avg_score:.1f}" if avg_score else f"**Posts:** {post_count} | **Comments:** {comment_count}",
"",
]
if top_posts:
lines.append("## Top Posts")
for i, (title, score, num_comments, permalink) in enumerate(top_posts, 1):
lines.append(f"{i}. **{title}** — {score} pts, {num_comments} comments")
lines.append("")
if top_authors:
lines.append("## Most Active Users")
for username, cnt in top_authors:
lines.append(f"- u/{username}: {cnt} comments")
lines.append("")
content = "\n".join(lines)
metadata = {
"post_count": post_count,
"comment_count": comment_count,
"avg_score": float(avg_score) if avg_score else 0,
"top_posts": [
{"title": t, "score": s, "num_comments": n}
for t, s, n, _ in top_posts
],
"top_authors": [
{"username": u, "comment_count": c}
for u, c in top_authors
],
}
digest = DailyDigest(
subreddit_id=sub.id,
digest_date=yesterday,
content=content,
metadata_=metadata,
)
db.add(digest)
db.commit()
logger.info(f"Generated daily digest for r/{sub.name} on {yesterday}")

90
backend/worker/main.py Normal file
View File

@@ -0,0 +1,90 @@
import logging
import signal
import sys
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from backend.config import settings
from backend.worker.monitor import poll_new_posts, poll_hot_posts, collect_comments, update_scores
from backend.worker.snapshot import take_metric_snapshots
from backend.worker.digest_job import generate_daily_digests
from backend.worker.summary_job import generate_summaries
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
logger = logging.getLogger(__name__)
def seed_subreddits():
"""Add seed subreddits on first startup if configured."""
if not settings.seed_subreddits:
return
from sqlalchemy import select, create_engine
from sqlalchemy.orm import sessionmaker
from backend.models.subreddit import MonitoredSubreddit
engine = create_engine(settings.database_url_sync)
Session = sessionmaker(engine)
names = [s.strip().lower() for s in settings.seed_subreddits.split(",") if s.strip()]
with Session() as db:
for name in names:
existing = db.execute(
select(MonitoredSubreddit).where(MonitoredSubreddit.name == name)
).scalar_one_or_none()
if not existing:
db.add(MonitoredSubreddit(name=name))
logger.info(f"Seeded subreddit: r/{name}")
db.commit()
engine.dispose()
def main():
logger.info("Starting Reddit monitor worker")
seed_subreddits()
scheduler = BlockingScheduler()
# Reddit polling jobs
scheduler.add_job(poll_new_posts, IntervalTrigger(minutes=2), id="poll_new", max_instances=1)
scheduler.add_job(poll_hot_posts, IntervalTrigger(minutes=2), id="poll_hot", max_instances=1)
scheduler.add_job(collect_comments, IntervalTrigger(minutes=5), id="comments", max_instances=1)
scheduler.add_job(update_scores, IntervalTrigger(minutes=15), id="scores", max_instances=1)
# Metric snapshots
scheduler.add_job(take_metric_snapshots, IntervalTrigger(minutes=30), id="snapshots", max_instances=1)
# Daily digest
scheduler.add_job(
generate_daily_digests,
CronTrigger(hour=settings.digest_hour_utc, minute=0),
id="digest",
max_instances=1,
)
# AI summary stub
scheduler.add_job(
generate_summaries,
CronTrigger(hour=settings.digest_hour_utc, minute=30),
id="summary",
max_instances=1,
)
def shutdown(signum, frame):
logger.info("Shutting down worker...")
scheduler.shutdown(wait=False)
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
logger.info("Worker started. Scheduled jobs are running.")
scheduler.start()
if __name__ == "__main__":
main()

295
backend/worker/monitor.py Normal file
View File

@@ -0,0 +1,295 @@
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.dialects.postgresql import insert
from backend.config import settings
from backend.models.subreddit import MonitoredSubreddit
from backend.models.author import Author
from backend.models.post import Post
from backend.models.comment import Comment
from backend.worker.reddit_client import create_client, fetch_json
logger = logging.getLogger(__name__)
# Sync engine for worker (PRAW-replacement uses async httpx, but DB writes are sync for simplicity with APScheduler)
_engine = create_engine(settings.database_url_sync, pool_size=3, max_overflow=5, pool_recycle=3600)
SyncSession = sessionmaker(_engine)
def _get_active_subreddits() -> list[dict]:
with SyncSession() as db:
stmt = select(MonitoredSubreddit).where(MonitoredSubreddit.is_active == True) # noqa: E712
result = db.execute(stmt)
return [{"id": s.id, "name": s.name} for s in result.scalars()]
def _upsert_author(db: Session, username: str) -> int | None:
if not username or username == "[deleted]":
return None
now = datetime.now(timezone.utc)
stmt = insert(Author).values(username=username, first_seen_at=now, last_seen_at=now)
stmt = stmt.on_conflict_do_update(
index_elements=[Author.username],
set_={"last_seen_at": now},
)
db.execute(stmt)
result = db.execute(select(Author.id).where(Author.username == username))
row = result.first()
return row[0] if row else None
def _parse_post(post_data: dict, subreddit_id: int, db: Session, hot_rank: int | None = None) -> dict:
data = post_data.get("data", post_data)
author_id = _upsert_author(db, data.get("author"))
created = datetime.fromtimestamp(data.get("created_utc", 0), tz=timezone.utc)
return {
"reddit_id": data.get("name", f"t3_{data.get('id', '')}"),
"subreddit_id": subreddit_id,
"author_id": author_id,
"title": data.get("title", ""),
"selftext": data.get("selftext"),
"url": data.get("url"),
"permalink": data.get("permalink"),
"flair": data.get("link_flair_text"),
"score": data.get("score", 0),
"upvote_ratio": data.get("upvote_ratio"),
"num_comments": data.get("num_comments", 0),
"is_self": data.get("is_self"),
"over_18": data.get("over_18", False),
"hot_rank": hot_rank,
"created_utc": created,
"collected_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
def _upsert_posts(db: Session, posts: list[dict], update_hot_rank: bool = False):
if not posts:
return
update_set = {
"score": insert(Post).excluded.score,
"upvote_ratio": insert(Post).excluded.upvote_ratio,
"num_comments": insert(Post).excluded.num_comments,
"updated_at": insert(Post).excluded.updated_at,
}
if update_hot_rank:
update_set["hot_rank"] = insert(Post).excluded.hot_rank
stmt = insert(Post).values(posts)
stmt = stmt.on_conflict_do_update(
index_elements=[Post.reddit_id],
set_=update_set,
)
db.execute(stmt)
def _parse_comment(comment_data: dict, post_id: int, db: Session, parent_map: dict) -> dict | None:
data = comment_data.get("data", comment_data)
if data.get("kind") == "more" or not data.get("body"):
return None
reddit_id = data.get("name", f"t1_{data.get('id', '')}")
author_id = _upsert_author(db, data.get("author"))
created = datetime.fromtimestamp(data.get("created_utc", 0), tz=timezone.utc)
parent_reddit_id = data.get("parent_id", "")
parent_comment_id = parent_map.get(parent_reddit_id)
return {
"reddit_id": reddit_id,
"post_id": post_id,
"parent_comment_id": parent_comment_id,
"author_id": author_id,
"body": data.get("body", ""),
"score": data.get("score", 0),
"created_utc": created,
"collected_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
import asyncio
def poll_new_posts():
"""Fetch /new for each active subreddit and upsert posts."""
asyncio.run(_poll_new_posts_async())
async def _poll_new_posts_async():
subreddits = _get_active_subreddits()
if not subreddits:
return
client = create_client()
async with client:
for sub in subreddits:
data = await fetch_json(client, f"/r/{sub['name']}/new", {"limit": "100"})
if not data:
continue
children = data.get("data", {}).get("children", [])
if not children:
continue
with SyncSession() as db:
posts = [_parse_post(child, sub["id"], db) for child in children]
_upsert_posts(db, posts)
db.commit()
logger.info(f"r/{sub['name']}: upserted {len(children)} new posts")
def poll_hot_posts():
"""Fetch /hot for each active subreddit and update hot_rank."""
asyncio.run(_poll_hot_posts_async())
async def _poll_hot_posts_async():
subreddits = _get_active_subreddits()
if not subreddits:
return
client = create_client()
async with client:
for sub in subreddits:
data = await fetch_json(client, f"/r/{sub['name']}/hot", {"limit": "100"})
if not data:
continue
children = data.get("data", {}).get("children", [])
if not children:
continue
with SyncSession() as db:
posts = [
_parse_post(child, sub["id"], db, hot_rank=i + 1)
for i, child in enumerate(children)
]
_upsert_posts(db, posts, update_hot_rank=True)
db.commit()
logger.info(f"r/{sub['name']}: updated hot ranks for {len(children)} posts")
def collect_comments():
"""Fetch comments for recent posts."""
asyncio.run(_collect_comments_async())
async def _collect_comments_async():
cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
with SyncSession() as db:
stmt = (
select(Post.id, Post.reddit_id, Post.subreddit_id)
.join(MonitoredSubreddit)
.where(
MonitoredSubreddit.is_active == True, # noqa: E712
Post.created_utc >= cutoff,
)
.order_by(Post.created_utc.desc())
.limit(50)
)
result = db.execute(stmt)
recent_posts = [{"id": r[0], "reddit_id": r[1], "subreddit_id": r[2]} for r in result]
if not recent_posts:
return
client = create_client()
async with client:
for post in recent_posts:
short_id = post["reddit_id"].replace("t3_", "")
data = await fetch_json(client, f"/comments/{short_id}", {"limit": "500", "sort": "new"})
if not data or len(data) < 2:
continue
comment_listing = data[1].get("data", {}).get("children", [])
with SyncSession() as db:
# Build parent_map from existing comments
existing = db.execute(
select(Comment.id, Comment.reddit_id).where(Comment.post_id == post["id"])
)
parent_map = {r[1]: r[0] for r in existing}
comments_to_upsert = []
def process_comments(children):
for child in children:
if child.get("kind") == "more":
continue
c_data = child.get("data", {})
parsed = _parse_comment(c_data, post["id"], db, parent_map)
if parsed:
comments_to_upsert.append(parsed)
# Process replies recursively
replies = c_data.get("replies")
if isinstance(replies, dict):
reply_children = replies.get("data", {}).get("children", [])
process_comments(reply_children)
process_comments(comment_listing)
if comments_to_upsert:
# Upsert comments one at a time to handle parent references
for comment in comments_to_upsert:
stmt = insert(Comment).values(comment)
stmt = stmt.on_conflict_do_update(
index_elements=[Comment.reddit_id],
set_={
"score": stmt.excluded.score,
"body": stmt.excluded.body,
"updated_at": stmt.excluded.updated_at,
},
)
db.execute(stmt)
db.commit()
logger.info(f"Post {short_id}: upserted {len(comments_to_upsert)} comments")
def update_scores():
"""Re-fetch recent posts to update scores and comment counts."""
asyncio.run(_update_scores_async())
async def _update_scores_async():
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
with SyncSession() as db:
stmt = (
select(Post.reddit_id, Post.subreddit_id, MonitoredSubreddit.name)
.join(MonitoredSubreddit)
.where(
MonitoredSubreddit.is_active == True, # noqa: E712
Post.created_utc >= cutoff,
)
)
result = db.execute(stmt)
posts_by_sub: dict[str, list[str]] = {}
for reddit_id, _, sub_name in result:
posts_by_sub.setdefault(sub_name, []).append(reddit_id)
if not posts_by_sub:
return
# Score updates piggyback on the new/hot polls — the upsert already updates scores.
# This job explicitly re-fetches to catch score changes on older posts.
client = create_client()
async with client:
for sub_name, reddit_ids in posts_by_sub.items():
data = await fetch_json(client, f"/r/{sub_name}/new", {"limit": "100"})
if not data:
continue
children = data.get("data", {}).get("children", [])
with SyncSession() as db:
sub = db.execute(
select(MonitoredSubreddit).where(MonitoredSubreddit.name == sub_name)
).scalar_one_or_none()
if not sub:
continue
posts = [_parse_post(child, sub.id, db) for child in children]
_upsert_posts(db, posts)
db.commit()
logger.info(f"Score update complete for {len(posts_by_sub)} subreddits")

View File

@@ -0,0 +1,58 @@
import asyncio
import logging
import time
import httpx
from backend.config import settings
logger = logging.getLogger(__name__)
BASE_URL = "https://www.reddit.com"
# Simple in-process rate limiter: track request timestamps
_request_times: list[float] = []
MAX_REQUESTS_PER_MINUTE = 9 # Stay under Reddit's ~10/min limit
async def _wait_for_rate_limit():
"""Block until we have budget for another request."""
now = time.monotonic()
# Remove timestamps older than 60 seconds
while _request_times and _request_times[0] < now - 60:
_request_times.pop(0)
if len(_request_times) >= MAX_REQUESTS_PER_MINUTE:
wait = 60 - (now - _request_times[0]) + 0.5
logger.info(f"Rate limit: waiting {wait:.1f}s")
await asyncio.sleep(wait)
_request_times.append(time.monotonic())
async def fetch_json(client: httpx.AsyncClient, path: str, params: dict | None = None) -> dict | None:
"""Fetch a Reddit .json endpoint with rate limiting and error handling."""
await _wait_for_rate_limit()
url = f"{BASE_URL}{path}.json"
try:
response = await client.get(url, params=params)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
logger.warning(f"Rate limited, waiting {retry_after}s")
await asyncio.sleep(retry_after)
return await fetch_json(client, path, params)
if response.status_code >= 500:
logger.warning(f"Reddit returned {response.status_code} for {path}")
return None
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"HTTP error fetching {path}: {e}")
return None
def create_client() -> httpx.AsyncClient:
"""Create an httpx client configured for Reddit."""
return httpx.AsyncClient(
headers={"User-Agent": settings.reddit_user_agent},
timeout=30.0,
follow_redirects=True,
)

View File

@@ -0,0 +1,47 @@
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, create_engine
from sqlalchemy.orm import sessionmaker
from backend.config import settings
from backend.models.post import Post
from backend.models.metric_snapshot import MetricSnapshot
from backend.models.subreddit import MonitoredSubreddit
logger = logging.getLogger(__name__)
_engine = create_engine(settings.database_url_sync, pool_size=2, pool_recycle=3600)
SyncSession = sessionmaker(_engine)
def take_metric_snapshots():
"""Snapshot current metrics for recent posts."""
now = datetime.now(timezone.utc)
with SyncSession() as db:
# Posts < 48h old: snapshot every run (every 30 min)
cutoff_recent = now - timedelta(hours=48)
stmt = (
select(Post.id, Post.score, Post.num_comments, Post.upvote_ratio)
.join(MonitoredSubreddit)
.where(
MonitoredSubreddit.is_active == True, # noqa: E712
Post.created_utc >= cutoff_recent,
)
)
result = db.execute(stmt)
snapshots = []
for post_id, score, num_comments, upvote_ratio in result:
snapshots.append(MetricSnapshot(
post_id=post_id,
score=score,
num_comments=num_comments,
upvote_ratio=upvote_ratio,
snapshot_at=now,
))
if snapshots:
db.add_all(snapshots)
db.commit()
logger.info(f"Took {len(snapshots)} metric snapshots")

View File

@@ -0,0 +1,12 @@
import logging
from backend.config import settings
logger = logging.getLogger(__name__)
def generate_summaries():
"""Stub: AI summary generation. Enable when a provider is configured."""
if not settings.ai_summary_enabled:
return
logger.info("AI summary generation not yet configured")

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "127.0.0.1:8000:8000"
env_file: .env
restart: unless-stopped
command: >
sh -c "alembic upgrade head &&
uvicorn backend.main:app --host 0.0.0.0 --port 8000"
worker:
build:
context: .
dockerfile: Dockerfile.worker
env_file: .env
restart: unless-stopped
command: python -m backend.worker.main

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4283
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

35
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import SubredditManager from './pages/SubredditManager';
import PostExplorer from './pages/PostExplorer';
import UserActivity from './pages/UserActivity';
import Digests from './pages/Digests';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/subreddits" element={<SubredditManager />} />
<Route path="/posts" element={<PostExplorer />} />
<Route path="/users" element={<UserActivity />} />
<Route path="/digests" element={<Digests />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,40 @@
const BASE = '/api/v1';
export async function fetchApi<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
const url = new URL(`${BASE}${path}`, window.location.origin);
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') {
url.searchParams.set(k, String(v));
}
});
}
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export async function postApi<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export async function patchApi<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export async function deleteApi(path: string): Promise<void> {
const res = await fetch(`${BASE}${path}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,36 @@
import { NavLink, Outlet } from 'react-router-dom';
const navItems = [
{ to: '/', label: 'Dashboard' },
{ to: '/subreddits', label: 'Subreddits' },
{ to: '/posts', label: 'Posts' },
{ to: '/users', label: 'Users' },
{ to: '/digests', label: 'Digests' },
];
export default function Layout() {
return (
<div className="flex min-h-screen">
<nav className="w-56 bg-slate-800 p-4 flex flex-col gap-1 shrink-0">
<h1 className="text-lg font-bold text-white mb-4 px-3">Reddit Monitor</h1>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`px-3 py-2 rounded text-sm ${
isActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-slate-700'
}`
}
end={item.to === '/'}
>
{item.label}
</NavLink>
))}
</nav>
<main className="flex-1 p-6 overflow-auto">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,13 @@
interface Props {
label: string;
value: string | number;
}
export default function StatCard({ label, value }: Props) {
return (
<div className="bg-slate-800 rounded-lg p-4">
<div className="text-sm text-slate-400">{label}</div>
<div className="text-2xl font-bold text-white mt-1">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
interface Props {
value: string;
onChange: (val: string) => void;
}
const ranges = [
{ label: '24h', value: '1' },
{ label: '7d', value: '7' },
{ label: '30d', value: '30' },
{ label: '90d', value: '90' },
];
export default function TimeRangeSelector({ value, onChange }: Props) {
return (
<div className="flex gap-1">
{ranges.map((r) => (
<button
key={r.value}
onClick={() => onChange(r.value)}
className={`px-3 py-1 text-sm rounded ${
value === r.value
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{r.label}
</button>
))}
</div>
);
}

9
frontend/src/index.css Normal file
View File

@@ -0,0 +1,9 @@
@import "tailwindcss";
body {
margin: 0;
min-height: 100vh;
background-color: #0f172a;
color: #e2e8f0;
font-family: system-ui, -apple-system, sans-serif;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { fetchApi } from '../api/client';
import StatCard from '../components/StatCard';
import TimeRangeSelector from '../components/TimeRangeSelector';
import type { EngagementPoint, TopPost, SubredditSummary } from '../types';
export default function Dashboard() {
const [days, setDays] = useState('30');
const since = new Date(Date.now() - Number(days) * 86400000).toISOString();
const { data: engagement } = useQuery({
queryKey: ['engagement', days],
queryFn: () => fetchApi<EngagementPoint[]>('/analytics/engagement', { since, granularity: 'day' }),
});
const { data: topPosts } = useQuery({
queryKey: ['top-posts', days],
queryFn: () => fetchApi<TopPost[]>('/analytics/top-posts', { since, limit: 10 }),
});
const { data: summary } = useQuery({
queryKey: ['subreddit-summary', days],
queryFn: () => fetchApi<SubredditSummary[]>('/analytics/subreddit-summary', { since }),
});
const totalPosts = summary?.reduce((s, r) => s + r.total_posts, 0) ?? 0;
const totalComments = summary?.reduce((s, r) => s + r.total_comments, 0) ?? 0;
const subCount = summary?.length ?? 0;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">Dashboard</h2>
<TimeRangeSelector value={days} onChange={setDays} />
</div>
<div className="grid grid-cols-4 gap-4 mb-6">
<StatCard label="Total Posts" value={totalPosts} />
<StatCard label="Total Comments" value={totalComments} />
<StatCard label="Monitored Subs" value={subCount} />
<StatCard
label="Avg Score"
value={summary?.length ? (summary.reduce((s, r) => s + r.avg_score, 0) / summary.length).toFixed(1) : '0'}
/>
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-slate-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-slate-400 mb-3">Engagement Over Time</h3>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={engagement ?? []}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="period" tick={{ fontSize: 11, fill: '#94a3b8' }} tickFormatter={(v) => v.slice(5, 10)} />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: 8 }} />
<Area type="monotone" dataKey="posts" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.2} name="Posts" />
<Area type="monotone" dataKey="comments" stroke="#10b981" fill="#10b981" fillOpacity={0.2} name="Comments" />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="bg-slate-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-slate-400 mb-3">Top Posts by Score</h3>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={topPosts?.slice(0, 8) ?? []} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis type="number" tick={{ fontSize: 11, fill: '#94a3b8' }} />
<YAxis
dataKey="title"
type="category"
width={150}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickFormatter={(v) => (v.length > 25 ? v.slice(0, 25) + '...' : v)}
/>
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: 8 }} />
<Bar dataKey="score" fill="#f59e0b" name="Score" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{summary && summary.length > 0 && (
<div className="bg-slate-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-slate-400 mb-3">Subreddit Summary</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-slate-400 border-b border-slate-700">
<th className="text-left py-2">Subreddit</th>
<th className="text-right py-2">Posts</th>
<th className="text-right py-2">Comments</th>
<th className="text-right py-2">Avg Score</th>
<th className="text-right py-2">Top Flair</th>
</tr>
</thead>
<tbody>
{summary.map((s) => (
<tr key={s.subreddit_id} className="border-b border-slate-700/50">
<td className="py-2">r/{s.subreddit_name}</td>
<td className="text-right py-2">{s.total_posts}</td>
<td className="text-right py-2">{s.total_comments}</td>
<td className="text-right py-2">{s.avg_score}</td>
<td className="text-right py-2 text-slate-400">{s.top_flair ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchApi } from '../api/client';
import type { Digest, Subreddit } from '../types';
export default function Digests() {
const [subFilter, setSubFilter] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const { data: subreddits } = useQuery({
queryKey: ['subreddits'],
queryFn: () => fetchApi<Subreddit[]>('/subreddits'),
});
const { data, isLoading } = useQuery({
queryKey: ['digests', subFilter],
queryFn: () =>
fetchApi<{ data: Digest[] }>('/digests', {
subreddit_id: subFilter || undefined,
per_page: 50,
}),
});
const { data: detail } = useQuery({
queryKey: ['digest', selectedId],
queryFn: () => fetchApi<Digest>(`/digests/${selectedId}`),
enabled: !!selectedId,
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">Daily Digests</h2>
<select
value={subFilter}
onChange={(e) => setSubFilter(e.target.value)}
className="bg-slate-800 border border-slate-600 rounded px-3 py-1 text-sm text-white"
>
<option value="">All subreddits</option>
{subreddits?.map((s) => (
<option key={s.id} value={s.id}>r/{s.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-3 gap-6">
<div className="col-span-1">
{isLoading ? (
<p className="text-slate-400">Loading...</p>
) : (
<div className="space-y-2">
{data?.data.map((digest) => (
<button
key={digest.id}
onClick={() => setSelectedId(digest.id)}
className={`w-full text-left p-3 rounded-lg text-sm ${
selectedId === digest.id ? 'bg-blue-600' : 'bg-slate-800 hover:bg-slate-700'
}`}
>
<div className="font-medium">r/{digest.subreddit_name}</div>
<div className="text-slate-400 text-xs mt-1">{digest.digest_date}</div>
</button>
))}
{data?.data.length === 0 && (
<p className="text-slate-400 text-sm">No digests yet. They are generated daily.</p>
)}
</div>
)}
</div>
<div className="col-span-2">
{detail ? (
<div className="bg-slate-800 rounded-lg p-6">
<pre className="whitespace-pre-wrap text-sm text-slate-200 font-sans leading-relaxed">
{detail.content}
</pre>
</div>
) : (
<div className="bg-slate-800 rounded-lg p-6 text-slate-400 text-sm">
Select a digest to view its content.
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchApi } from '../api/client';
import type { Post, PaginatedResponse, Subreddit } from '../types';
import TimeRangeSelector from '../components/TimeRangeSelector';
export default function PostExplorer() {
const [days, setDays] = useState('7');
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState('created_utc');
const [sortOrder, setSortOrder] = useState('desc');
const [subFilter, setSubFilter] = useState('');
const since = new Date(Date.now() - Number(days) * 86400000).toISOString();
const { data: subreddits } = useQuery({
queryKey: ['subreddits'],
queryFn: () => fetchApi<Subreddit[]>('/subreddits'),
});
const { data, isLoading } = useQuery({
queryKey: ['posts', page, sortBy, sortOrder, days, subFilter],
queryFn: () =>
fetchApi<PaginatedResponse<Post>>('/posts', {
page,
per_page: 25,
sort_by: sortBy,
sort_order: sortOrder,
since,
subreddit_id: subFilter || undefined,
}),
});
const toggleSort = (col: string) => {
if (sortBy === col) {
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc');
} else {
setSortBy(col);
setSortOrder('desc');
}
setPage(1);
};
const sortIcon = (col: string) => {
if (sortBy !== col) return '';
return sortOrder === 'desc' ? ' \u25bc' : ' \u25b2';
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">Posts</h2>
<div className="flex items-center gap-3">
<select
value={subFilter}
onChange={(e) => { setSubFilter(e.target.value); setPage(1); }}
className="bg-slate-800 border border-slate-600 rounded px-3 py-1 text-sm text-white"
>
<option value="">All subreddits</option>
{subreddits?.map((s) => (
<option key={s.id} value={s.id}>r/{s.name}</option>
))}
</select>
<TimeRangeSelector value={days} onChange={(v) => { setDays(v); setPage(1); }} />
</div>
</div>
{isLoading ? (
<p className="text-slate-400">Loading...</p>
) : (
<>
<div className="bg-slate-800 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-slate-400 border-b border-slate-700">
<th className="text-left py-3 px-4 cursor-pointer" onClick={() => toggleSort('created_utc')}>
Title{sortIcon('created_utc')}
</th>
<th className="text-left py-3 px-2">Sub</th>
<th className="text-left py-3 px-2">Author</th>
<th className="text-right py-3 px-2 cursor-pointer" onClick={() => toggleSort('score')}>
Score{sortIcon('score')}
</th>
<th className="text-right py-3 px-2 cursor-pointer" onClick={() => toggleSort('num_comments')}>
Comments{sortIcon('num_comments')}
</th>
<th className="text-left py-3 px-2">Flair</th>
<th className="text-right py-3 px-4">Hot</th>
</tr>
</thead>
<tbody>
{data?.data.map((post) => (
<tr key={post.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="py-2 px-4 max-w-md truncate">
{post.permalink ? (
<a href={`https://reddit.com${post.permalink}`} target="_blank" rel="noreferrer" className="text-blue-400 hover:underline">
{post.title}
</a>
) : post.title}
</td>
<td className="py-2 px-2 text-slate-400">r/{post.subreddit_name}</td>
<td className="py-2 px-2 text-slate-400">{post.author_name ?? '[deleted]'}</td>
<td className="py-2 px-2 text-right">{post.score}</td>
<td className="py-2 px-2 text-right">{post.num_comments}</td>
<td className="py-2 px-2">
{post.flair && (
<span className="text-xs bg-slate-700 px-2 py-0.5 rounded">{post.flair}</span>
)}
</td>
<td className="py-2 px-4 text-right text-slate-400">
{post.hot_rank ? `#${post.hot_rank}` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{data && data.pages > 1 && (
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-slate-400">
Page {data.page} of {data.pages} ({data.total} posts)
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded text-sm disabled:opacity-50"
>
Prev
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page >= data.pages}
className="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded text-sm disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchApi, postApi, patchApi, deleteApi } from '../api/client';
import type { Subreddit } from '../types';
export default function SubredditManager() {
const [newName, setNewName] = useState('');
const qc = useQueryClient();
const { data: subreddits, isLoading } = useQuery({
queryKey: ['subreddits'],
queryFn: () => fetchApi<Subreddit[]>('/subreddits'),
});
const addMutation = useMutation({
mutationFn: (name: string) => postApi('/subreddits', { name }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['subreddits'] });
setNewName('');
},
});
const toggleMutation = useMutation({
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
patchApi(`/subreddits/${id}`, { is_active }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['subreddits'] }),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteApi(`/subreddits/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['subreddits'] }),
});
return (
<div>
<h2 className="text-xl font-bold mb-6">Monitored Subreddits</h2>
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) addMutation.mutate(newName.trim());
}}
className="flex gap-2 mb-6"
>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="subreddit name (without r/)"
className="flex-1 bg-slate-800 border border-slate-600 rounded px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={addMutation.isPending}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
Add
</button>
</form>
{isLoading ? (
<p className="text-slate-400">Loading...</p>
) : (
<div className="grid grid-cols-3 gap-4">
{subreddits?.map((sub) => (
<div key={sub.id} className="bg-slate-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium">r/{sub.name}</h3>
<span
className={`text-xs px-2 py-0.5 rounded ${
sub.is_active ? 'bg-green-900 text-green-300' : 'bg-slate-700 text-slate-400'
}`}
>
{sub.is_active ? 'Active' : 'Paused'}
</span>
</div>
<div className="text-sm text-slate-400 mb-3">
{sub.post_count} posts collected
{sub.subscribers ? ` · ${(sub.subscribers / 1000).toFixed(0)}k subscribers` : ''}
</div>
<div className="flex gap-2">
<button
onClick={() => toggleMutation.mutate({ id: sub.id, is_active: !sub.is_active })}
className="text-xs bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded"
>
{sub.is_active ? 'Pause' : 'Resume'}
</button>
<button
onClick={() => {
if (confirm(`Stop monitoring r/${sub.name}?`)) deleteMutation.mutate(sub.id);
}}
className="text-xs bg-red-900/50 hover:bg-red-900 text-red-300 px-3 py-1 rounded"
>
Remove
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchApi } from '../api/client';
import type { PaginatedResponse, Author, Subreddit } from '../types';
import TimeRangeSelector from '../components/TimeRangeSelector';
export default function UserActivity() {
const [days, setDays] = useState('7');
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState('total_comments');
const [subFilter, setSubFilter] = useState('');
const since = new Date(Date.now() - Number(days) * 86400000).toISOString();
const { data: subreddits } = useQuery({
queryKey: ['subreddits'],
queryFn: () => fetchApi<Subreddit[]>('/subreddits'),
});
const { data, isLoading } = useQuery({
queryKey: ['authors', page, sortBy, days, subFilter],
queryFn: () =>
fetchApi<PaginatedResponse<Author>>('/authors', {
page,
per_page: 25,
sort_by: sortBy,
sort_order: 'desc',
since,
subreddit_id: subFilter || undefined,
}),
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">User Activity</h2>
<div className="flex items-center gap-3">
<select
value={subFilter}
onChange={(e) => { setSubFilter(e.target.value); setPage(1); }}
className="bg-slate-800 border border-slate-600 rounded px-3 py-1 text-sm text-white"
>
<option value="">All subreddits</option>
{subreddits?.map((s) => (
<option key={s.id} value={s.id}>r/{s.name}</option>
))}
</select>
<div className="flex gap-1">
<button
onClick={() => { setSortBy('total_comments'); setPage(1); }}
className={`px-3 py-1 text-sm rounded ${sortBy === 'total_comments' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300'}`}
>
By Comments
</button>
<button
onClick={() => { setSortBy('total_posts'); setPage(1); }}
className={`px-3 py-1 text-sm rounded ${sortBy === 'total_posts' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300'}`}
>
By Posts
</button>
</div>
<TimeRangeSelector value={days} onChange={(v) => { setDays(v); setPage(1); }} />
</div>
</div>
{isLoading ? (
<p className="text-slate-400">Loading...</p>
) : (
<>
<div className="bg-slate-800 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-slate-400 border-b border-slate-700">
<th className="text-left py-3 px-4">#</th>
<th className="text-left py-3 px-4">Username</th>
<th className="text-right py-3 px-4">Posts</th>
<th className="text-right py-3 px-4">Comments</th>
<th className="text-right py-3 px-4">Total Activity</th>
<th className="text-right py-3 px-4">First Seen</th>
<th className="text-right py-3 px-4">Last Seen</th>
</tr>
</thead>
<tbody>
{data?.data.map((author, i) => (
<tr key={author.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="py-2 px-4 text-slate-400">{(page - 1) * 25 + i + 1}</td>
<td className="py-2 px-4">u/{author.username}</td>
<td className="py-2 px-4 text-right">{author.total_posts}</td>
<td className="py-2 px-4 text-right">{author.total_comments}</td>
<td className="py-2 px-4 text-right font-medium">{author.total_posts + author.total_comments}</td>
<td className="py-2 px-4 text-right text-slate-400">{new Date(author.first_seen_at).toLocaleDateString()}</td>
<td className="py-2 px-4 text-right text-slate-400">{new Date(author.last_seen_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
{data && data.pages > 1 && (
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-slate-400">Page {data.page} of {data.pages}</span>
<div className="flex gap-2">
<button onClick={() => setPage(page - 1)} disabled={page <= 1} className="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded text-sm disabled:opacity-50">Prev</button>
<button onClick={() => setPage(page + 1)} disabled={page >= data.pages} className="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded text-sm disabled:opacity-50">Next</button>
</div>
</div>
)}
</>
)}
</div>
);
}

115
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,115 @@
export interface Subreddit {
id: number;
name: string;
display_name: string | null;
description: string | null;
subscribers: number | null;
is_active: boolean;
created_at: string;
updated_at: string;
post_count: number;
}
export interface Post {
id: number;
reddit_id: string;
subreddit_id: number;
subreddit_name: string | null;
author_id: number | null;
author_name: string | null;
title: string;
selftext: string | null;
url: string | null;
permalink: string | null;
flair: string | null;
score: number;
upvote_ratio: number | null;
num_comments: number;
is_self: boolean | null;
over_18: boolean;
hot_rank: number | null;
created_utc: string;
collected_at: string;
updated_at: string;
}
export interface Comment {
id: number;
reddit_id: string;
post_id: number;
parent_comment_id: number | null;
author_id: number | null;
author_name: string | null;
body: string;
score: number;
created_utc: string;
}
export interface Author {
id: number;
username: string;
first_seen_at: string;
last_seen_at: string;
total_posts: number;
total_comments: number;
}
export interface EngagementPoint {
period: string;
posts: number;
comments: number;
avg_score: number;
}
export interface TopPost {
id: number;
title: string;
score: number;
num_comments: number;
author_name: string | null;
subreddit_name: string;
created_utc: string;
permalink: string | null;
}
export interface TopAuthor {
id: number;
username: string;
post_count: number;
comment_count: number;
total_activity: number;
}
export interface SubredditSummary {
subreddit_id: number;
subreddit_name: string;
total_posts: number;
total_comments: number;
avg_score: number;
top_flair: string | null;
}
export interface Digest {
id: number;
subreddit_id: number;
subreddit_name: string | null;
digest_date: string;
content: string;
metadata: Record<string, unknown> | null;
generated_at: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
pages: number;
}
export interface MetricSnapshot {
score: number;
num_comments: number;
upvote_ratio: number | null;
snapshot_at: string;
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

12
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
sqlalchemy[asyncio]>=2.0.30
asyncpg>=0.29.0
psycopg2-binary>=2.9.9
alembic>=1.13.0
pydantic>=2.7.0
pydantic-settings>=2.3.0
httpx>=0.27.0
apscheduler>=3.10.4
jinja2>=3.1.4
python-multipart>=0.0.9