diff --git a/api/main.py b/api/main.py index 886ecc6c23..c030f6b159 100644 --- a/api/main.py +++ b/api/main.py @@ -27,6 +27,7 @@ debug_router, download_router, health_router, + insights_router, libraries_router, og_images_router, plots_router, @@ -128,6 +129,9 @@ async def add_cache_headers(request: Request, call_next): # Individual spec details (5 min cache, 1h stale-while-revalidate) elif path.startswith("/specs/"): response.headers["Cache-Control"] = "public, max-age=300, stale-while-revalidate=3600" + # Insights endpoints (5 min cache, 1h stale-while-revalidate) + elif path.startswith("/insights/"): + response.headers["Cache-Control"] = "public, max-age=300, stale-while-revalidate=3600" return response @@ -141,6 +145,7 @@ async def add_cache_headers(request: Request, call_next): app.include_router(specs_router) app.include_router(libraries_router) app.include_router(plots_router) +app.include_router(insights_router) app.include_router(download_router) app.include_router(seo_router) app.include_router(og_images_router) diff --git a/api/routers/__init__.py b/api/routers/__init__.py index 69c1f77502..8271c1306c 100644 --- a/api/routers/__init__.py +++ b/api/routers/__init__.py @@ -3,6 +3,7 @@ from api.routers.debug import router as debug_router from api.routers.download import router as download_router from api.routers.health import router as health_router +from api.routers.insights import router as insights_router from api.routers.libraries import router as libraries_router from api.routers.og_images import router as og_images_router from api.routers.plots import router as plots_router @@ -16,6 +17,7 @@ "debug_router", "download_router", "health_router", + "insights_router", "libraries_router", "og_images_router", "plots_router", diff --git a/api/routers/insights.py b/api/routers/insights.py new file mode 100644 index 0000000000..ab869e8128 --- /dev/null +++ b/api/routers/insights.py @@ -0,0 +1,545 @@ +""" +Insights endpoints for pyplots platform. + +Public analytics and discovery features that leverage aggregated database data: +- Dashboard: Rich platform statistics and visualizations +- Plot of the Day: Daily featured high-quality implementation +- Related Plots: Tag-based similarity recommendations +""" + +from __future__ import annotations + +import hashlib +from collections import Counter, defaultdict +from datetime import date, datetime, timezone + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from api.cache import cache_key, get_or_set_cache +from api.dependencies import require_db +from core.constants import SUPPORTED_LIBRARIES +from core.database import ImplRepository, SpecRepository +from core.database.connection import get_db_context +from core.utils import strip_noqa_comments + + +router = APIRouter(prefix="/insights", tags=["insights"]) + + +# ============================================================================= +# Response Models +# ============================================================================= + + +class LibraryDashboardStats(BaseModel): + """Per-library statistics for the dashboard.""" + + id: str + name: str + impl_count: int + avg_score: float | None + min_score: float | None + max_score: float | None + score_buckets: dict[str, int] # "50-55": 2, "90-95": 45, etc. + loc_buckets: dict[str, int] # "0-20": 5, "20-40": 12, etc. + avg_loc: float | None + + +class CoverageCell(BaseModel): + """Single cell in coverage heatmap.""" + + score: float | None = None + has_impl: bool = False + + +class CoverageRow(BaseModel): + """One spec row in coverage heatmap.""" + + spec_id: str + title: str + libraries: dict[str, CoverageCell] + + +class TopImpl(BaseModel): + """A top-rated implementation.""" + + spec_id: str + spec_title: str + library_id: str + quality_score: float + preview_url: str | None = None + + +class TimelinePoint(BaseModel): + """Monthly implementation count.""" + + month: str # "2025-01" + count: int + + +class DashboardResponse(BaseModel): + """Full dashboard statistics.""" + + total_specs: int + total_implementations: int + total_interactive: int + total_lines_of_code: int + avg_quality_score: float | None + coverage_percent: float + + library_stats: list[LibraryDashboardStats] + coverage_matrix: list[CoverageRow] + top_implementations: list[TopImpl] + tag_distribution: dict[str, dict[str, int]] + score_distribution: dict[str, int] + timeline: list[TimelinePoint] + + +class PlotOfTheDayResponse(BaseModel): + """Daily featured plot.""" + + spec_id: str + spec_title: str + description: str | None = None + library_id: str + library_name: str + quality_score: float + preview_url: str | None = None + image_description: str | None = None + code: str | None = None + date: str + + +class RelatedSpecItem(BaseModel): + """A related spec with similarity info.""" + + id: str + title: str + preview_url: str | None = None + library_id: str | None = None + similarity: float + shared_tags: list[str] + + +class RelatedSpecsResponse(BaseModel): + """Related specs for a given spec.""" + + related: list[RelatedSpecItem] + + +# ============================================================================= +# Shared helpers +# ============================================================================= + +LIBRARY_NAMES = { + "altair": "Altair", + "bokeh": "Bokeh", + "highcharts": "Highcharts", + "letsplot": "lets-plot", + "matplotlib": "Matplotlib", + "plotly": "Plotly", + "plotnine": "plotnine", + "pygal": "Pygal", + "seaborn": "Seaborn", +} + + +def _score_bucket(score: float) -> str: + """Map a quality score (50-100) to a 5-step histogram bucket label.""" + clamped = max(50, min(score, 100)) + bucket = min(int((clamped - 50) // 5), 9) + lo = 50 + bucket * 5 + hi = lo + 5 + return f"{lo}-{hi}" + + +def _flatten_tags(tags: dict | None) -> set[str]: + """Flatten a spec's tags JSON into a set of 'category:value' strings.""" + if not tags: + return set() + flat: set[str] = set() + for category, values in tags.items(): + if isinstance(values, list): + for v in values: + flat.add(f"{category}:{v}") + return flat + + +def _parse_iso(s: str | None) -> datetime | None: + """Parse an ISO datetime string, returning None on failure.""" + if not s: + return None + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + return None + + +# ============================================================================= +# 1. Dashboard +# ============================================================================= + + +async def _refresh_dashboard() -> DashboardResponse: + """Standalone factory for background refresh.""" + async with get_db_context() as db: + return await _build_dashboard(SpecRepository(db), ImplRepository(db)) + + +async def _build_dashboard(repo: SpecRepository, impl_repo: ImplRepository) -> DashboardResponse: + """Build the full dashboard response from DB data.""" + all_specs = await repo.get_all() + total_loc = await impl_repo.get_total_code_lines() + loc_per_impl = await impl_repo.get_loc_per_impl() + + # Build LOC buckets per library (0-20, 20-40, ..., 380-400, 400+) + loc_bucket_ranges = [(i, i + 20) for i in range(0, 400, 20)] + [(400, 999)] + library_loc_buckets: dict[str, Counter[str]] = {lib: Counter() for lib in SUPPORTED_LIBRARIES} + for lib_id, lines in loc_per_impl: + if lib_id in library_loc_buckets: + for lo, hi in loc_bucket_ranges: + if lo <= lines < hi or (hi == 999 and lines >= lo): + label = f"{lo}-{hi}" if hi != 999 else f"{lo}+" + library_loc_buckets[lib_id][label] += 1 + break + + # Avg LOC per library + library_loc_lists: dict[str, list[int]] = {lib: [] for lib in SUPPORTED_LIBRARIES} + for lib_id, lines in loc_per_impl: + if lib_id in library_loc_lists: + library_loc_lists[lib_id].append(lines) + + total_impls = 0 + total_interactive = 0 + all_scores: list[float] = [] + + library_scores: dict[str, list[float]] = {lib: [] for lib in SUPPORTED_LIBRARIES} + library_counts: dict[str, int] = dict.fromkeys(SUPPORTED_LIBRARIES, 0) # type: ignore[arg-type] + + tag_counter: dict[str, Counter[str]] = defaultdict(Counter) + monthly_counts: Counter[str] = Counter() + score_buckets: Counter[str] = Counter() + + coverage_rows: list[CoverageRow] = [] + top_impls: list[TopImpl] = [] + + for spec in all_specs: + row_libs: dict[str, CoverageCell] = {} + + for impl in spec.impls: + lib_id = impl.library_id + score = impl.quality_score + total_impls += 1 + if impl.preview_html: + total_interactive += 1 + library_counts[lib_id] = library_counts.get(lib_id, 0) + 1 + row_libs[lib_id] = CoverageCell(score=score, has_impl=True) + + if score is not None: + library_scores[lib_id].append(score) + all_scores.append(score) + score_buckets[_score_bucket(score)] += 1 + + if score >= 95: + top_impls.append( + TopImpl( + spec_id=spec.id, + spec_title=spec.title, + library_id=lib_id, + quality_score=score, + preview_url=impl.preview_url, + ) + ) + + # Timeline from generated_at (datetime field, not string) + gen_dt = impl.generated_at + if gen_dt: + monthly_counts[gen_dt.strftime("%Y-%m")] += 1 + + coverage_rows.append(CoverageRow(spec_id=spec.id, title=spec.title, libraries=row_libs)) + + # Tag distribution (spec-level + impl-level) + if spec.tags: + for category, values in spec.tags.items(): + if isinstance(values, list): + for v in values: + tag_counter[category][v] += 1 + for impl in spec.impls: + if impl.impl_tags and isinstance(impl.impl_tags, dict): + for category, values in impl.impl_tags.items(): + if isinstance(values, list): + for v in values: + tag_counter[category][v] += 1 + + # Build library stats + lib_stats: list[LibraryDashboardStats] = [] + for lib_id in sorted(SUPPORTED_LIBRARIES): + scores = library_scores[lib_id] + buckets: Counter[str] = Counter() + for s in scores: + buckets[_score_bucket(s)] += 1 + lib_stats.append( + LibraryDashboardStats( + id=lib_id, + name=LIBRARY_NAMES.get(lib_id, lib_id), + impl_count=library_counts[lib_id], + avg_score=round(sum(scores) / len(scores), 1) if scores else None, + min_score=round(min(scores), 1) if scores else None, + max_score=round(max(scores), 1) if scores else None, + score_buckets=dict(buckets), + loc_buckets=dict(library_loc_buckets[lib_id]), + avg_loc=round(sum(library_loc_lists[lib_id]) / len(library_loc_lists[lib_id]), 1) + if library_loc_lists[lib_id] + else None, + ) + ) + lib_stats.sort(key=lambda x: x.impl_count, reverse=True) + + # Top impls sorted by score desc, limit 20 + top_impls.sort(key=lambda x: x.quality_score, reverse=True) + top_impls = top_impls[:20] + + # Score distribution — ensure all buckets present + score_dist = {f"{50 + i * 5}-{55 + i * 5}": score_buckets.get(f"{50 + i * 5}-{55 + i * 5}", 0) for i in range(10)} + + # Timeline sorted by month + timeline = [TimelinePoint(month=m, count=c) for m, c in sorted(monthly_counts.items())] + + # Coverage + coverage = (total_impls / (len(all_specs) * len(SUPPORTED_LIBRARIES)) * 100) if all_specs else 0 + + # Coverage matrix sorted by title + coverage_rows.sort(key=lambda r: r.title.lower()) + + return DashboardResponse( + total_specs=len(all_specs), + total_implementations=total_impls, + total_interactive=total_interactive, + total_lines_of_code=total_loc, + avg_quality_score=round(sum(all_scores) / len(all_scores), 1) if all_scores else None, + coverage_percent=round(coverage, 1), + library_stats=lib_stats, + coverage_matrix=coverage_rows, + top_implementations=top_impls, + tag_distribution={cat: dict(counter.most_common(20)) for cat, counter in sorted(tag_counter.items())}, + score_distribution=score_dist, + timeline=timeline, + ) + + +@router.get("/dashboard", response_model=DashboardResponse) +async def get_dashboard(db: AsyncSession = Depends(require_db)) -> DashboardResponse: + """ + Get rich platform statistics for the public stats dashboard. + + Includes per-library scores, coverage heatmap, top implementations, + tag distribution, score histogram, and implementation timeline. + """ + repo = SpecRepository(db) + impl_repo = ImplRepository(db) + + async def _fetch() -> DashboardResponse: + return await _build_dashboard(repo, impl_repo) + + return await get_or_set_cache( + cache_key("insights", "dashboard"), _fetch, refresh_after=3600, refresh_factory=_refresh_dashboard + ) + + +# ============================================================================= +# 2. Plot of the Day +# ============================================================================= + + +async def _refresh_potd() -> PlotOfTheDayResponse | None: + """Standalone factory for background refresh.""" + async with get_db_context() as db: + return await _build_potd(SpecRepository(db), ImplRepository(db)) + + +async def _build_potd(spec_repo: SpecRepository, impl_repo: ImplRepository) -> PlotOfTheDayResponse | None: + """Select the plot of the day deterministically.""" + all_specs = await spec_repo.get_all() + today = date.today().isoformat() + + # Collect candidates: implementations with quality_score >= 90 (lightweight, no code loaded) + candidates: list[tuple[str, str, str, str | None, float, str | None]] = [] + for spec in all_specs: + for impl in spec.impls: + if impl.quality_score is not None and impl.quality_score >= 90 and impl.preview_url: + candidates.append( + (spec.id, spec.title, spec.description or "", impl.library_id, impl.quality_score, impl.preview_url) + ) + + if not candidates: + return None + + # Deterministic selection based on date + seed = int(hashlib.md5(today.encode()).hexdigest(), 16) # noqa: S324 + idx = seed % len(candidates) + spec_id, spec_title, description, library_id, quality_score, preview_url = candidates[idx] + + # Load deferred fields (code, image_description) for just this one impl + full_impl = await impl_repo.get_by_spec_and_library(spec_id, library_id) + + return PlotOfTheDayResponse( + spec_id=spec_id, + spec_title=spec_title, + description=description, + library_id=library_id, + library_name=LIBRARY_NAMES.get(library_id, library_id), + quality_score=quality_score, + preview_url=preview_url, + image_description=full_impl.review_image_description if full_impl else None, + code=strip_noqa_comments(full_impl.code) if full_impl and full_impl.code else None, + date=today, + ) + + +@router.get("/plot-of-the-day", response_model=PlotOfTheDayResponse | None) +async def get_plot_of_the_day(db: AsyncSession = Depends(require_db)) -> PlotOfTheDayResponse | None: + """ + Get the featured plot of the day. + + Deterministically selects a high-quality implementation (score >= 90) + based on today's date. Returns the same result for the entire day. + """ + spec_repo = SpecRepository(db) + impl_repo = ImplRepository(db) + + async def _fetch() -> PlotOfTheDayResponse | None: + return await _build_potd(spec_repo, impl_repo) + + return await get_or_set_cache( + cache_key("insights", "potd", date.today().isoformat()), + _fetch, + refresh_after=3600, + refresh_factory=_refresh_potd, + ) + + +# ============================================================================= +# 3. Related Plots +# ============================================================================= + + +def _collect_impl_tags(spec: object, library: str | None = None) -> set[str]: + """Collect spec-level tags + impl-level tags for a spec. + + If library is specified, only include that library's impl_tags. + Otherwise, collect impl_tags from all implementations. + """ + tags = _flatten_tags(spec.tags) + for impl in spec.impls: + if library and impl.library_id != library: + continue + if impl.impl_tags and isinstance(impl.impl_tags, dict): + tags |= _flatten_tags(impl.impl_tags) + return tags + + +async def _build_related( + repo: SpecRepository, spec_id: str, limit: int, mode: str, library: str | None = None +) -> RelatedSpecsResponse: + """Find related specs using Jaccard similarity on tags. + + mode="spec": only spec-level tags (for overview page) + mode="full": spec + impl tags for the given library (for impl detail page) + """ + all_specs = await repo.get_all() + + # Find target spec + target = None + for s in all_specs: + if s.id == spec_id: + target = s + break + if target is None: + return RelatedSpecsResponse(related=[]) + + # Build target tags based on mode + if mode == "full": + target_tags = _collect_impl_tags(target, library) + else: + target_tags = _flatten_tags(target.tags) + if not target_tags: + return RelatedSpecsResponse(related=[]) + + # Compute similarity for all other specs + scored: list[tuple[float, list[str], object]] = [] + for spec in all_specs: + if spec.id == spec_id: + continue + # For other specs: use same library's impl_tags if available, else best match + if mode == "full": + other_tags = _collect_impl_tags(spec, library) + else: + other_tags = _flatten_tags(spec.tags) + if not other_tags: + continue + intersection = target_tags & other_tags + union = target_tags | other_tags + similarity = len(intersection) / len(union) if union else 0 + if similarity > 0: + shared = [t.split(":", 1)[1] for t in sorted(intersection)] + scored.append((similarity, shared, spec)) + + # Sort by similarity desc, then shuffle within same-score groups for variety + scored.sort(key=lambda x: x[0], reverse=True) + # Take top candidates (2x limit) and shuffle lightly to add variety + top_pool = scored[: limit * 2] + if len(top_pool) > limit: + import random + + # Group by rounded similarity, shuffle within groups + seed = int(hashlib.md5(spec_id.encode()).hexdigest(), 16) % (2**32) # noqa: S324 + rng = random.Random(seed) + rng.shuffle(top_pool) + # Re-sort but with jitter: high similarity still wins, just not deterministic order + top_pool.sort(key=lambda x: x[0] + rng.uniform(0, 0.05), reverse=True) + + # Build response with best-quality preview per spec + related: list[RelatedSpecItem] = [] + for similarity, shared_tags, spec in top_pool[:limit]: + impls_with_preview = [i for i in spec.impls if i.preview_url] + best_impl = max(impls_with_preview, key=lambda i: i.quality_score or 0) if impls_with_preview else None + related.append( + RelatedSpecItem( + id=spec.id, + title=spec.title, + preview_url=best_impl.preview_url if best_impl else None, + library_id=best_impl.library_id if best_impl else None, + similarity=round(similarity, 3), + shared_tags=shared_tags, + ) + ) + + return RelatedSpecsResponse(related=related) + + +@router.get("/related/{spec_id}", response_model=RelatedSpecsResponse) +async def get_related_specs( + spec_id: str, + limit: int = Query(default=6, ge=1, le=12), + mode: str = Query(default="spec", pattern="^(spec|full)$"), + library: str | None = Query(default=None), + db: AsyncSession = Depends(require_db), +) -> RelatedSpecsResponse: + """ + Get specs related to the given spec, based on tag similarity. + + mode=spec: only spec-level tags (plot_type, domain, etc.) + mode=full: spec tags + impl tags for the given library + library: in full mode, use this library's impl_tags (required for accurate tag matching) + """ + repo = SpecRepository(db) + + async def _fetch() -> RelatedSpecsResponse: + return await _build_related(repo, spec_id, limit, mode, library) + + return await get_or_set_cache(cache_key("insights", "related", spec_id, str(limit), mode, library or ""), _fetch) diff --git a/api/routers/libraries.py b/api/routers/libraries.py index 58cca151ba..1dd02e062a 100644 --- a/api/routers/libraries.py +++ b/api/routers/libraries.py @@ -88,7 +88,7 @@ async def get_library_images(library_id: str, db: AsyncSession = Depends(require return cached repo = SpecRepository(db) - specs = await repo.get_all() + specs = await repo.get_all_with_code() images = [] for spec in specs: diff --git a/api/routers/seo.py b/api/routers/seo.py index a7824e2432..2c85f5ee28 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -31,6 +31,7 @@ def _build_sitemap_xml(specs: list) -> str: " https://pyplots.ai/catalog", " https://pyplots.ai/mcp", " https://pyplots.ai/legal", + " https://pyplots.ai/stats", ] for spec in specs: diff --git a/api/routers/specs.py b/api/routers/specs.py index df0dc76c7f..11a78782f6 100644 --- a/api/routers/specs.py +++ b/api/routers/specs.py @@ -8,7 +8,7 @@ from api.exceptions import raise_not_found from api.schemas import ImplementationResponse, SpecDetailResponse, SpecListItem from core.config import settings -from core.database import SpecRepository +from core.database import ImplRepository, SpecRepository from core.database.connection import get_db_context from core.utils import strip_noqa_comments @@ -96,8 +96,9 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)): preview_url=impl.preview_url, preview_html=impl.preview_html, quality_score=impl.quality_score, - code=strip_noqa_comments(impl.code), + code=None, # Code loaded separately via /specs/{spec_id}/{library}/code generated_at=impl.generated_at.isoformat() if impl.generated_at else None, + updated=impl.updated.isoformat() if impl.updated else None, generated_by=impl.generated_by, python_version=impl.python_version, library_version=impl.library_version, @@ -129,6 +130,29 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)): return result +@router.get("/specs/{spec_id}/{library}/code") +async def get_impl_code(spec_id: str, library: str, db: AsyncSession = Depends(require_db)): + """ + Get implementation code for a specific spec + library. + + Lightweight endpoint that loads only the code field (deferred in main query). + """ + key = cache_key("impl_code", spec_id, library) + cached = get_cache(key) + if cached is not None: + return cached + + repo = ImplRepository(db) + impl = await repo.get_code(spec_id, library) + + if not impl or not impl.code: + raise_not_found("Implementation code", f"{spec_id}/{library}") + + result = {"spec_id": spec_id, "library": library, "code": strip_noqa_comments(impl.code)} + set_cache(key, result) + return result + + @router.get("/specs/{spec_id}/images") async def get_spec_images(spec_id: str, db: AsyncSession = Depends(require_db)): """ diff --git a/api/schemas.py b/api/schemas.py index 71493c8e93..f5bdd385dc 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -19,6 +19,7 @@ class ImplementationResponse(BaseModel): quality_score: float | None = None code: str | None = None generated_at: str | None = None + updated: str | None = None generated_by: str | None = None python_version: str | None = None library_version: str | None = None diff --git a/app/src/components/Footer.tsx b/app/src/components/Footer.tsx index d7d6d610af..1e327ac873 100644 --- a/app/src/components/Footer.tsx +++ b/app/src/components/Footer.tsx @@ -38,10 +38,9 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr · onTrackEvent?.('external_link', { destination: 'stats', spec: selectedSpec, library: selectedLibrary })} + component={RouterLink} + to="/stats" + onClick={() => onTrackEvent?.('internal_link', { destination: 'stats', spec: selectedSpec, library: selectedLibrary })} sx={{ color: '#9ca3af', textDecoration: 'none', diff --git a/app/src/components/PlotOfTheDay.tsx b/app/src/components/PlotOfTheDay.tsx new file mode 100644 index 0000000000..f489edac1c --- /dev/null +++ b/app/src/components/PlotOfTheDay.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; + +import { API_URL } from '../constants'; +import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; + +interface PlotOfTheDayData { + spec_id: string; + spec_title: string; + description: string | null; + library_id: string; + library_name: string; + quality_score: number; + preview_url: string | null; + image_description: string | null; + date: string; +} + +const mono = '"MonoLisa", "MonoLisa Fallback", monospace'; + +export function PlotOfTheDay() { + const [data, setData] = useState(null); + const [dismissed, setDismissed] = useState(() => window.sessionStorage.getItem('potd_dismissed') === 'true'); + + useEffect(() => { + if (dismissed) return; + fetch(`${API_URL}/insights/plot-of-the-day`) + .then(r => { if (!r.ok) throw new Error(); return r.json(); }) + .then(setData) + .catch(() => {}); + }, [dismissed]); + + const handleDismiss = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setDismissed(true); + window.sessionStorage.setItem('potd_dismissed', 'true'); + }, []); + + if (!data || dismissed) return null; + + return ( + + + + + {/* Preview image */} + {data.preview_url && ( + + + + + + + + )} + + {/* Info */} + + + + plot of the day + + + {data.date} + + + + + + {data.spec_title} + + + + + {data.library_name} · {data.quality_score}/100 + + + {data.image_description && ( + + "{data.image_description.trim()}" + + )} + + + ); +} diff --git a/app/src/components/RelatedSpecs.tsx b/app/src/components/RelatedSpecs.tsx new file mode 100644 index 0000000000..30e7c744eb --- /dev/null +++ b/app/src/components/RelatedSpecs.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; + +import { API_URL } from '../constants'; +import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; + +interface RelatedSpec { + id: string; + title: string; + preview_url: string | null; + library_id: string | null; + similarity: number; + shared_tags: string[]; +} + +const mono = '"MonoLisa", "MonoLisa Fallback", monospace'; + +// 6 columns max at md+, ~160px each → 400w is plenty +const SIZES = '(max-width: 599px) 50vw, (max-width: 899px) 33vw, 17vw'; + +interface RelatedSpecsProps { + specId: string; + /** "spec" = spec tags only (overview), "full" = spec + impl tags (detail) */ + mode?: 'spec' | 'full'; + /** Current library — in full mode, matches tags against this library's impl_tags */ + library?: string; + /** Called when hovering a related spec card — passes shared tag values */ + onHoverTags?: (tags: string[]) => void; +} + +export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: RelatedSpecsProps) { + const [related, setRelated] = useState([]); + + useEffect(() => { + let cancelled = false; + const params = new URLSearchParams({ limit: '6', mode }); + if (library && mode === 'full') params.set('library', library); + fetch(`${API_URL}/insights/related/${specId}?${params}`) + .then(r => { if (!r.ok) throw new Error(); return r.json(); }) + .then(data => { if (!cancelled) setRelated(data.related ?? []); }) + .catch(() => { if (!cancelled) setRelated([]); }); + return () => { cancelled = true; }; + }, [specId, mode, library]); + + if (related.length === 0) return null; + + return ( + + + {mode === 'full' ? 'similar implementations' : 'similar specifications'} + + + {related.map(spec => ( + onHoverTags?.(spec.shared_tags)} + onMouseLeave={() => onHoverTags?.([])} + sx={{ + textDecoration: 'none', + color: 'inherit', + border: '1px solid #f3f4f6', + borderRadius: 1, + overflow: 'hidden', + transition: 'transform 0.15s ease', + '&:hover': { transform: 'scale(1.02)', borderColor: '#e5e7eb' }, + }} + > + {spec.preview_url ? ( + + + + + + ) : ( + + no preview + + )} + + + {spec.title} + + + + {spec.shared_tags.length} tags in common + + {mode === 'full' && spec.library_id && ( + + {spec.library_id} + + )} + + + + ))} + + + ); +} diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx index 5a1e416aff..4be4f3bf46 100644 --- a/app/src/components/SpecTabs.tsx +++ b/app/src/components/SpecTabs.tsx @@ -46,6 +46,7 @@ interface SpecTabsProps { notes?: string[]; tags?: Record; created?: string; + updated?: string; // Implementation tab imageDescription?: string; strengths?: string[]; @@ -54,11 +55,15 @@ interface SpecTabsProps { // Quality tab qualityScore: number | null; criteriaChecklist?: Record; + // Implementation date + generatedAt?: string; // Common libraryId: string; onTrackEvent?: (name: string, props?: Record) => void; // Overview mode - only show Spec tab overviewMode?: boolean; + // Tags to highlight (from similar specs hover) + highlightedTags?: string[]; } interface TabPanelProps { @@ -158,19 +163,22 @@ export function SpecTabs({ notes, tags, created, + updated, imageDescription, strengths, weaknesses, implTags, qualityScore, criteriaChecklist, + generatedAt, libraryId, onTrackEvent, overviewMode = false, + highlightedTags = [], }: SpecTabsProps) { const [copied, setCopied] = useState(false); // In overview mode, start with Spec tab open; in detail mode, all collapsed - const [tabIndex, setTabIndex] = useState(overviewMode ? 0 : null); + const [tabIndex, setTabIndex] = useState(null); const [expandedCategories, setExpandedCategories] = useState>({}); // Handle tag click - navigate to filtered catalog (full page navigation) @@ -255,6 +263,12 @@ export function SpecTabs({ textTransform: 'none', fontSize: '0.875rem', minHeight: 48, + transition: 'background-color 0.15s ease, color 0.15s ease', + borderRadius: '4px 4px 0 0', + '&:hover': { + backgroundColor: '#f3f4f6', + color: '#3776AB', + }, }, '& .Mui-selected': { color: '#3776AB', @@ -265,14 +279,15 @@ export function SpecTabs({ }} > {!overviewMode && ( - } iconPosition="start" label="Code" /> + tabIndex === 0 && handleTabChange(e, 0)} icon={} iconPosition="start" label="Code" /> )} - } iconPosition="start" label="Spec" /> + tabIndex === specTabIndex && handleTabChange(e, specTabIndex)} icon={} iconPosition="start" label="Spec" /> {!overviewMode && ( - } iconPosition="start" label="Impl" /> + tabIndex === 2 && handleTabChange(e, 2)} icon={} iconPosition="start" label="Impl" /> )} {!overviewMode && ( tabIndex === 3 && handleTabChange(e, 3)} icon={} iconPosition="start" label={qualityScore ? `${Math.round(qualityScore)}` : 'Quality'} @@ -388,57 +403,6 @@ export function SpecTabs({ )} - {/* Tags grouped by category - compact inline, clickable */} - {tags && Object.keys(tags).length > 0 && ( - - {Object.entries(tags).map(([category, values]) => { - const paramName = SPEC_TAG_PARAM_MAP[category]; - return ( - - - {category.replace(/_/g, ' ')}: - - {values.map((value, i) => ( - handleTagClick(paramName, value) : undefined} - sx={{ - fontFamily: '"MonoLisa", monospace', - fontSize: '0.65rem', - height: 20, - bgcolor: '#f3f4f6', - color: '#4b5563', - cursor: paramName ? 'pointer' : 'default', - '&:hover': paramName ? { bgcolor: '#e5e7eb' } : {}, - }} - /> - ))} - - ); - })} - - )} - - {/* Metadata footer */} - - {specId}{created && ` · ${formatDate(created)}`} - @@ -522,48 +486,6 @@ export function SpecTabs({ )} - {/* Implementation Tags - only show non-empty categories, clickable */} - {implTags && Object.entries(implTags).some(([, values]) => values && values.length > 0) && ( - - {Object.entries(implTags) - .filter(([, values]) => values && values.length > 0) - .map(([category, values]) => { - const paramName = IMPL_TAG_PARAM_MAP[category]; - return ( - - - {category}: - - {values.map((value, i) => ( - handleTagClick(paramName, value) : undefined} - sx={{ - fontFamily: '"MonoLisa", monospace', - fontSize: '0.65rem', - height: 20, - bgcolor: '#f3f4f6', - color: '#4b5563', - cursor: paramName ? 'pointer' : 'default', - '&:hover': paramName ? { bgcolor: '#e5e7eb' } : {}, - }} - /> - ))} - - ); - })} - - )} - {/* No data message */} {!imageDescription && (!strengths || strengths.length === 0) && (!weaknesses || weaknesses.length === 0) && ( @@ -713,6 +635,78 @@ export function SpecTabs({ )} + + {/* Tags — always visible after tab content (spec tags + impl tags on detail page) */} + {((tags && Object.keys(tags).length > 0) || (implTags && Object.values(implTags).some(v => v?.length > 0))) && ( + + {tags && Object.entries(tags).map(([category, values]) => { + const paramName = SPEC_TAG_PARAM_MAP[category]; + return ( + + + {category.replace(/_/g, ' ')}: + + {values.map((value, i) => { + const isHighlighted = highlightedTags.includes(value); + return ( + handleTagClick(paramName, value) : undefined} + sx={{ + fontFamily: '"MonoLisa", monospace', fontSize: '0.65rem', height: 20, + bgcolor: isHighlighted ? '#dbeafe' : '#f3f4f6', + color: isHighlighted ? '#1e40af' : '#4b5563', + cursor: paramName ? 'pointer' : 'default', + transition: 'all 0.2s ease', + fontWeight: isHighlighted ? 600 : 400, + '&:hover': paramName ? { bgcolor: '#e5e7eb' } : {}, + }} + /> + ); + })} + + ); + })} + {!overviewMode && implTags && Object.entries(implTags) + .filter(([, values]) => values && values.length > 0) + .map(([category, values]) => { + const paramName = IMPL_TAG_PARAM_MAP[category]; + return ( + + + {category}: + + {values.map((value, i) => { + const isHighlighted = highlightedTags.includes(value); + return ( + handleTagClick(paramName, value) : undefined} + sx={{ + fontFamily: '"MonoLisa", monospace', fontSize: '0.65rem', height: 20, + bgcolor: isHighlighted ? '#dbeafe' : '#f3f4f6', + color: isHighlighted ? '#1e40af' : '#4b5563', + cursor: paramName ? 'pointer' : 'default', + transition: 'all 0.2s ease', + fontWeight: isHighlighted ? 600 : 400, + '&:hover': paramName ? { bgcolor: '#e5e7eb' } : {}, + }} + /> + ); + })} + + ); + })} + + )} + + {/* Metadata footer — always visible */} + + {specId} + {!overviewMode && libraryId && ` · ${libraryId}`} + {(() => { + const date = !overviewMode ? (generatedAt || updated || created) : (updated || created); + return date ? ` · ${formatDate(date)}` : ''; + })()} + ); } diff --git a/app/src/hooks/useCodeFetch.ts b/app/src/hooks/useCodeFetch.ts index 5167074d24..cdd9bdad53 100644 --- a/app/src/hooks/useCodeFetch.ts +++ b/app/src/hooks/useCodeFetch.ts @@ -1,8 +1,8 @@ /** * Hook for fetching plot code on-demand. * - * Code is excluded from /plots/filter to reduce payload size (~2MB savings). - * This hook fetches code from /specs/{spec_id} when needed. + * Code is deferred in the database and excluded from /specs/{id} responses. + * This hook fetches code from the lightweight /specs/{spec_id}/{library}/code endpoint. */ import { useState, useCallback, useRef } from 'react'; @@ -42,21 +42,18 @@ export function useCodeFetch(): UseCodeFetchReturn { return pending; } - // Fetch from API + // Fetch from lightweight code endpoint setIsLoading(true); const promise = (async () => { try { - const response = await fetch(`${API_URL}/specs/${specId}`); + const response = await fetch(`${API_URL}/specs/${specId}/${library}/code`); if (!response.ok) { cacheRef.current[key] = null; return null; } const data = await response.json(); - const impl = data.implementations?.find( - (i: { library_id: string }) => i.library_id === library - ); - const code = impl?.code ?? null; + const code = data.code ?? null; cacheRef.current[key] = code; return code; } catch { diff --git a/app/src/pages/HomePage.tsx b/app/src/pages/HomePage.tsx index 2abbd65dfa..d2f025b266 100644 --- a/app/src/pages/HomePage.tsx +++ b/app/src/pages/HomePage.tsx @@ -13,6 +13,7 @@ import { Header } from '../components/Header'; import { Footer } from '../components/Footer'; import { FilterBar } from '../components/FilterBar'; import { ImagesGrid } from '../components/ImagesGrid'; +import { PlotOfTheDay } from '../components/PlotOfTheDay'; import { useAppData, useHomeState } from '../hooks'; export function HomePage() { @@ -166,6 +167,8 @@ export function HomePage() {
+ {isFiltersEmpty(activeFilters) && !loading && } + {error && ( {error} diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index fe85b4af41..319c1d605d 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -11,11 +11,12 @@ import BugReportIcon from '@mui/icons-material/BugReport'; import { NotFoundPage } from './NotFoundPage'; import { API_URL, GITHUB_URL } from '../constants'; -import { useAnalytics } from '../hooks'; +import { useAnalytics, useCodeFetch } from '../hooks'; import { useAppData } from '../hooks'; import { LibraryPills } from '../components/LibraryPills'; import { Breadcrumb } from '../components/Breadcrumb'; import { Footer } from '../components/Footer'; +import { RelatedSpecs } from '../components/RelatedSpecs'; const SpecTabs = lazy(() => import('../components/SpecTabs').then(m => ({ default: m.SpecTabs }))); const SpecOverview = lazy(() => import('../components/SpecOverview').then(m => ({ default: m.SpecOverview }))); @@ -48,6 +49,8 @@ export function SpecPage() { const [descExpanded, setDescExpanded] = useState(false); const [codeCopied, setCodeCopied] = useState(null); const [openTooltip, setOpenTooltip] = useState(null); + const [highlightedTags, setHighlightedTags] = useState([]); + const { fetchCode, getCode } = useCodeFetch(); // Get library metadata by ID const getLibraryMeta = useCallback( @@ -99,6 +102,16 @@ export function SpecPage() { return specData.implementations.find((impl) => impl.library_id === selectedLibrary) || null; }, [specData, selectedLibrary]); + // Prefetch code in background when impl detail page opens + useEffect(() => { + if (specId && selectedLibrary) { + fetchCode(specId, selectedLibrary); + } + }, [specId, selectedLibrary, fetchCode]); + + // Get code from cache (populated by prefetch or on-demand) + const currentCode = specId && selectedLibrary ? getCode(specId, selectedLibrary) : null; + // Handle library switch (in detail mode) const handleLibrarySelect = useCallback( (libraryId: string) => { @@ -138,12 +151,13 @@ export function SpecPage() { [specId, trackEvent, isOverviewMode] ); - // Handle copy code + // Handle copy code (fetches on-demand if not prefetched yet) const handleCopyCode = useCallback( async (impl: Implementation) => { - if (!impl?.code) return; try { - await navigator.clipboard.writeText(impl.code); + const code = impl.code || (specId ? await fetchCode(specId, impl.library_id) : null); + if (!code) return; + await navigator.clipboard.writeText(code); setCodeCopied(impl.library_id); trackEvent('copy_code', { spec: specId, @@ -352,6 +366,7 @@ export function SpecPage() { notes={specData.notes} tags={specData.tags} created={specData.created} + updated={specData.updated} imageDescription={undefined} strengths={undefined} weaknesses={undefined} @@ -360,6 +375,7 @@ export function SpecPage() { libraryId="" onTrackEvent={trackEvent} overviewMode={true} + highlightedTags={highlightedTags} /> ) : ( @@ -387,7 +403,7 @@ export function SpecPage() { /> )} + +