From 30ca0797b421f6345b63cab745b98bfa3c328757 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Fri, 30 Jan 2026 12:58:30 +0530 Subject: [PATCH 1/6] feat: Add analytics dashboard with activity insights - Add analytics page with overview stats, storage breakdown, and trends - Implement week-based activity heatmap with navigation and clickable cells - Add timeline gaps detection showing both regular and incognito periods - Include date range filter with custom picker on main timeline page - Add API endpoints for all analytics data (overview, activity, gaps, etc.) --- api/main.py | 3 +- api/routes/analytics.py | 753 +++++++++++++++++++++++ web/src/app/analytics/page.tsx | 1043 ++++++++++++++++++++++++++++++++ web/src/app/page.tsx | 295 ++++++++- web/src/lib/api.ts | 36 ++ web/src/types/index.ts | 152 +++++ 6 files changed, 2266 insertions(+), 16 deletions(-) create mode 100644 api/routes/analytics.py create mode 100644 web/src/app/analytics/page.tsx 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..27b7fe3 --- /dev/null +++ b/api/routes/analytics.py @@ -0,0 +1,753 @@ +""" +Analytics API Routes +Provides aggregated analytics data for the dashboard +""" + +import contextlib +from datetime import datetime, timedelta +from pathlib import Path +from typing import TypedDict + +from fastapi import APIRouter, Query + +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.""" + 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) + + +def format_timestamp(dt: datetime) -> str: + """Convert datetime back to YYMMDDHHMMSS format.""" + return dt.strftime("%y%m%d%H%M%S") + + +@router.get("/overview") +async 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") +async 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 lookup of file sizes by date from the database timestamps + cur.execute( + """ + SELECT timestamp, image_path + FROM screenshots + WHERE timestamp >= ? + ORDER BY timestamp ASC + """, + (format_timestamp(start_date),), + ) + rows = cur.fetchall() + + # Group by date + date_sizes: dict[str, list[int]] = {} + for row in rows: + ts = row[0] + path = Path(row[1]) + if len(ts) >= 6: + date_key = f"20{ts[0:2]}-{ts[2:4]}-{ts[4:6]}" + if path.exists(): + size = path.stat().st_size + 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") +async 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") +async 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") +async 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"), +): + """ + 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). + """ + with db.cursor() as cur: + # Get VISIBLE screenshots only for gap detection + cur.execute( + """ + SELECT timestamp + FROM screenshots + WHERE is_hidden = 0 + ORDER BY timestamp ASC + """ + ) + visible_rows = cur.fetchall() + + # Get HIDDEN screenshots for incognito detection + cur.execute( + """ + SELECT timestamp + FROM screenshots + WHERE is_hidden = 1 + ORDER BY timestamp ASC + """ + ) + 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 list of hidden timestamps for checking incognito periods + hidden_timestamps = [] + for (ts,) in hidden_rows: + with contextlib.suppress(Exception): + hidden_timestamps.append(parse_timestamp(ts)) + + 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: + # Check if there are hidden screenshots during this gap period + # If so, it's an incognito period + has_hidden_during_gap = False + for hidden_dt in hidden_timestamps: + if prev_dt < hidden_dt < curr_dt: + has_hidden_during_gap = True + break + + 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 + + # Separate incognito and regular gaps, then merge to ensure both types appear + 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 + ) + + # Take up to half the limit from each type, then fill remaining + half_limit = limit // 2 + selected_incognito = incognito_gaps[:half_limit] + selected_regular = regular_gaps[:half_limit] + + # Fill remaining slots from whichever has more + remaining = limit - len(selected_incognito) - len(selected_regular) + if remaining > 0: + remaining_incognito = incognito_gaps[half_limit : half_limit + remaining] + remaining_regular = regular_gaps[half_limit : half_limit + remaining] + # Add from whichever has longer gaps + combined_remaining = sorted( + remaining_incognito + remaining_regular, key=lambda x: x["duration_seconds"], reverse=True + ) + selected_incognito.extend([g for g in combined_remaining[:remaining] if g["type"] == "incognito"]) + selected_regular.extend([g for g in combined_remaining[:remaining] if g["type"] == "gap"]) + + # Merge and sort by duration + gaps = sorted(selected_incognito + selected_regular, key=lambda x: x["duration_seconds"], reverse=True) + + # Calculate statistics + total_gap_time = sum(g["duration_seconds"] for g in gaps) + longest_gap = gaps[0]["duration_seconds"] if gaps else 0 + avg_gap = total_gap_time / len(gaps) if gaps else 0 + + return { + "gaps": gaps, + "total_gap_time_seconds": total_gap_time, + "longest_gap_seconds": longest_gap, + "avg_gap_seconds": int(avg_gap), + "gap_count": len(gaps), + } + + +@router.get("/quality") +async 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") +async 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/web/src/app/analytics/page.tsx b/web/src/app/analytics/page.tsx new file mode 100644 index 0000000..4943086 --- /dev/null +++ b/web/src/app/analytics/page.tsx @@ -0,0 +1,1043 @@ +'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'; + }; + + // 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 = new Date(weekData.week_start); + for (let i = 0; i < 7; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + dates.push({ + date: d.toISOString().split('T')[0], + label: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + dayLabel: dayLabels[i], + }); + } + return dates; + }; + + const weekDates = getWeekDates(); + + const formatDateRange = () => { + if (!weekData) return ''; + const start = new Date(weekData.week_start); + const end = new Date(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 */} +
+
+ {/* Left - Logo */} +
+ + LiveRecall + + + {/* Nav Tabs */} + +
+ + {/* Right - Settings */} +
+ + + + + + + +
+
+
+ + {/* 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) => ( +
+
+
+ {item.label} +
+ {item.count} +
+ ))} +
+
+ ) : ( +
+ 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..28f93d5 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,26 @@ 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) { + setTimelineStartDate(start); + setTimelineEndDate(end); + // Switch to search/gallery view to show filtered results + 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 +316,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 +328,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 +339,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 +354,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 +372,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 +397,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 +790,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 +862,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 +917,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 +929,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 +1042,12 @@ export default function Home() { > Search + + Analytics +
@@ -1265,9 +1299,14 @@ export default function Home() { {['1h', '24h', '7d', '30d', 'all'].map((preset) => ( ))} + {/* Custom date range button/chip */} + {timelineStartDate && timelineEndDate ? ( + + ) : ( + + )}
{/* Safe mode controls */}
@@ -1321,7 +1403,7 @@ export default function Home() { {searchTime != null && {searchTime.toFixed(0)}ms} ) : ( - <>{galleryTotal} snapshots (gallery) + <>{galleryTotal} snapshots {timelineStartDate ? '(filtered)' : '(gallery)'} )}
@@ -1548,6 +1630,34 @@ export default function Home() { View Text +
)} @@ -1643,6 +1753,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; +} From 37bbc13ab65672d6593ae9b4cd945f75e3a3123d Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sat, 14 Feb 2026 13:11:18 +0530 Subject: [PATCH 2/6] fix: Interleave incognito and regular gaps in timeline Previously gaps were sorted purely by duration, causing shorter incognito gaps to be pushed below longer overnight gaps. Now interleaves both types so purple (incognito) and yellow (regular) gaps are both visible at the top of the list. --- api/routes/analytics.py | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/api/routes/analytics.py b/api/routes/analytics.py index 27b7fe3..eb6a32c 100644 --- a/api/routes/analytics.py +++ b/api/routes/analytics.py @@ -535,7 +535,7 @@ async def get_gap_analytics( prev_ts = curr_ts - # Separate incognito and regular gaps, then merge to ensure both types appear + # 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 ) @@ -543,25 +543,19 @@ async def get_gap_analytics( [g for g in gaps if g["type"] == "gap"], key=lambda x: x["duration_seconds"], reverse=True ) - # Take up to half the limit from each type, then fill remaining - half_limit = limit // 2 - selected_incognito = incognito_gaps[:half_limit] - selected_regular = regular_gaps[:half_limit] - - # Fill remaining slots from whichever has more - remaining = limit - len(selected_incognito) - len(selected_regular) - if remaining > 0: - remaining_incognito = incognito_gaps[half_limit : half_limit + remaining] - remaining_regular = regular_gaps[half_limit : half_limit + remaining] - # Add from whichever has longer gaps - combined_remaining = sorted( - remaining_incognito + remaining_regular, key=lambda x: x["duration_seconds"], reverse=True - ) - selected_incognito.extend([g for g in combined_remaining[:remaining] if g["type"] == "incognito"]) - selected_regular.extend([g for g in combined_remaining[:remaining] if g["type"] == "gap"]) - - # Merge and sort by duration - gaps = sorted(selected_incognito + selected_regular, 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 # Calculate statistics total_gap_time = sum(g["duration_seconds"] for g in gaps) From b262318981930aaa102ecc27271ab0932157b459 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sat, 14 Feb 2026 13:36:09 +0530 Subject: [PATCH 3/6] chore: Remove incomplete Find Similar button Placeholder image search feature is not ready yet - will be implemented properly with CLIP vector similarity on a separate branch. --- web/src/app/page.tsx | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 28f93d5..0016eaa 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1630,34 +1630,6 @@ function HomeContent() { View Text -
)} From 0cc48cd16cd2c2fc8f4e55dbccf6b36b8b0bab77 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sat, 14 Feb 2026 15:07:30 +0530 Subject: [PATCH 4/6] fix: Address code review findings for analytics - Add try-except to parse_timestamp for malformed data - Validate URL date params (12-digit format, start <= end) - Use bisect for O(log n) incognito gap detection instead of O(n*m) - Batch file size lookups via single directory scan instead of per-row stat --- api/routes/analytics.py | 53 ++++++++++++++++++++++++----------------- web/src/app/page.tsx | 11 +++++---- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/api/routes/analytics.py b/api/routes/analytics.py index eb6a32c..6e48439 100644 --- a/api/routes/analytics.py +++ b/api/routes/analytics.py @@ -3,6 +3,7 @@ Provides aggregated analytics data for the dashboard """ +import bisect import contextlib from datetime import datetime, timedelta from pathlib import Path @@ -34,15 +35,18 @@ class DailyDataPoint(TypedDict): def parse_timestamp(ts: str) -> datetime: """Convert YYMMDDHHMMSS to datetime.""" - if len(ts) != 12: + 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() - 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) def format_timestamp(dt: datetime) -> str: @@ -128,7 +132,15 @@ async def get_storage_analytics( daily_data = [] - # Build a lookup of file sizes by date from the database timestamps + # 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 @@ -140,15 +152,15 @@ async def get_storage_analytics( ) rows = cur.fetchall() - # Group by date + # 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] - path = Path(row[1]) + image_path = row[1] if len(ts) >= 6: date_key = f"20{ts[0:2]}-{ts[2:4]}-{ts[4:6]}" - if path.exists(): - size = path.stat().st_size + 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) @@ -494,11 +506,12 @@ async def get_gap_analytics( "gap_count": 0, } - # Build list of hidden timestamps for checking incognito periods - hidden_timestamps = [] + # 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 = [] @@ -514,13 +527,9 @@ async def get_gap_analytics( gap_seconds = (curr_dt - prev_dt).total_seconds() if gap_seconds >= min_gap_seconds: - # Check if there are hidden screenshots during this gap period - # If so, it's an incognito period - has_hidden_during_gap = False - for hidden_dt in hidden_timestamps: - if prev_dt < hidden_dt < curr_dt: - has_hidden_during_gap = True - break + # 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" diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 0016eaa..acc3de1 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -160,10 +160,13 @@ function HomeContent() { const start = searchParams.get('start'); const end = searchParams.get('end'); if (start && end) { - setTimelineStartDate(start); - setTimelineEndDate(end); - // Switch to search/gallery view to show filtered results - setActiveView('search'); + // 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]); From 5b17806cdd53358c56e329e2afb2e762003204a6 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sat, 14 Feb 2026 15:24:59 +0530 Subject: [PATCH 5/6] fix: Address major code review findings - Convert all analytics endpoints from async to sync (FastAPI runs sync handlers in thread pool, avoiding event loop blocking for I/O) - Add lookback_days param to /gaps endpoint to bound query size - Compute gap statistics from full dataset before interleaving/truncation - Batch file size lookups via single directory scan instead of per-row stat - Parse YYYY-MM-DD dates as local time to avoid UTC timezone shift --- api/routes/analytics.py | 45 ++++++++++++++++++++-------------- web/src/app/analytics/page.tsx | 17 ++++++++++--- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/api/routes/analytics.py b/api/routes/analytics.py index 6e48439..5c43c08 100644 --- a/api/routes/analytics.py +++ b/api/routes/analytics.py @@ -55,7 +55,7 @@ def format_timestamp(dt: datetime) -> str: @router.get("/overview") -async def get_overview(): +def get_overview(): """ Get overview statistics for the analytics dashboard. @@ -119,7 +119,7 @@ async def get_overview(): @router.get("/storage") -async def get_storage_analytics( +def get_storage_analytics( days: int = Query(default=30, ge=7, le=365, description="Number of days to analyze"), ): """ @@ -284,7 +284,7 @@ async def get_storage_analytics( @router.get("/activity") -async def get_activity_analytics( +def get_activity_analytics( weeks: int = Query(default=12, ge=1, le=52, description="Number of weeks for heatmap"), ): """ @@ -367,7 +367,7 @@ async def get_activity_analytics( @router.get("/activity-week") -async def get_activity_week( +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.)" ), @@ -461,9 +461,10 @@ async def get_activity_week( @router.get("/gaps") -async def get_gap_analytics( +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). @@ -474,26 +475,30 @@ async def get_gap_analytics( 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 + # Get VISIBLE screenshots only for gap detection (bounded by lookback) cur.execute( """ SELECT timestamp FROM screenshots - WHERE is_hidden = 0 + WHERE is_hidden = 0 AND timestamp >= ? ORDER BY timestamp ASC - """ + """, + (lookback_ts,), ) visible_rows = cur.fetchall() - # Get HIDDEN screenshots for incognito detection + # Get HIDDEN screenshots for incognito detection (bounded by lookback) cur.execute( """ SELECT timestamp FROM screenshots - WHERE is_hidden = 1 + WHERE is_hidden = 1 AND timestamp >= ? ORDER BY timestamp ASC - """ + """, + (lookback_ts,), ) hidden_rows = cur.fetchall() @@ -544,6 +549,13 @@ async def get_gap_analytics( 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 @@ -566,22 +578,17 @@ async def get_gap_analytics( gaps = interleaved - # Calculate statistics - total_gap_time = sum(g["duration_seconds"] for g in gaps) - longest_gap = gaps[0]["duration_seconds"] if gaps else 0 - avg_gap = total_gap_time / len(gaps) if gaps else 0 - return { "gaps": gaps, "total_gap_time_seconds": total_gap_time, "longest_gap_seconds": longest_gap, "avg_gap_seconds": int(avg_gap), - "gap_count": len(gaps), + "gap_count": total_gap_count, } @router.get("/quality") -async def get_quality_analytics(): +def get_quality_analytics(): """ Get file quality and size distribution analytics. """ @@ -659,7 +666,7 @@ async def get_quality_analytics(): @router.get("/trends") -async def get_trends_analytics( +def get_trends_analytics( days: int = Query(default=30, ge=7, le=365, description="Number of days to analyze"), ): """ diff --git a/web/src/app/analytics/page.tsx b/web/src/app/analytics/page.tsx index 4943086..40af500 100644 --- a/web/src/app/analytics/page.tsx +++ b/web/src/app/analytics/page.tsx @@ -257,18 +257,27 @@ function WeekActivityHeatmap({ 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 = new Date(weekData.week_start); + 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: d.toISOString().split('T')[0], + date: `${yyyy}-${mm}-${dd}`, label: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), dayLabel: dayLabels[i], }); @@ -280,8 +289,8 @@ function WeekActivityHeatmap({ const formatDateRange = () => { if (!weekData) return ''; - const start = new Date(weekData.week_start); - const end = new Date(weekData.week_end); + 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' })}`; }; From 69ebf5c2a82d1b6c2943f37ff236aeb395118306 Mon Sep 17 00:00:00 2001 From: Vedank Purohit Date: Sat, 14 Feb 2026 17:21:51 +0530 Subject: [PATCH 6/6] fix: Add response models and fix tooltip mismatch - Add Pydantic response_model to all 7 analytics endpoints for validation and OpenAPI docs generation - Fix tooltip saying "30+ minutes" when actual threshold is 5 minutes --- api/routes/analytics.py | 23 +++-- api/schemas.py | 183 +++++++++++++++++++++++++++++++++ web/src/app/analytics/page.tsx | 2 +- 3 files changed, 200 insertions(+), 8 deletions(-) diff --git a/api/routes/analytics.py b/api/routes/analytics.py index 5c43c08..b5aed57 100644 --- a/api/routes/analytics.py +++ b/api/routes/analytics.py @@ -11,6 +11,15 @@ 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 @@ -54,7 +63,7 @@ def format_timestamp(dt: datetime) -> str: return dt.strftime("%y%m%d%H%M%S") -@router.get("/overview") +@router.get("/overview", response_model=AnalyticsOverviewResponse) def get_overview(): """ Get overview statistics for the analytics dashboard. @@ -118,7 +127,7 @@ def get_overview(): } -@router.get("/storage") +@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"), ): @@ -283,7 +292,7 @@ def get_storage_analytics( } -@router.get("/activity") +@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"), ): @@ -366,7 +375,7 @@ def get_activity_analytics( } -@router.get("/activity-week") +@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.)" @@ -460,7 +469,7 @@ def get_activity_week( } -@router.get("/gaps") +@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"), @@ -587,7 +596,7 @@ def get_gap_analytics( } -@router.get("/quality") +@router.get("/quality", response_model=AnalyticsQualityResponse) def get_quality_analytics(): """ Get file quality and size distribution analytics. @@ -665,7 +674,7 @@ def get_quality_analytics(): } -@router.get("/trends") +@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"), ): 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 index 40af500..982de30 100644 --- a/web/src/app/analytics/page.tsx +++ b/web/src/app/analytics/page.tsx @@ -851,7 +851,7 @@ export default function AnalyticsPage() {

Timeline Gaps

- +
{gaps && gaps.gap_count > 0 && (