diff --git a/api/main.py b/api/main.py
index 6b53715..320c6f1 100644
--- a/api/main.py
+++ b/api/main.py
@@ -13,7 +13,7 @@
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
-from api.routes import compression, events, incognito, recording, screenshots, search, setup, status, sync
+from api.routes import analytics, compression, events, incognito, recording, screenshots, search, setup, status, sync
from core.database import db
@@ -138,6 +138,7 @@ async def strip_trailing_slash(request: Request, call_next):
app.include_router(incognito.router, prefix="/api/v1")
app.include_router(setup.router, prefix="/api/v1")
app.include_router(events.router, prefix="/api/v1")
+app.include_router(analytics.router, prefix="/api/v1")
# Serve static web UI if available
diff --git a/api/routes/analytics.py b/api/routes/analytics.py
new file mode 100644
index 0000000..b5aed57
--- /dev/null
+++ b/api/routes/analytics.py
@@ -0,0 +1,772 @@
+"""
+Analytics API Routes
+Provides aggregated analytics data for the dashboard
+"""
+
+import bisect
+import contextlib
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import TypedDict
+
+from fastapi import APIRouter, Query
+
+from api.schemas import (
+ AnalyticsActivityResponse,
+ AnalyticsActivityWeekResponse,
+ AnalyticsGapsResponse,
+ AnalyticsOverviewResponse,
+ AnalyticsQualityResponse,
+ AnalyticsStorageResponse,
+ AnalyticsTrendsResponse,
+)
+from core.config import get_screenshots_dir
+from core.database import db
+
+
+class HeatmapCell(TypedDict):
+ date: str
+ day_of_week: int
+ day_label: str
+ hour: int
+ count: int
+ screenshot_ids: list[int]
+
+
+class DailyDataPoint(TypedDict):
+ date: str
+ count: int
+ moving_avg: float
+
+
+router = APIRouter(prefix="/analytics", tags=["Analytics"])
+
+
+def parse_timestamp(ts: str) -> datetime:
+ """Convert YYMMDDHHMMSS to datetime."""
+ try:
+ if len(ts) != 12:
+ return datetime.now()
+ year = 2000 + int(ts[0:2])
+ month = int(ts[2:4])
+ day = int(ts[4:6])
+ hour = int(ts[6:8])
+ minute = int(ts[8:10])
+ second = int(ts[10:12])
+ return datetime(year, month, day, hour, minute, second)
+ except (ValueError, TypeError):
+ return datetime.now()
+
+
+def format_timestamp(dt: datetime) -> str:
+ """Convert datetime back to YYMMDDHHMMSS format."""
+ return dt.strftime("%y%m%d%H%M%S")
+
+
+@router.get("/overview", response_model=AnalyticsOverviewResponse)
+def get_overview():
+ """
+ Get overview statistics for the analytics dashboard.
+
+ Returns high-level metrics about screenshots, storage, and processing status.
+ """
+ with db.cursor() as cur:
+ # Total screenshots
+ cur.execute("SELECT COUNT(*) FROM screenshots")
+ total_screenshots = cur.fetchone()[0]
+
+ # OCR processed count
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE has_ocr = 1")
+ ocr_processed_count = cur.fetchone()[0]
+
+ # Compressed count
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE is_compressed = 1")
+ compressed_count = cur.fetchone()[0]
+
+ # Screenshots today
+ today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ today_ts = format_timestamp(today_start)
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE timestamp >= ?", (today_ts,))
+ screenshots_today = cur.fetchone()[0]
+
+ # Screenshots this week
+ week_start = today_start - timedelta(days=today_start.weekday())
+ week_ts = format_timestamp(week_start)
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE timestamp >= ?", (week_ts,))
+ screenshots_this_week = cur.fetchone()[0]
+
+ # Screenshots yesterday (for comparison)
+ yesterday_start = today_start - timedelta(days=1)
+ yesterday_ts = format_timestamp(yesterday_start)
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE timestamp >= ? AND timestamp < ?", (yesterday_ts, today_ts))
+ screenshots_yesterday = cur.fetchone()[0]
+
+ # Calculate storage
+ screenshots_dir = get_screenshots_dir()
+ total_storage_bytes = 0
+ file_sizes = []
+
+ if screenshots_dir.exists():
+ for f in screenshots_dir.iterdir():
+ if f.is_file() and f.suffix.lower() in (".jpg", ".jpeg", ".png"):
+ size = f.stat().st_size
+ total_storage_bytes += size
+ file_sizes.append(size)
+
+ avg_file_size = int(total_storage_bytes / len(file_sizes)) if file_sizes else 0
+
+ return {
+ "total_screenshots": total_screenshots,
+ "total_storage_bytes": total_storage_bytes,
+ "compressed_count": compressed_count,
+ "avg_file_size": avg_file_size,
+ "screenshots_today": screenshots_today,
+ "screenshots_yesterday": screenshots_yesterday,
+ "screenshots_this_week": screenshots_this_week,
+ "ocr_processed_count": ocr_processed_count,
+ }
+
+
+@router.get("/storage", response_model=AnalyticsStorageResponse)
+def get_storage_analytics(
+ days: int = Query(default=30, ge=7, le=365, description="Number of days to analyze"),
+):
+ """
+ Get storage analytics including daily growth and size distribution.
+ """
+ with db.cursor() as cur:
+ # Get daily storage data for the last N days
+ end_date = datetime.now()
+ start_date = end_date - timedelta(days=days)
+
+ daily_data = []
+
+ # Build a fast filename→size lookup via single scandir pass
+ screenshots_dir = get_screenshots_dir()
+ file_size_map: dict[str, int] = {}
+ if screenshots_dir.exists():
+ with contextlib.suppress(OSError):
+ for entry in screenshots_dir.iterdir():
+ if entry.is_file() and entry.suffix.lower() in (".jpg", ".jpeg", ".png"):
+ file_size_map[str(entry)] = entry.stat().st_size
+
+ cur.execute(
+ """
+ SELECT timestamp, image_path
+ FROM screenshots
+ WHERE timestamp >= ?
+ ORDER BY timestamp ASC
+ """,
+ (format_timestamp(start_date),),
+ )
+ rows = cur.fetchall()
+
+ # Group by date using pre-built size map (no per-row stat calls)
+ date_sizes: dict[str, list[int]] = {}
+ for row in rows:
+ ts = row[0]
+ image_path = row[1]
+ if len(ts) >= 6:
+ date_key = f"20{ts[0:2]}-{ts[2:4]}-{ts[4:6]}"
+ size = file_size_map.get(image_path)
+ if size is not None:
+ if date_key not in date_sizes:
+ date_sizes[date_key] = []
+ date_sizes[date_key].append(size)
+
+ # Fill in all dates
+ current = start_date
+ cumulative_bytes = 0
+
+ # Get initial cumulative from before start date
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE timestamp < ?", (format_timestamp(start_date),))
+ initial_count = cur.fetchone()[0]
+
+ # Estimate initial storage based on average file size
+ if date_sizes:
+ all_sizes = [s for sizes in date_sizes.values() for s in sizes]
+ avg_size = sum(all_sizes) / len(all_sizes) if all_sizes else 100000
+ cumulative_bytes = int(initial_count * avg_size)
+
+ while current <= end_date:
+ date_key = current.strftime("%Y-%m-%d")
+ day_sizes = date_sizes.get(date_key, [])
+ day_bytes = sum(day_sizes)
+ cumulative_bytes += day_bytes
+
+ daily_data.append(
+ {
+ "date": date_key,
+ "screenshots": len(day_sizes),
+ "bytes_added": day_bytes,
+ "cumulative_bytes": cumulative_bytes,
+ }
+ )
+ current += timedelta(days=1)
+
+ # Get compression stats
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE is_compressed = 1")
+ compressed_count = cur.fetchone()[0]
+
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE is_compressed = 0")
+ uncompressed_count = cur.fetchone()[0]
+
+ cur.execute("SELECT SUM(original_size_bytes) FROM screenshots WHERE is_compressed = 1")
+ original_bytes = cur.fetchone()[0] or 0
+
+ # Get current compressed file sizes
+ cur.execute("SELECT image_path FROM screenshots WHERE is_compressed = 1")
+ compressed_current_bytes = 0
+ for row in cur.fetchall():
+ path = Path(row[0])
+ if path.exists():
+ compressed_current_bytes += path.stat().st_size
+
+ bytes_saved = original_bytes - compressed_current_bytes if original_bytes > 0 else 0
+
+ # Get largest files
+ cur.execute(
+ """
+ SELECT id, image_path, timestamp
+ FROM screenshots
+ ORDER BY timestamp DESC
+ LIMIT 100
+ """
+ )
+
+ largest_files = []
+ for row in cur.fetchall():
+ path = Path(row[1])
+ if path.exists():
+ size = path.stat().st_size
+ largest_files.append(
+ {
+ "id": row[0],
+ "path": row[1],
+ "timestamp": row[2],
+ "size_bytes": size,
+ }
+ )
+
+ # Sort by size and take top 10
+ largest_files.sort(key=lambda x: x["size_bytes"], reverse=True)
+ largest_files = largest_files[:10]
+
+ # Storage by month
+ cur.execute(
+ """
+ SELECT
+ substr(timestamp, 1, 4) as month,
+ COUNT(*) as count
+ FROM screenshots
+ GROUP BY month
+ ORDER BY month DESC
+ LIMIT 12
+ """
+ )
+
+ storage_by_month = []
+ for row in cur.fetchall():
+ month_str = row[0] # YYMM format
+ if len(month_str) == 4:
+ year = 2000 + int(month_str[0:2])
+ month = int(month_str[2:4])
+ storage_by_month.append(
+ {
+ "month": f"{year}-{month:02d}",
+ "count": row[1],
+ }
+ )
+
+ return {
+ "daily_data": daily_data,
+ "compression": {
+ "compressed_count": compressed_count,
+ "uncompressed_count": uncompressed_count,
+ "original_bytes": original_bytes,
+ "current_bytes": compressed_current_bytes,
+ "bytes_saved": bytes_saved,
+ },
+ "largest_files": largest_files,
+ "storage_by_month": storage_by_month,
+ }
+
+
+@router.get("/activity", response_model=AnalyticsActivityResponse)
+def get_activity_analytics(
+ weeks: int = Query(default=12, ge=1, le=52, description="Number of weeks for heatmap"),
+):
+ """
+ Get activity analytics including heatmap data and distributions.
+ """
+ with db.cursor() as cur:
+ # Get all screenshots from the last N weeks for heatmap
+ end_date = datetime.now()
+ start_date = end_date - timedelta(weeks=weeks)
+
+ cur.execute(
+ """
+ SELECT timestamp
+ FROM screenshots
+ WHERE timestamp >= ?
+ ORDER BY timestamp ASC
+ """,
+ (format_timestamp(start_date),),
+ )
+
+ # Build heatmap data: {day_of_week: {hour: count}}
+ heatmap: dict[int, dict[int, int]] = {i: {j: 0 for j in range(24)} for i in range(7)}
+ hourly_totals: dict[int, int] = {i: 0 for i in range(24)}
+ daily_totals: dict[int, int] = {i: 0 for i in range(7)}
+
+ # Weekly data for the last N weeks
+ weekly_data: dict[str, int] = {}
+
+ for row in cur.fetchall():
+ ts = row[0]
+ dt = parse_timestamp(ts)
+ day_of_week = dt.weekday() # 0=Monday, 6=Sunday
+ hour = dt.hour
+
+ heatmap[day_of_week][hour] += 1
+ hourly_totals[hour] += 1
+ daily_totals[day_of_week] += 1
+
+ # Week key for weekly trend
+ week_start = dt - timedelta(days=dt.weekday())
+ week_key = week_start.strftime("%Y-%m-%d")
+ weekly_data[week_key] = weekly_data.get(week_key, 0) + 1
+
+ # Convert heatmap to list format
+ heatmap_data = []
+ for day in range(7):
+ for hour in range(24):
+ count = heatmap[day][hour]
+ if count > 0: # Only include non-zero for efficiency
+ heatmap_data.append(
+ {
+ "day_of_week": day,
+ "hour": hour,
+ "count": count,
+ }
+ )
+
+ # Convert distributions to sorted lists
+ hourly_distribution = [{"hour": h, "count": hourly_totals[h]} for h in range(24)]
+ daily_distribution = [{"day": d, "count": daily_totals[d]} for d in range(7)]
+
+ # Weekly trend (sorted by date)
+ weekly_trend = [{"week": k, "count": v} for k, v in sorted(weekly_data.items())]
+
+ # Find peak times
+ max_hour = max(hourly_totals.items(), key=lambda x: x[1])
+ max_day = max(daily_totals.items(), key=lambda x: x[1])
+
+ day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+
+ return {
+ "heatmap_data": heatmap_data,
+ "hourly_distribution": hourly_distribution,
+ "daily_distribution": daily_distribution,
+ "weekly_trend": weekly_trend,
+ "peak_hour": max_hour[0],
+ "peak_day": day_names[max_day[0]],
+ "total_in_period": sum(hourly_totals.values()),
+ }
+
+
+@router.get("/activity-week", response_model=AnalyticsActivityWeekResponse)
+def get_activity_week(
+ week_offset: int = Query(
+ default=0, ge=-52, le=0, description="Week offset from current week (0=current, -1=last week, etc.)"
+ ),
+):
+ """
+ Get activity data for a specific week.
+
+ Returns hourly screenshot counts for each day of the specified week,
+ suitable for a 7x24 heatmap calendar view.
+ """
+ with db.cursor() as cur:
+ # Calculate the week's start and end dates
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ current_week_start = today - timedelta(days=today.weekday()) # Monday
+
+ # Apply offset
+ week_start = current_week_start + timedelta(weeks=week_offset)
+ week_end = week_start + timedelta(days=7)
+
+ start_ts = format_timestamp(week_start)
+ end_ts = format_timestamp(week_end)
+
+ cur.execute(
+ """
+ SELECT timestamp, id
+ FROM screenshots
+ WHERE timestamp >= ? AND timestamp < ?
+ ORDER BY timestamp ASC
+ """,
+ (start_ts, end_ts),
+ )
+
+ # Build heatmap data: list of {date, day_of_week, hour, count, screenshot_ids}
+ # Group by date and hour
+ data_map: dict[str, dict[int, list[int]]] = {} # date -> hour -> [screenshot_ids]
+
+ for row in cur.fetchall():
+ ts = row[0]
+ screenshot_id = row[1]
+ dt = parse_timestamp(ts)
+ date_key = dt.strftime("%Y-%m-%d")
+ hour = dt.hour
+
+ if date_key not in data_map:
+ data_map[date_key] = {h: [] for h in range(24)}
+ data_map[date_key][hour].append(screenshot_id)
+
+ # Build response with all 7 days
+ heatmap_data: list[HeatmapCell] = []
+ day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
+
+ for day_offset in range(7):
+ day_date = week_start + timedelta(days=day_offset)
+ date_key = day_date.strftime("%Y-%m-%d")
+ day_data = data_map.get(date_key, {h: [] for h in range(24)})
+
+ for hour in range(24):
+ ids = day_data.get(hour, [])
+ heatmap_data.append(
+ {
+ "date": date_key,
+ "day_of_week": day_offset,
+ "day_label": day_labels[day_offset],
+ "hour": hour,
+ "count": len(ids),
+ "screenshot_ids": ids[:10], # Limit to first 10 for preview
+ }
+ )
+
+ # Calculate totals for this week
+ total_screenshots = sum(item["count"] for item in heatmap_data)
+
+ # Find peak time this week
+ max_item = max(heatmap_data, key=lambda x: x["count"]) if heatmap_data else None
+ peak_info = None
+ if max_item and max_item["count"] > 0:
+ peak_info = {
+ "day": max_item["day_label"],
+ "hour": max_item["hour"],
+ "count": max_item["count"],
+ }
+
+ return {
+ "week_start": week_start.strftime("%Y-%m-%d"),
+ "week_end": (week_end - timedelta(days=1)).strftime("%Y-%m-%d"),
+ "week_offset": week_offset,
+ "heatmap_data": heatmap_data,
+ "total_screenshots": total_screenshots,
+ "peak": peak_info,
+ }
+
+
+@router.get("/gaps", response_model=AnalyticsGapsResponse)
+def get_gap_analytics(
+ min_gap_minutes: int = Query(default=30, ge=5, le=1440, description="Minimum gap duration in minutes"),
+ limit: int = Query(default=50, ge=10, le=200, description="Maximum gaps to return"),
+ lookback_days: int = Query(default=90, ge=7, le=365, description="How many days back to analyze"),
+):
+ """
+ Get timeline gaps (periods with no visible screenshots).
+
+ Identifies gaps in recording, which could be due to system sleep,
+ app not running, or incognito mode.
+
+ A gap is marked as 'incognito' if there are hidden screenshots
+ during the gap period (incognito saves screenshots as hidden).
+ """
+ lookback_ts = format_timestamp(datetime.now() - timedelta(days=lookback_days))
+
+ with db.cursor() as cur:
+ # Get VISIBLE screenshots only for gap detection (bounded by lookback)
+ cur.execute(
+ """
+ SELECT timestamp
+ FROM screenshots
+ WHERE is_hidden = 0 AND timestamp >= ?
+ ORDER BY timestamp ASC
+ """,
+ (lookback_ts,),
+ )
+ visible_rows = cur.fetchall()
+
+ # Get HIDDEN screenshots for incognito detection (bounded by lookback)
+ cur.execute(
+ """
+ SELECT timestamp
+ FROM screenshots
+ WHERE is_hidden = 1 AND timestamp >= ?
+ ORDER BY timestamp ASC
+ """,
+ (lookback_ts,),
+ )
+ hidden_rows = cur.fetchall()
+
+ if len(visible_rows) < 2:
+ return {
+ "gaps": [],
+ "total_gap_time_seconds": 0,
+ "longest_gap_seconds": 0,
+ "avg_gap_seconds": 0,
+ "gap_count": 0,
+ }
+
+ # Build sorted list of hidden timestamps for O(log n) incognito checking
+ hidden_timestamps: list[datetime] = []
+ for (ts,) in hidden_rows:
+ with contextlib.suppress(Exception):
+ hidden_timestamps.append(parse_timestamp(ts))
+ hidden_timestamps.sort()
+
+ min_gap_seconds = min_gap_minutes * 60
+ gaps = []
+
+ prev_ts = visible_rows[0][0]
+
+ for i in range(1, len(visible_rows)):
+ curr_ts = visible_rows[i][0]
+
+ prev_dt = parse_timestamp(prev_ts)
+ curr_dt = parse_timestamp(curr_ts)
+
+ gap_seconds = (curr_dt - prev_dt).total_seconds()
+
+ if gap_seconds >= min_gap_seconds:
+ # O(log n) check: find if any hidden timestamp falls in (prev_dt, curr_dt)
+ idx = bisect.bisect_right(hidden_timestamps, prev_dt)
+ has_hidden_during_gap = idx < len(hidden_timestamps) and hidden_timestamps[idx] < curr_dt
+
+ gap_type = "incognito" if has_hidden_during_gap else "gap"
+
+ gaps.append(
+ {
+ "start_time": prev_ts,
+ "end_time": curr_ts,
+ "duration_seconds": int(gap_seconds),
+ "type": gap_type,
+ }
+ )
+
+ prev_ts = curr_ts
+
+ # Calculate statistics from ALL detected gaps (before truncation)
+ all_durations = [g["duration_seconds"] for g in gaps]
+ total_gap_time = sum(all_durations)
+ longest_gap = max(all_durations) if all_durations else 0
+ avg_gap = total_gap_time / len(all_durations) if all_durations else 0
+ total_gap_count = len(gaps)
+
+ # Separate incognito and regular gaps, sorted by duration (longest first)
+ incognito_gaps = sorted(
+ [g for g in gaps if g["type"] == "incognito"], key=lambda x: x["duration_seconds"], reverse=True
+ )
+ regular_gaps = sorted(
+ [g for g in gaps if g["type"] == "gap"], key=lambda x: x["duration_seconds"], reverse=True
+ )
+
+ # Interleave both types so both are always visible at the top
+ # Pattern: regular, incognito, regular, incognito, ...
+ interleaved: list[dict] = []
+ i_reg, i_inc = 0, 0
+ while len(interleaved) < limit and (i_reg < len(regular_gaps) or i_inc < len(incognito_gaps)):
+ if i_reg < len(regular_gaps):
+ interleaved.append(regular_gaps[i_reg])
+ i_reg += 1
+ if len(interleaved) < limit and i_inc < len(incognito_gaps):
+ interleaved.append(incognito_gaps[i_inc])
+ i_inc += 1
+
+ gaps = interleaved
+
+ return {
+ "gaps": gaps,
+ "total_gap_time_seconds": total_gap_time,
+ "longest_gap_seconds": longest_gap,
+ "avg_gap_seconds": int(avg_gap),
+ "gap_count": total_gap_count,
+ }
+
+
+@router.get("/quality", response_model=AnalyticsQualityResponse)
+def get_quality_analytics():
+ """
+ Get file quality and size distribution analytics.
+ """
+ screenshots_dir = get_screenshots_dir()
+
+ file_sizes = []
+ if screenshots_dir.exists():
+ for f in screenshots_dir.iterdir():
+ if f.is_file() and f.suffix.lower() in (".jpg", ".jpeg", ".png"):
+ file_sizes.append(f.stat().st_size)
+
+ if not file_sizes:
+ return {
+ "avg_file_size": 0,
+ "min_file_size": 0,
+ "max_file_size": 0,
+ "median_file_size": 0,
+ "size_distribution": [],
+ "total_files": 0,
+ }
+
+ file_sizes.sort()
+ total_files = len(file_sizes)
+ avg_size = sum(file_sizes) / total_files
+ min_size = file_sizes[0]
+ max_size = file_sizes[-1]
+ median_size = file_sizes[total_files // 2]
+
+ # Create size distribution buckets
+ # Buckets: <50KB, 50-100KB, 100-200KB, 200-500KB, 500KB-1MB, >1MB
+ buckets = [
+ ("< 50 KB", 0, 50 * 1024),
+ ("50-100 KB", 50 * 1024, 100 * 1024),
+ ("100-200 KB", 100 * 1024, 200 * 1024),
+ ("200-500 KB", 200 * 1024, 500 * 1024),
+ ("500 KB - 1 MB", 500 * 1024, 1024 * 1024),
+ ("> 1 MB", 1024 * 1024, float("inf")),
+ ]
+
+ size_distribution = []
+ for label, min_bound, max_bound in buckets:
+ count = sum(1 for s in file_sizes if min_bound <= s < max_bound)
+ size_distribution.append(
+ {
+ "label": label,
+ "count": count,
+ "percentage": round(count / total_files * 100, 1),
+ }
+ )
+
+ # Get compression stats
+ with db.cursor() as cur:
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE is_compressed = 1")
+ compressed_count = cur.fetchone()[0]
+
+ cur.execute("SELECT COUNT(*) FROM screenshots WHERE is_compressed = 0")
+ uncompressed_count = cur.fetchone()[0]
+
+ cur.execute("SELECT SUM(original_size_bytes) FROM screenshots WHERE is_compressed = 1")
+ original_total = cur.fetchone()[0] or 0
+
+ return {
+ "avg_file_size": int(avg_size),
+ "min_file_size": min_size,
+ "max_file_size": max_size,
+ "median_file_size": median_size,
+ "size_distribution": size_distribution,
+ "total_files": total_files,
+ "compression_stats": {
+ "compressed_count": compressed_count,
+ "uncompressed_count": uncompressed_count,
+ "original_bytes_before_compression": original_total,
+ },
+ }
+
+
+@router.get("/trends", response_model=AnalyticsTrendsResponse)
+def get_trends_analytics(
+ days: int = Query(default=30, ge=7, le=365, description="Number of days to analyze"),
+):
+ """
+ Get recording trends and patterns over time.
+ """
+ with db.cursor() as cur:
+ end_date = datetime.now()
+ start_date = end_date - timedelta(days=days)
+
+ # Get daily screenshot counts
+ cur.execute(
+ """
+ SELECT timestamp
+ FROM screenshots
+ WHERE timestamp >= ?
+ ORDER BY timestamp ASC
+ """,
+ (format_timestamp(start_date),),
+ )
+
+ daily_counts: dict[str, int] = {}
+ for row in cur.fetchall():
+ ts = row[0]
+ if len(ts) >= 6:
+ date_key = f"20{ts[0:2]}-{ts[2:4]}-{ts[4:6]}"
+ daily_counts[date_key] = daily_counts.get(date_key, 0) + 1
+
+ # Fill in missing days
+ daily_data: list[DailyDataPoint] = []
+ current = start_date
+ while current <= end_date:
+ date_key = current.strftime("%Y-%m-%d")
+ daily_data.append(
+ {
+ "date": date_key,
+ "count": daily_counts.get(date_key, 0),
+ "moving_avg": 0.0,
+ }
+ )
+ current += timedelta(days=1)
+
+ # Calculate moving average (7-day)
+ for i, day in enumerate(daily_data):
+ start_idx = max(0, i - 6)
+ window = daily_data[start_idx : i + 1]
+ day["moving_avg"] = round(sum(d["count"] for d in window) / len(window), 1)
+
+ # Get sync and OCR stats over time
+ cur.execute(
+ """
+ SELECT
+ COUNT(*) as total,
+ SUM(CASE WHEN has_embedding = 1 THEN 1 ELSE 0 END) as synced,
+ SUM(CASE WHEN has_ocr = 1 THEN 1 ELSE 0 END) as ocr_done
+ FROM screenshots
+ WHERE timestamp >= ?
+ """,
+ (format_timestamp(start_date),),
+ )
+ row = cur.fetchone()
+
+ total_in_period = row[0] or 0
+ synced_in_period = row[1] or 0
+ ocr_in_period = row[2] or 0
+
+ # Calculate averages
+ days_with_data = len([d for d in daily_data if d["count"] > 0])
+ avg_per_day = total_in_period / days if days > 0 else 0
+
+ # Find best and worst days
+ if daily_data:
+ best_day = max(daily_data, key=lambda x: x["count"])
+ worst_day_result = min(
+ (d for d in daily_data if d["count"] > 0),
+ key=lambda x: x["count"],
+ default=None,
+ )
+ worst_day = worst_day_result
+ else:
+ best_day = None
+ worst_day = None
+
+ return {
+ "daily_data": daily_data,
+ "summary": {
+ "total_screenshots": total_in_period,
+ "synced_count": synced_in_period,
+ "ocr_processed_count": ocr_in_period,
+ "avg_per_day": round(avg_per_day, 1),
+ "days_with_recordings": days_with_data,
+ "best_day": {"date": best_day["date"], "count": best_day["count"]} if best_day else None,
+ "worst_day": {"date": worst_day["date"], "count": worst_day["count"]} if worst_day else None,
+ },
+ }
diff --git a/api/schemas.py b/api/schemas.py
index b8635c8..b5e7981 100644
--- a/api/schemas.py
+++ b/api/schemas.py
@@ -741,6 +741,189 @@ class Config:
}
+# =============================================================================
+# Analytics Types
+# =============================================================================
+
+
+class AnalyticsOverviewResponse(BaseModel):
+ """Overview statistics for the analytics dashboard"""
+
+ total_screenshots: int
+ total_storage_bytes: int
+ compressed_count: int
+ avg_file_size: int
+ screenshots_today: int
+ screenshots_yesterday: int
+ screenshots_this_week: int
+ ocr_processed_count: int
+
+
+class StorageDailyData(BaseModel):
+ date: str
+ screenshots: int
+ bytes_added: int
+ cumulative_bytes: int
+
+
+class StorageCompression(BaseModel):
+ compressed_count: int
+ uncompressed_count: int
+ original_bytes: int
+ current_bytes: int
+ bytes_saved: int
+
+
+class StorageMonthEntry(BaseModel):
+ month: str
+ bytes: int
+ count: int
+
+
+class StorageLargestFile(BaseModel):
+ path: str
+ size: int
+ timestamp: str
+
+
+class AnalyticsStorageResponse(BaseModel):
+ """Storage analytics breakdown"""
+
+ daily_data: list[StorageDailyData]
+ compression: StorageCompression
+ largest_files: list[StorageLargestFile]
+ storage_by_month: list[StorageMonthEntry]
+
+
+class HourlyDistribution(BaseModel):
+ hour: int
+ count: int
+
+
+class DailyDistribution(BaseModel):
+ day: str
+ count: int
+
+
+class WeeklyTrend(BaseModel):
+ week: str
+ count: int
+
+
+class HeatmapItem(BaseModel):
+ date: str
+ day_of_week: int
+ day_label: str
+ hour: int
+ count: int
+
+
+class AnalyticsActivityResponse(BaseModel):
+ """Activity distribution analytics"""
+
+ heatmap_data: list[HeatmapItem]
+ hourly_distribution: list[HourlyDistribution]
+ daily_distribution: list[DailyDistribution]
+ weekly_trend: list[WeeklyTrend]
+ peak_hour: int
+ peak_day: str
+ total_in_period: int
+
+
+class WeekHeatmapItem(BaseModel):
+ date: str
+ day_of_week: int
+ day_label: str
+ hour: int
+ count: int
+ screenshot_ids: list[int]
+
+
+class WeekPeak(BaseModel):
+ day: str
+ hour: int
+ count: int
+
+
+class AnalyticsActivityWeekResponse(BaseModel):
+ """Week-based activity heatmap"""
+
+ week_start: str
+ week_end: str
+ week_offset: int
+ heatmap_data: list[WeekHeatmapItem]
+ total_screenshots: int
+ peak: WeekPeak | None
+
+
+class GapEntry(BaseModel):
+ start_time: str
+ end_time: str
+ duration_seconds: int
+ type: str
+
+
+class AnalyticsGapsResponse(BaseModel):
+ """Timeline gaps analytics"""
+
+ gaps: list[GapEntry]
+ total_gap_time_seconds: int
+ longest_gap_seconds: int
+ avg_gap_seconds: int
+ gap_count: int
+
+
+class SizeDistributionEntry(BaseModel):
+ range: str
+ count: int
+
+
+class QualityCompressionStats(BaseModel):
+ compressed_count: int
+ uncompressed_count: int
+ original_bytes_before_compression: int
+
+
+class AnalyticsQualityResponse(BaseModel):
+ """File quality and size distribution"""
+
+ avg_file_size: int
+ min_file_size: int
+ max_file_size: int
+ median_file_size: int
+ size_distribution: list[SizeDistributionEntry]
+ total_files: int
+ compression_stats: QualityCompressionStats
+
+
+class TrendsDailyData(BaseModel):
+ date: str
+ count: int
+ moving_avg: float
+
+
+class TrendsDayInfo(BaseModel):
+ date: str | None
+ count: int
+
+
+class TrendsSummary(BaseModel):
+ total_screenshots: int
+ synced_count: int
+ ocr_processed_count: int
+ avg_per_day: float
+ days_with_recordings: int
+ best_day: TrendsDayInfo | None
+ worst_day: TrendsDayInfo | None
+
+
+class AnalyticsTrendsResponse(BaseModel):
+ """Screenshot trends over time"""
+
+ daily_data: list[TrendsDailyData]
+ summary: TrendsSummary
+
+
class EnhancedSetupStatus(BaseModel):
"""Comprehensive setup status including models and migration"""
diff --git a/web/src/app/analytics/page.tsx b/web/src/app/analytics/page.tsx
new file mode 100644
index 0000000..982de30
--- /dev/null
+++ b/web/src/app/analytics/page.tsx
@@ -0,0 +1,1052 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import Link from 'next/link';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ AreaChart,
+ Area,
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ PieChart,
+ Pie,
+ Cell,
+} from 'recharts';
+import {
+ getAnalyticsOverview,
+ getAnalyticsStorage,
+ getAnalyticsActivity,
+ getAnalyticsGaps,
+ getAnalyticsQuality,
+ getAnalyticsTrends,
+ getAnalyticsActivityWeek,
+} from '@/lib/api';
+import type {
+ AnalyticsOverview,
+ AnalyticsStorage,
+ AnalyticsActivity,
+ AnalyticsGaps,
+ AnalyticsQuality,
+ AnalyticsTrends,
+ AnalyticsActivityWeek,
+} from '@/types';
+import { useRouter } from 'next/navigation';
+
+// Animated counter component
+function AnimatedCounter({
+ value,
+ duration = 1000,
+ formatter = (v: number) => v.toLocaleString(),
+}: {
+ value: number;
+ duration?: number;
+ formatter?: (v: number) => string;
+}) {
+ const [displayValue, setDisplayValue] = useState(0);
+
+ useEffect(() => {
+ let startTime: number;
+ let animationFrame: number;
+
+ const animate = (currentTime: number) => {
+ if (!startTime) startTime = currentTime;
+ const progress = Math.min((currentTime - startTime) / duration, 1);
+
+ // Ease out cubic
+ const easeOut = 1 - Math.pow(1 - progress, 3);
+ setDisplayValue(Math.floor(value * easeOut));
+
+ if (progress < 1) {
+ animationFrame = requestAnimationFrame(animate);
+ }
+ };
+
+ animationFrame = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(animationFrame);
+ }, [value, duration]);
+
+ return {formatter(displayValue)};
+}
+
+// Format bytes to human readable
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
+
+// Format duration
+function formatDuration(seconds: number): string {
+ if (seconds < 60) return `${seconds}s`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
+ return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
+}
+
+// Format timestamp
+function formatTimestamp(ts: string): string {
+ if (!ts || ts.length !== 12) return '';
+ const year = 2000 + parseInt(ts.slice(0, 2));
+ const month = parseInt(ts.slice(2, 4));
+ const day = parseInt(ts.slice(4, 6));
+ const hour = parseInt(ts.slice(6, 8));
+ const minute = parseInt(ts.slice(8, 10));
+ const date = new Date(year, month - 1, day, hour, minute);
+ return date.toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+// Info tooltip component
+function InfoTooltip({ text }: { text: string }) {
+ const [isVisible, setIsVisible] = useState(false);
+
+ return (
+
+
+
+ {isVisible && (
+
+ {text}
+
+
+ )}
+
+
+ );
+}
+
+// Card wrapper with animation
+function Card({
+ children,
+ className = '',
+ delay = 0
+}: {
+ children: React.ReactNode;
+ className?: string;
+ delay?: number;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Stat card component
+function StatCard({
+ label,
+ value,
+ formatter = (v: number) => v.toLocaleString(),
+ trend,
+ icon,
+ delay = 0,
+}: {
+ label: string;
+ value: number;
+ formatter?: (v: number) => string;
+ trend?: { value: number; isPositive: boolean };
+ icon?: React.ReactNode;
+ delay?: number;
+}) {
+ return (
+
+
+
+
{label}
+
+
+
+ {trend && (
+
+
+
{Math.abs(trend.value)}% from yesterday
+
+ )}
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+ );
+}
+
+// Week Activity Heatmap component with navigation
+function WeekActivityHeatmap({
+ weekOffset,
+ onWeekChange,
+ onCellClick,
+}: {
+ weekOffset: number;
+ onWeekChange: (offset: number) => void;
+ onCellClick: (date: string, hour: number, screenshotIds: number[]) => void;
+}) {
+ const [weekData, setWeekData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ const loadWeekData = async () => {
+ setIsLoading(true);
+ try {
+ const data = await getAnalyticsActivityWeek(weekOffset);
+ setWeekData(data);
+ } catch (err) {
+ console.error('Failed to load week data:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ loadWeekData();
+ }, [weekOffset]);
+
+ // Create a lookup map for quick access: key = "date-hour" → cell data
+ const dataMap = new Map();
+ let maxCount = 1;
+
+ if (weekData) {
+ weekData.heatmap_data.forEach(d => {
+ const key = `${d.date}-${d.hour}`;
+ dataMap.set(key, { count: d.count, screenshot_ids: d.screenshot_ids });
+ if (d.count > maxCount) maxCount = d.count;
+ });
+ }
+
+ const getIntensity = (count: number): string => {
+ if (count === 0) return 'bg-[#1e1e1e]';
+ const ratio = count / maxCount;
+ if (ratio < 0.25) return 'bg-[#86efac]/20';
+ if (ratio < 0.5) return 'bg-[#86efac]/40';
+ if (ratio < 0.75) return 'bg-[#86efac]/60';
+ return 'bg-[#86efac]/90';
+ };
+
+ // Parse "YYYY-MM-DD" as local date (not UTC) to avoid timezone shift
+ const parseLocalDate = (dateStr: string): Date => {
+ const [y, m, d] = dateStr.split('-').map(Number);
+ return new Date(y, m - 1, d);
+ };
+
+ // Get array of dates for the week (Mon-Sun)
+ const getWeekDates = (): { date: string; label: string; dayLabel: string }[] => {
+ if (!weekData) return [];
+ const dates: { date: string; label: string; dayLabel: string }[] = [];
+ const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
+
+ const start = parseLocalDate(weekData.week_start);
+ for (let i = 0; i < 7; i++) {
+ const d = new Date(start);
+ d.setDate(start.getDate() + i);
+ const yyyy = d.getFullYear();
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ dates.push({
+ date: `${yyyy}-${mm}-${dd}`,
+ label: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
+ dayLabel: dayLabels[i],
+ });
+ }
+ return dates;
+ };
+
+ const weekDates = getWeekDates();
+
+ const formatDateRange = () => {
+ if (!weekData) return '';
+ const start = parseLocalDate(weekData.week_start);
+ const end = parseLocalDate(weekData.week_end);
+ return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Week Navigation */}
+
+
+
+
{formatDateRange()}
+
+ {weekOffset === 0 ? 'Current Week' : weekOffset === -1 ? 'Last Week' : `${Math.abs(weekOffset)} weeks ago`}
+
+
+
+
+
+ {/* Heatmap Grid */}
+
+
+ {/* Hour labels */}
+
+ {Array.from({ length: 24 }, (_, i) => (
+
+ {i % 3 === 0 ? i : ''}
+
+ ))}
+
+ {/* Grid */}
+ {weekDates.map((day) => (
+
+
{day.dayLabel}
+
{day.label}
+ {Array.from({ length: 24 }, (_, hour) => {
+ const cellData = dataMap.get(`${day.date}-${hour}`);
+ const count = cellData?.count || 0;
+ const screenshotIds = cellData?.screenshot_ids || [];
+ return (
+
count > 0 && onCellClick(day.date, hour, screenshotIds)}
+ className={`w-4 h-4 rounded-sm ${getIntensity(count)} transition-colors ${count > 0 ? 'hover:ring-1 hover:ring-[#86efac] cursor-pointer' : ''}`}
+ title={`${day.label} ${hour}:00 - ${count} screenshots`}
+ />
+ );
+ })}
+
+ ))}
+ {/* Legend */}
+
+
Less
+
+
+
+
+
+
More
+
+
+
+
+ {/* Week Stats */}
+ {weekData && (
+
+
+ {weekData.total_screenshots} screenshots this week
+
+ {weekData.peak && (
+
+ Peak: {weekData.peak.day} at {weekData.peak.hour}:00 ({weekData.peak.count})
+
+ )}
+
+ )}
+
+ );
+}
+
+// Gap Timeline component
+function GapTimeline({ gaps }: { gaps: AnalyticsGaps }) {
+ if (gaps.gaps.length === 0) {
+ return (
+
+
No significant gaps detected
+
+ );
+ }
+
+ return (
+
+ {gaps.gaps.slice(0, 10).map((gap, idx) => (
+
+
+
+
+ {formatDuration(gap.duration_seconds)}
+
+
+ {formatTimestamp(gap.start_time)} - {formatTimestamp(gap.end_time)}
+
+
+
+ ))}
+
+ );
+}
+
+export default function AnalyticsPage() {
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState
(null);
+ const [weekOffset, setWeekOffset] = useState(0);
+
+ // Data states
+ const [overview, setOverview] = useState(null);
+ const [storage, setStorage] = useState(null);
+ const [activity, setActivity] = useState(null);
+ const [gaps, setGaps] = useState(null);
+ const [quality, setQuality] = useState(null);
+ const [trends, setTrends] = useState(null);
+
+ // Handle clicking a heatmap cell - navigate to timeline with that hour's screenshots
+ const handleHeatmapCellClick = useCallback((date: string, hour: number, _screenshotIds: number[]) => {
+ // Navigate to timeline with date filter
+ // The timeline page will show screenshots from this specific hour
+ const startDate = new Date(`${date}T${hour.toString().padStart(2, '0')}:00:00`);
+ const endDate = new Date(startDate);
+ endDate.setHours(endDate.getHours() + 1);
+
+ // Format as YYMMDDHHMMSS for the API
+ const formatTimestamp = (d: Date): string => {
+ const yy = String(d.getFullYear()).slice(-2);
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ const hh = String(d.getHours()).padStart(2, '0');
+ const min = String(d.getMinutes()).padStart(2, '0');
+ const ss = String(d.getSeconds()).padStart(2, '0');
+ return `${yy}${mm}${dd}${hh}${min}${ss}`;
+ };
+
+ const startStr = formatTimestamp(startDate);
+ const endStr = formatTimestamp(endDate);
+
+ router.push(`/?start=${startStr}&end=${endStr}`);
+ }, [router]);
+
+ // Load all analytics data
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [
+ overviewData,
+ storageData,
+ activityData,
+ gapsData,
+ qualityData,
+ trendsData,
+ ] = await Promise.all([
+ getAnalyticsOverview(),
+ getAnalyticsStorage(30),
+ getAnalyticsActivity(12),
+ getAnalyticsGaps(5, 50),
+ getAnalyticsQuality(),
+ getAnalyticsTrends(30),
+ ]);
+
+ setOverview(overviewData);
+ setStorage(storageData);
+ setActivity(activityData);
+ setGaps(gapsData);
+ setQuality(qualityData);
+ setTrends(trendsData);
+ } catch (err) {
+ console.error('Failed to load analytics:', err);
+ setError('Failed to load analytics data');
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ // Calculate trend for today vs yesterday
+ const todayTrend = overview ? {
+ value: overview.screenshots_yesterday > 0
+ ? Math.round(((overview.screenshots_today - overview.screenshots_yesterday) / overview.screenshots_yesterday) * 100)
+ : 0,
+ isPositive: overview.screenshots_today >= overview.screenshots_yesterday,
+ } : undefined;
+
+ // Pie chart colors
+ const COLORS = ['#86efac', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Main Content */}
+
+
+ {isLoading && !overview ? (
+
+
+
+
Loading analytics...
+
+
+ ) : error ? (
+
+
+
{error}
+
+
+
+ ) : (
+
+ {/* Header Stats Row */}
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+ {/* Main Grid */}
+
+ {/* Storage Growth Chart */}
+
+
+
Storage Growth
+
+
+ {storage && storage.daily_data.length > 0 ? (
+
+
+
+
+
+
+
+
+
+ {
+ const d = new Date(value);
+ return d.getDate().toString();
+ }}
+ />
+ formatBytes(value)}
+ />
+ [formatBytes(value as number), 'Storage']}
+ labelFormatter={(label) => new Date(label).toLocaleDateString()}
+ />
+
+
+
+ ) : (
+
+ No storage data available
+
+ )}
+
+
+ {/* Activity Heatmap */}
+
+
+
Activity Heatmap
+
+
+
+
+
+ {/* Hourly Distribution */}
+
+
+
Hourly Distribution
+
+
+ {activity && activity.hourly_distribution.length > 0 ? (
+
+
+
+ `${v}h`}
+ />
+
+ [value, 'Screenshots']}
+ labelFormatter={(label) => `${label}:00`}
+ />
+
+
+
+ ) : (
+
+ No hourly data available
+
+ )}
+
+
+ {/* Recording Trends */}
+
+
+
Daily Recording Trend
+
+
+ {trends && trends.daily_data.length > 0 ? (
+
+
+
+
+
+
+
+
+
+ {
+ const d = new Date(value);
+ return d.getDate().toString();
+ }}
+ />
+
+ [
+ value,
+ name === 'count' ? 'Screenshots' : '7-day Avg',
+ ]}
+ labelFormatter={(label) => new Date(label).toLocaleDateString()}
+ />
+
+
+
+
+ ) : (
+
+ No trend data available
+
+ )}
+ {trends?.summary && (
+
+ Avg: {trends.summary.avg_per_day}/day
+ {trends.summary.best_day?.date && (
+
+ Best: {new Date(trends.summary.best_day.date).toLocaleDateString()} ({trends.summary.best_day.count})
+
+ )}
+
+ )}
+
+
+ {/* Timeline Gaps */}
+
+
+
+
Timeline Gaps
+
+
+ {gaps && gaps.gap_count > 0 && (
+
+ {gaps.gap_count} gaps
+
+ )}
+
+ {gaps ? (
+ <>
+
+ {gaps.gap_count > 0 && (
+
+
+
Total Gap Time
+
+ {formatDuration(gaps.total_gap_time_seconds)}
+
+
+
+
Longest
+
+ {formatDuration(gaps.longest_gap_seconds)}
+
+
+
+
Average
+
+ {formatDuration(gaps.avg_gap_seconds)}
+
+
+
+ )}
+ >
+ ) : (
+
+ No gap data available
+
+ )}
+
+
+ {/* File Size Distribution */}
+
+
+
File Size Distribution
+
+
+ {quality && quality.size_distribution.length > 0 ? (
+
+
+
+
+ d.count > 0)}
+ cx="50%"
+ cy="50%"
+ innerRadius={35}
+ outerRadius={60}
+ paddingAngle={2}
+ dataKey="count"
+ >
+ {quality.size_distribution.filter(d => d.count > 0).map((_, index) => (
+ |
+ ))}
+
+ [
+ `${value} (${(props.payload as { percentage?: number })?.percentage || 0}%)`,
+ '',
+ ]}
+ />
+
+
+
+
+ {quality.size_distribution.map((item, idx) => (
+
+ ))}
+
+
+ ) : (
+
+ No quality data available
+
+ )}
+
+
+ {/* Compression Stats */}
+
+
+
Compression Status
+
+
+ {storage?.compression ? (
+
+ {/* Progress bar */}
+
+
+ Compressed
+
+ {storage.compression.compressed_count} / {storage.compression.compressed_count + storage.compression.uncompressed_count}
+
+
+
+
+ {/* Stats */}
+
+
+
Original Size
+
+ {formatBytes(storage.compression.original_bytes)}
+
+
+
+
Space Saved
+
+ {formatBytes(storage.compression.bytes_saved)}
+
+
+
+
+ ) : (
+
+ No compression data available
+
+ )}
+
+
+ {/* Processing Stats */}
+
+
+
Processing Status
+
+
+
+ {/* OCR Progress */}
+
+
+ OCR Processed
+
+ {overview?.ocr_processed_count || 0} / {overview?.total_screenshots || 0}
+
+
+
+
+ {/* Quick stats */}
+
+
+
+ {overview?.screenshots_this_week || 0}
+
+
This Week
+
+
+
+ {overview?.compressed_count || 0}
+
+
Compressed
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
index ee6949f..acc3de1 100644
--- a/web/src/app/page.tsx
+++ b/web/src/app/page.tsx
@@ -1,7 +1,8 @@
'use client';
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef, useCallback, Suspense } from 'react';
import Link from 'next/link';
+import { useSearchParams, useRouter } from 'next/navigation';
import {
getStatus,
getSyncStatus,
@@ -46,9 +47,16 @@ const SAFE_MODE_LEVELS = [
{ value: 'extreme', label: 'Extreme' },
];
-export default function Home() {
+function HomeContent() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
const [activeView, setActiveView] = useState<'timeline' | 'search'>('timeline');
const [status, setStatus] = useState(null);
+
+ // Timeline date filter from URL params (for heatmap navigation)
+ const [timelineStartDate, setTimelineStartDate] = useState();
+ const [timelineEndDate, setTimelineEndDate] = useState();
+ const [showDateRangePicker, setShowDateRangePicker] = useState(false);
const [syncStatus, setSyncStatus] = useState(null);
const [snapshots, setSnapshots] = useState([]);
const [densityBuckets, setDensityBuckets] = useState([]);
@@ -147,6 +155,29 @@ export default function Home() {
}
}, []);
+ // Read URL params for timeline date filter (from analytics heatmap clicks)
+ useEffect(() => {
+ const start = searchParams.get('start');
+ const end = searchParams.get('end');
+ if (start && end) {
+ // Validate: must be 12-digit YYMMDDHHMMSS timestamps and start <= end
+ const isValidTs = (s: string) => /^\d{12}$/.test(s);
+ if (isValidTs(start) && isValidTs(end) && start <= end) {
+ setTimelineStartDate(start);
+ setTimelineEndDate(end);
+ setActiveView('search');
+ }
+ }
+ }, [searchParams]);
+
+ // Clear timeline date filter
+ const clearTimelineFilter = useCallback(() => {
+ setTimelineStartDate(undefined);
+ setTimelineEndDate(undefined);
+ // Clear URL params
+ router.push('/', { scroll: false });
+ }, [router]);
+
// Fetch incognito status
useEffect(() => {
const fetchIncognitoStatus = async () => {
@@ -288,7 +319,7 @@ export default function Home() {
setGalleryOffset(0);
setHasMoreGallery(true);
setHasMoreGalleryBefore(false); // Starting from offset 0, nothing before
- const data = await getScreenshots(GALLERY_PAGE_SIZE, 0, undefined, undefined, visibilityFilter);
+ const data = await getScreenshots(GALLERY_PAGE_SIZE, 0, timelineStartDate, timelineEndDate, visibilityFilter);
if (data?.screenshots) {
setGallerySnapshots(data.screenshots);
setGalleryTotal(data.total);
@@ -300,7 +331,7 @@ export default function Home() {
};
fetchGallery();
}
- }, [activeView, query, visibilityFilter]);
+ }, [activeView, query, visibilityFilter, timelineStartDate, timelineEndDate]);
// Load more gallery images for infinite scroll
// galleryOffset is the START of the loaded window, so next offset is galleryOffset + current count
@@ -311,7 +342,7 @@ export default function Home() {
try {
// Calculate next offset based on start offset + current loaded count
const nextOffset = galleryOffset + gallerySnapshots.length;
- const data = await getScreenshots(GALLERY_PAGE_SIZE, nextOffset, undefined, undefined, visibilityFilter);
+ const data = await getScreenshots(GALLERY_PAGE_SIZE, nextOffset, timelineStartDate, timelineEndDate, visibilityFilter);
if (data?.screenshots?.length) {
setGallerySnapshots(prev => [...prev, ...data.screenshots]);
@@ -326,7 +357,7 @@ export default function Home() {
} finally {
setIsLoadingMore(false);
}
- }, [isLoadingMore, hasMoreGallery, galleryOffset, visibilityFilter, query, gallerySnapshots.length]);
+ }, [isLoadingMore, hasMoreGallery, galleryOffset, visibilityFilter, query, gallerySnapshots.length, timelineStartDate, timelineEndDate]);
// Load more gallery images before current position (for bidirectional scroll)
const loadMoreGalleryBefore = useCallback(async () => {
@@ -344,7 +375,7 @@ export default function Home() {
// This is more reliable than tracking scrollHeight which can change if user scrolls during fetch
const firstVisibleId = gallerySnapshots[0]?.id;
- const data = await getScreenshots(loadCount, newOffset, undefined, undefined, visibilityFilter);
+ const data = await getScreenshots(loadCount, newOffset, timelineStartDate, timelineEndDate, visibilityFilter);
if (data?.screenshots?.length) {
// Prepend to existing screenshots
@@ -369,7 +400,7 @@ export default function Home() {
} finally {
setIsLoadingMoreBefore(false);
}
- }, [isLoadingMoreBefore, hasMoreGalleryBefore, galleryOffset, visibilityFilter, query, gallerySnapshots]);
+ }, [isLoadingMoreBefore, hasMoreGalleryBefore, galleryOffset, visibilityFilter, query, gallerySnapshots, timelineStartDate, timelineEndDate]);
// Intersection observer for infinite scroll (load more at bottom)
// Note: We check isLoadingMore and call loadMoreGallery inside the callback,
@@ -762,7 +793,7 @@ export default function Home() {
const data = await search(query, 50, safeMode, safeModeLevel, searchStartDate, searchEndDate, visibilityFilter, searchMode);
setSearchResults(data?.results || []);
} else {
- const data = await getScreenshots(100, 0, undefined, undefined, visibilityFilter);
+ const data = await getScreenshots(100, 0, timelineStartDate, timelineEndDate, visibilityFilter);
if (data?.screenshots) {
setGallerySnapshots(data.screenshots);
setGalleryTotal(data.total);
@@ -834,7 +865,7 @@ export default function Home() {
const startOffset = Math.max(0, targetOffset - Math.floor(GALLERY_PAGE_SIZE / 2));
// Load screenshots around the target position
- const data = await getScreenshots(GALLERY_PAGE_SIZE * 2, startOffset, undefined, undefined, visibilityFilter);
+ const data = await getScreenshots(GALLERY_PAGE_SIZE * 2, startOffset, timelineStartDate, timelineEndDate, visibilityFilter);
if (data?.screenshots) {
// Verify target is in loaded data
@@ -889,7 +920,7 @@ export default function Home() {
// Fallback to loading from start
try {
- const data = await getScreenshots(GALLERY_PAGE_SIZE, 0, undefined, undefined, visibilityFilter);
+ const data = await getScreenshots(GALLERY_PAGE_SIZE, 0, timelineStartDate, timelineEndDate, visibilityFilter);
if (data?.screenshots) {
setGallerySnapshots(data.screenshots);
setGalleryTotal(data.total);
@@ -901,7 +932,7 @@ export default function Home() {
console.error('Fallback gallery load failed:', fallbackErr);
}
}
- }, [visibilityFilter]);
+ }, [visibilityFilter, timelineStartDate, timelineEndDate]);
// Navigate to a specific screenshot in the timeline
const navigateToTimeline = useCallback(async (screenshot: Screenshot) => {
@@ -1014,6 +1045,12 @@ export default function Home() {
>
Search
+
+ Analytics
+
@@ -1265,9 +1302,14 @@ export default function Home() {
{['1h', '24h', '7d', '30d', 'all'].map((preset) => (
))}
+ {/* Custom date range button/chip */}
+ {timelineStartDate && timelineEndDate ? (
+
+ ) : (
+
+ )}
{/* Safe mode controls */}
@@ -1321,7 +1406,7 @@ export default function Home() {
{searchTime != null && {searchTime.toFixed(0)}ms}
>
) : (
- <>{galleryTotal} snapshots (gallery)>
+ <>{galleryTotal} snapshots {timelineStartDate ? '(filtered)' : '(gallery)'}>
)}
@@ -1643,6 +1728,148 @@ export default function Home() {
isLoading={isBulkOperationLoading}
/>
+ {/* Date Range Picker Modal */}
+ {showDateRangePicker && (
+
+
setShowDateRangePicker(false)}
+ />
+
+
Set Date Range
+
+
+ {/* Start Date/Time */}
+
+
+
+ {
+ if (!timelineStartDate || timelineStartDate.length !== 12) {
+ const d = new Date();
+ d.setDate(d.getDate() - 1);
+ return d.toISOString().split('T')[0];
+ }
+ const year = 2000 + parseInt(timelineStartDate.slice(0, 2));
+ return `${year}-${timelineStartDate.slice(2, 4)}-${timelineStartDate.slice(4, 6)}`;
+ })()}
+ className="flex-1 bg-[#1a1a1a] text-[#f5f5f5] text-sm px-3 py-2 rounded-lg border border-[#333] focus:border-[#86efac]/50 focus:outline-none"
+ />
+
+ :
+
+
+
+
+ {/* End Date/Time */}
+
+
+
+ {
+ if (!timelineEndDate || timelineEndDate.length !== 12) {
+ return new Date().toISOString().split('T')[0];
+ }
+ const year = 2000 + parseInt(timelineEndDate.slice(0, 2));
+ return `${year}-${timelineEndDate.slice(2, 4)}-${timelineEndDate.slice(4, 6)}`;
+ })()}
+ className="flex-1 bg-[#1a1a1a] text-[#f5f5f5] text-sm px-3 py-2 rounded-lg border border-[#333] focus:border-[#86efac]/50 focus:outline-none"
+ />
+
+ :
+
+
+
+
+ {/* Validation error */}
+
+ Start date/time must be before end date/time
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ )}
+
{/* Delete Confirmation Dialog */}
);
}
+
+// Wrapper component with Suspense for useSearchParams
+export default function Home() {
+ return (
+
+
+
+ }>
+
+
+ );
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 1fcb400..9e3c6db 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -15,6 +15,13 @@ import type {
IncognitoStatus,
BulkOperationResponse,
SearchMode,
+ AnalyticsOverview,
+ AnalyticsStorage,
+ AnalyticsActivity,
+ AnalyticsGaps,
+ AnalyticsQuality,
+ AnalyticsTrends,
+ AnalyticsActivityWeek,
} from '@/types';
const API_BASE = '/api/v1';
@@ -274,3 +281,32 @@ export async function setIncognitoMode(durationMinutes: number): Promise> {
return fetchApi('/incognito/stop', { method: 'POST' });
}
+
+// Analytics
+export async function getAnalyticsOverview(): Promise {
+ return fetchApi('/analytics/overview');
+}
+
+export async function getAnalyticsStorage(days: number = 30): Promise {
+ return fetchApi(`/analytics/storage?days=${days}`);
+}
+
+export async function getAnalyticsActivity(weeks: number = 12): Promise {
+ return fetchApi(`/analytics/activity?weeks=${weeks}`);
+}
+
+export async function getAnalyticsGaps(minGapMinutes: number = 30, limit: number = 50): Promise {
+ return fetchApi(`/analytics/gaps?min_gap_minutes=${minGapMinutes}&limit=${limit}`);
+}
+
+export async function getAnalyticsQuality(): Promise {
+ return fetchApi('/analytics/quality');
+}
+
+export async function getAnalyticsTrends(days: number = 30): Promise {
+ return fetchApi(`/analytics/trends?days=${days}`);
+}
+
+export async function getAnalyticsActivityWeek(weekOffset: number = 0): Promise {
+ return fetchApi(`/analytics/activity-week?week_offset=${weekOffset}`);
+}
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index b6d5a10..51a0f06 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -160,3 +160,155 @@ export interface BulkOperationResponse {
affected_count: number;
message: string;
}
+
+// Analytics Types
+export interface AnalyticsOverview {
+ total_screenshots: number;
+ total_storage_bytes: number;
+ compressed_count: number;
+ avg_file_size: number;
+ screenshots_today: number;
+ screenshots_yesterday: number;
+ screenshots_this_week: number;
+ ocr_processed_count: number;
+}
+
+export interface DailyStorageData {
+ date: string;
+ screenshots: number;
+ bytes_added: number;
+ cumulative_bytes: number;
+}
+
+export interface LargestFile {
+ id: number;
+ path: string;
+ timestamp: string;
+ size_bytes: number;
+}
+
+export interface StorageByMonth {
+ month: string;
+ count: number;
+}
+
+export interface AnalyticsStorage {
+ daily_data: DailyStorageData[];
+ compression: {
+ compressed_count: number;
+ uncompressed_count: number;
+ original_bytes: number;
+ current_bytes: number;
+ bytes_saved: number;
+ };
+ largest_files: LargestFile[];
+ storage_by_month: StorageByMonth[];
+}
+
+export interface HeatmapData {
+ day_of_week: number;
+ hour: number;
+ count: number;
+}
+
+export interface HourlyData {
+ hour: number;
+ count: number;
+}
+
+export interface DailyData {
+ day: number;
+ count: number;
+}
+
+export interface WeeklyTrend {
+ week: string;
+ count: number;
+}
+
+export interface AnalyticsActivity {
+ heatmap_data: HeatmapData[];
+ hourly_distribution: HourlyData[];
+ daily_distribution: DailyData[];
+ weekly_trend: WeeklyTrend[];
+ peak_hour: number;
+ peak_day: string;
+ total_in_period: number;
+}
+
+export interface Gap {
+ start_time: string;
+ end_time: string;
+ duration_seconds: number;
+ type: 'gap' | 'incognito';
+}
+
+export interface AnalyticsGaps {
+ gaps: Gap[];
+ total_gap_time_seconds: number;
+ longest_gap_seconds: number;
+ avg_gap_seconds: number;
+ gap_count: number;
+}
+
+export interface SizeDistribution {
+ label: string;
+ count: number;
+ percentage: number;
+}
+
+export interface AnalyticsQuality {
+ avg_file_size: number;
+ min_file_size: number;
+ max_file_size: number;
+ median_file_size: number;
+ size_distribution: SizeDistribution[];
+ total_files: number;
+ compression_stats: {
+ compressed_count: number;
+ uncompressed_count: number;
+ original_bytes_before_compression: number;
+ };
+}
+
+export interface TrendDailyData {
+ date: string;
+ count: number;
+ moving_avg: number;
+}
+
+export interface AnalyticsTrends {
+ daily_data: TrendDailyData[];
+ summary: {
+ total_screenshots: number;
+ synced_count: number;
+ ocr_processed_count: number;
+ avg_per_day: number;
+ days_with_recordings: number;
+ best_day: { date: string | null; count: number };
+ worst_day: { date: string | null; count: number } | null;
+ };
+}
+
+// Week-specific activity data
+export interface WeekHeatmapCell {
+ date: string;
+ day_of_week: number;
+ day_label: string;
+ hour: number;
+ count: number;
+ screenshot_ids: number[];
+}
+
+export interface AnalyticsActivityWeek {
+ week_start: string;
+ week_end: string;
+ week_offset: number;
+ heatmap_data: WeekHeatmapCell[];
+ total_screenshots: number;
+ peak: {
+ day: string;
+ hour: number;
+ count: number;
+ } | null;
+}