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 */} +
+
+ {/* 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..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; +}