diff --git a/.gitignore b/.gitignore index 3b042f3..4c504c8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,14 @@ Thumbs.db .ruff_cache/ .pytest_cache/ +# Node (for Puppeteer testing) +node_modules/ +package.json +package-lock.json + +# Screenshots (debug artifacts) +Screenshot.png + # Other start.sh +.env diff --git a/CLAUDE.md b/CLAUDE.md index e7c0f5c..ce423d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,16 +20,55 @@ All work must align with these principles (in priority order): 2. **User Data Ownership** - Data lives in user's Google Sheets, not our servers 3. **Simplicity & Focus** - Pomodoro timer and time tracking only, no feature creep 4. **Timer Agnosticism** - Internal timer and external physical timers are equally supported -5. **Offline-First** - SQLite cache for all reads, background sync to Google Sheets +5. **Offline-First** - IndexedDB cache for all reads, background sync to Google Sheets 6. **Container-Ready** - Single container, env var config, no persistent volumes ## Technology Stack - **Backend**: Python 3.x / Flask - **Frontend**: Vanilla HTML/CSS/JavaScript (no build step) -- **Local Storage**: SQLite +- **Local Storage**: IndexedDB (browser-side) - **Cloud Storage**: Google Sheets API v4 -- **Auth**: Google OAuth 2.0 +- **Auth**: Google OAuth 2.0 (credentials stored in IndexedDB, server is stateless) + +## Local Development + +### Container Names (Standardized) + +Always use these exact names for local development: + +- **Image**: `acquacotta:dev` +- **Container**: `acquacotta-dev` + +### Build and Run Commands + +```bash +# Build the dev image +podman build -t acquacotta:dev . + +# Stop and remove existing container, then run new one +# Note: Map host port 5000 to container port 80 (Apache reverse proxy) +podman stop acquacotta-dev 2>/dev/null; podman rm acquacotta-dev 2>/dev/null +podman run -d --name acquacotta-dev -p 5000:80 --env-file .env acquacotta:dev + +# View logs +podman logs acquacotta-dev + +# One-liner for rebuild and restart +podman build -t acquacotta:dev . && podman stop acquacotta-dev 2>/dev/null; podman rm acquacotta-dev 2>/dev/null; podman run -d --name acquacotta-dev -p 5000:80 --env-file .env acquacotta:dev +``` + +### Environment Variables (.env) + +Required for local development: +``` +FLASK_ENV=development +FLASK_SECRET_KEY=dev-secret-key-not-for-production +SESSION_COOKIE_SECURE=false +OAUTH_REDIRECT_BASE=http://localhost:5000 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +``` ## Before Making Changes diff --git a/app.py b/app.py index b6dbd02..de32d60 100644 --- a/app.py +++ b/app.py @@ -1,20 +1,27 @@ #!/usr/bin/env python3 -"""Acquacotta - Pomodoro Time Tracking Application""" +"""Acquacotta - Pomodoro Time Tracking Application + +Sovereign Sandbox v2: Stateless Server + IndexedDB + +The server is stateless - it only handles: +1. OAuth authentication with Google +2. Proxying API calls to Google Sheets + +All user data lives in the browser's IndexedDB and optionally in their Google Sheets. +The server never stores any user pomodoro data. + +Credit: kirkjerk (localStorage approach idea, extended to IndexedDB) +""" import json import os -import sqlite3 -import threading -import uuid -from datetime import datetime, timedelta -from enum import Enum from http import HTTPStatus from pathlib import Path # Allow OAuth scope changes (users may have previously granted different scopes) os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" -from flask import Flask, g, jsonify, redirect, render_template, request, session +from flask import Flask, Response, jsonify, redirect, render_template, request, session from flask_session import Session from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import Flow @@ -24,27 +31,31 @@ import sheets_storage - -class ReportPeriod(Enum): - """Valid report period types.""" - - DAY = "day" - WEEK = "week" - MONTH = "month" - - -# Global sync state -sync_lock = threading.Lock() -sync_in_progress = False -last_sync_error = None - app = Flask(__name__) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) # Session configuration -app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY", "dev-secret-key-change-in-prod") +secret_key = os.environ.get("FLASK_SECRET_KEY") +if not secret_key: + if os.environ.get("FLASK_ENV") == "development": + secret_key = "dev-secret-key-for-local-development-only" + else: + raise ValueError("FLASK_SECRET_KEY environment variable must be set in production") +app.config["SECRET_KEY"] = secret_key app.config["SESSION_TYPE"] = "filesystem" app.config["SESSION_FILE_DIR"] = "/tmp/flask_session" +# Only require HTTPS cookies in production (localhost uses HTTP) +# Can be overridden via SESSION_COOKIE_SECURE env var (set to "false" for dev) +session_cookie_secure = os.environ.get("SESSION_COOKIE_SECURE", "").lower() +if session_cookie_secure in ("false", "0", "no"): + app.config["SESSION_COOKIE_SECURE"] = False +elif session_cookie_secure in ("true", "1", "yes"): + app.config["SESSION_COOKIE_SECURE"] = True +else: + # Default: secure in production, not secure in development + app.config["SESSION_COOKIE_SECURE"] = os.environ.get("FLASK_ENV") != "development" +app.config["SESSION_COOKIE_HTTPONLY"] = True # Prevent JavaScript access +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" # CSRF protection Session(app) @@ -52,7 +63,9 @@ class ReportPeriod(Enum): def handle_exception(e): """Return JSON for API errors instead of HTML.""" if request.path.startswith("/api/"): - return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + # Log the actual error for debugging, but don't expose details to client + app.logger.error(f"API error: {e}") + return jsonify({"error": "An internal error occurred"}), HTTPStatus.INTERNAL_SERVER_ERROR # For non-API routes, re-raise to get default handling raise e @@ -67,19 +80,12 @@ def handle_exception(e): "openid", ] -# Allow insecure transport for local development -os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" +# OAuth requires HTTPS by default (secure) +# For local development, set OAUTHLIB_INSECURE_TRANSPORT=1 in your environment -# Data directory for SQLite +# Data directory for user-to-spreadsheet mapping only (no user data stored) DATA_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "acquacotta" DATA_DIR.mkdir(parents=True, exist_ok=True) -DEFAULT_DB_PATH = DATA_DIR / "pomodoros.db" # For non-logged-in users - -# Clear cache on startup (default: true for development, set to "false" in production) -CLEAR_CACHE_ON_START = os.environ.get("CLEAR_CACHE_ON_START", "true").lower() == "true" -if CLEAR_CACHE_ON_START: - DEFAULT_DB_PATH.unlink(missing_ok=True) - print(f"Cleared cache: {DEFAULT_DB_PATH}") # Timer duration constants (in minutes) DEFAULT_POMODORO_DURATION = 25 @@ -87,16 +93,6 @@ def handle_exception(e): DEFAULT_LONG_BREAK = 15 TIMER_PRESET_MEDIUM = 10 -# Cache/sync timing constants (in seconds) -SYNC_CACHE_TTL = 300 # 5 minutes -BACKGROUND_SYNC_INTERVAL = 5000 # milliseconds for JS polling - -# Report period constants -MONTHS_IN_YEAR = 12 - -# Flask default port -DEFAULT_PORT = 5000 - # Default daily goal (in minutes) DEFAULT_DAILY_GOAL = 300 # 5 hours @@ -116,6 +112,34 @@ def handle_exception(e): "Unqueued", ] +# Default settings for Sheets +DEFAULT_SETTINGS = { + "timer_preset_1": DEFAULT_SHORT_BREAK, + "timer_preset_2": TIMER_PRESET_MEDIUM, + "timer_preset_3": DEFAULT_LONG_BREAK, + "timer_preset_4": DEFAULT_POMODORO_DURATION, + "short_break_minutes": DEFAULT_SHORT_BREAK, + "long_break_minutes": DEFAULT_LONG_BREAK, + "pomodoros_until_long_break": DEFAULT_POMODOROS_UNTIL_LONG_BREAK, + "always_use_short_break": False, + "sound_enabled": True, + "notifications_enabled": True, + "pomodoro_types": DEFAULT_POMODORO_TYPES, + "auto_start_after_break": False, + "tick_sound_during_breaks": False, + "bell_at_pomodoro_end": True, + "bell_at_break_end": True, + "show_notes_field": False, + "working_hours_start": "08:00", + "working_hours_end": "17:00", + "clock_format": "auto", + "period_labels": "auto", + "daily_minutes_goal": DEFAULT_DAILY_GOAL, +} + +# Flask default port +DEFAULT_PORT = 5000 + def get_user_spreadsheet_mapping_path(): """Get path to the user-to-spreadsheet mapping file.""" @@ -144,285 +168,20 @@ def save_spreadsheet_id(email, spreadsheet_id): json.dump(mapping, f) -def get_user_db_path(): - """Get database path for current user (per-user isolation).""" - if "user_email" in session: - # Create a safe filename from email - import hashlib - - email_hash = hashlib.md5(session["user_email"].encode()).hexdigest()[:12] - safe_email = session["user_email"].replace("@", "_at_").replace(".", "_") - # Use both readable name and hash for uniqueness - db_name = f"user_{safe_email[:20]}_{email_hash}.db" - return DATA_DIR / db_name - return DEFAULT_DB_PATH - - -def get_db(): - """Get database connection for current user.""" - db_path = get_user_db_path() - - if "db" not in g: - # Ensure database is initialized for this user - init_db(db_path) - g.db = sqlite3.connect(db_path) - g.db.row_factory = sqlite3.Row - return g.db - - -@app.teardown_appcontext -def close_db(error): - """Close database connection at end of request.""" - db = g.pop("db", None) - if db is not None: - db.close() - - -def init_db(db_path=None): - """Initialize the database schema for a user.""" - if db_path is None: - db_path = DEFAULT_DB_PATH - db = sqlite3.connect(db_path) - db.execute(""" - CREATE TABLE IF NOT EXISTS pomodoros ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL, - start_time TEXT NOT NULL, - end_time TEXT NOT NULL, - duration_minutes INTEGER NOT NULL, - notes TEXT, - synced INTEGER DEFAULT 0 - ) - """) - db.execute(""" - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - synced INTEGER DEFAULT 0 - ) - """) - db.execute(""" - CREATE TABLE IF NOT EXISTS sync_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - operation TEXT NOT NULL, - table_name TEXT NOT NULL, - record_id TEXT NOT NULL, - data TEXT, - created_at TEXT NOT NULL - ) - """) - db.execute(""" - CREATE TABLE IF NOT EXISTS sync_status ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - """) - # Add synced column if it doesn't exist (for existing databases) - # SQLite doesn't support IF NOT EXISTS for ALTER TABLE, so we catch the error - try: - db.execute("ALTER TABLE pomodoros ADD COLUMN synced INTEGER DEFAULT 0") - except sqlite3.OperationalError as e: - if "duplicate column name" not in str(e).lower(): - raise - try: - db.execute("ALTER TABLE settings ADD COLUMN synced INTEGER DEFAULT 0") - except sqlite3.OperationalError as e: - if "duplicate column name" not in str(e).lower(): - raise - db.commit() - db.close() - - -# Initialize default database on startup (for non-logged-in users) -init_db(DEFAULT_DB_PATH) - - -def queue_sync_operation(db_path, operation, table_name, record_id, record_data=None): - """Queue an operation for background sync to Google Sheets.""" - db = sqlite3.connect(db_path) - db.execute( - """ - INSERT INTO sync_queue (operation, table_name, record_id, data, created_at) - VALUES (?, ?, ?, ?, ?) - """, - ( - operation, - table_name, - record_id, - json.dumps(record_data) if record_data else None, - datetime.utcnow().isoformat(), - ), - ) - db.commit() - db.close() - - -def _create_sheets_credentials(credentials_dict): - """Create Google Sheets credentials from dict.""" - return Credentials( - token=credentials_dict["token"], - refresh_token=credentials_dict.get("refresh_token"), - token_uri=credentials_dict["token_uri"], - client_id=credentials_dict["client_id"], - client_secret=credentials_dict["client_secret"], - scopes=credentials_dict["scopes"], - ) - - -def _execute_sync_operation(service, spreadsheet_id, sync_op): - """Execute a single sync operation against Google Sheets.""" - table_name, operation, record_id, record_data = sync_op["table"], sync_op["op"], sync_op["id"], sync_op["data"] - if table_name == "pomodoros": - if operation == "INSERT": - sheets_storage.save_pomodoro(service, spreadsheet_id, record_data) - elif operation == "UPDATE": - sheets_storage.update_pomodoro(service, spreadsheet_id, record_id, record_data) - elif operation == "DELETE": - sheets_storage.delete_pomodoro(service, spreadsheet_id, record_id) - elif table_name == "settings" and operation in ("INSERT", "UPDATE"): - sheets_storage.save_settings(service, spreadsheet_id, record_data) - - -def process_sync_queue(db_path, credentials_dict, spreadsheet_id): - """Process pending sync operations in background.""" - global sync_in_progress, last_sync_error - - with sync_lock: - if sync_in_progress: - return - sync_in_progress = True - - try: - credentials = _create_sheets_credentials(credentials_dict) - service = build("sheets", "v4", credentials=credentials) - - db = sqlite3.connect(db_path) - db.row_factory = sqlite3.Row - rows = db.execute("SELECT * FROM sync_queue ORDER BY created_at").fetchall() - - for row in rows: - try: - sync_op = { - "table": row["table_name"], - "op": row["operation"], - "id": row["record_id"], - "data": json.loads(row["data"]) if row["data"] else None, - } - _execute_sync_operation(service, spreadsheet_id, sync_op) - db.execute("UPDATE pomodoros SET synced = 1 WHERE id = ?", (row["record_id"],)) - db.execute("DELETE FROM sync_queue WHERE id = ?", (row["id"],)) - db.commit() - except Exception as e: - last_sync_error = str(e) - - db.execute( - "INSERT OR REPLACE INTO sync_status (key, value) VALUES (?, ?)", - ("last_sync", datetime.utcnow().isoformat()), - ) - db.commit() - db.close() - last_sync_error = None - - except Exception as e: - last_sync_error = str(e) - finally: - with sync_lock: - sync_in_progress = False - - -def start_background_sync(db_path, credentials_dict, spreadsheet_id): - """Start background sync thread.""" - thread = threading.Thread( - target=process_sync_queue, - args=(db_path, credentials_dict, spreadsheet_id), - daemon=True, - ) - thread.start() - - -def sync_from_sheets(db_path, credentials_dict, spreadsheet_id): - """Pull all data from Google Sheets to local SQLite cache.""" - # Initialize database schema if needed - init_db(db_path) - - credentials = Credentials( - token=credentials_dict["token"], - refresh_token=credentials_dict.get("refresh_token"), - token_uri=credentials_dict["token_uri"], - client_id=credentials_dict["client_id"], - client_secret=credentials_dict["client_secret"], - scopes=credentials_dict["scopes"], - ) - service = build("sheets", "v4", credentials=credentials) - - db = sqlite3.connect(db_path) - - # Get all pomodoros from Google Sheets - pomodoros = sheets_storage.get_pomodoros(service, spreadsheet_id) - - # Upsert into local database - for p in pomodoros: - db.execute( - """ - INSERT OR REPLACE INTO pomodoros (id, name, type, start_time, end_time, duration_minutes, notes, synced) - VALUES (?, ?, ?, ?, ?, ?, ?, 1) - """, - (p["id"], p["name"], p["type"], p["start_time"], p["end_time"], p["duration_minutes"], p.get("notes")), - ) - - # Get settings from Google Sheets - defaults = { - "timer_preset_1": DEFAULT_SHORT_BREAK, - "timer_preset_2": TIMER_PRESET_MEDIUM, - "timer_preset_3": DEFAULT_LONG_BREAK, - "timer_preset_4": DEFAULT_POMODORO_DURATION, - "short_break_minutes": DEFAULT_SHORT_BREAK, - "long_break_minutes": DEFAULT_LONG_BREAK, - "pomodoros_until_long_break": DEFAULT_POMODOROS_UNTIL_LONG_BREAK, - "always_use_short_break": False, - "sound_enabled": True, - "notifications_enabled": True, - "pomodoro_types": DEFAULT_POMODORO_TYPES, - "auto_start_after_break": False, - "tick_sound_during_breaks": False, - "bell_at_pomodoro_end": True, - "bell_at_break_end": True, - "show_notes_field": False, - "working_hours_start": "08:00", - "working_hours_end": "17:00", - "clock_format": "auto", - "period_labels": "auto", - "daily_minutes_goal": DEFAULT_DAILY_GOAL, - } - settings = sheets_storage.get_settings(service, spreadsheet_id, defaults) - - for key, value in settings.items(): - db.execute( - "INSERT OR REPLACE INTO settings (key, value, synced) VALUES (?, ?, 1)", - (key, json.dumps(value)), - ) - - # Update sync status - db.execute( - "INSERT OR REPLACE INTO sync_status (key, value) VALUES (?, ?)", - ("last_full_sync", datetime.utcnow().isoformat()), - ) - db.commit() - db.close() - - return len(pomodoros) - - def get_google_flow(): """Create Google OAuth flow.""" if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET: return None - # Build redirect URI from X-Forwarded headers or fall back to request host - # Take first value if multiple proxies added headers (comma-separated) - proto = request.headers.get("X-Forwarded-Proto", request.scheme).split(",")[0].strip() - host = request.headers.get("X-Forwarded-Host", request.host).split(",")[0].strip() - redirect_uri = f"{proto}://{host}/auth/callback" + # Allow override via env var for development (e.g., OAUTH_REDIRECT_BASE=http://localhost:5000) + oauth_base = os.environ.get("OAUTH_REDIRECT_BASE") + if oauth_base: + redirect_uri = f"{oauth_base.rstrip('/')}/auth/callback" + else: + # Build redirect URI from X-Forwarded headers or fall back to request host + # Take first value if multiple proxies added headers (comma-separated) + proto = request.headers.get("X-Forwarded-Proto", request.scheme).split(",")[0].strip() + host = request.headers.get("X-Forwarded-Host", request.host).split(",")[0].strip() + redirect_uri = f"{proto}://{host}/auth/callback" return Flow.from_client_config( { @@ -438,18 +197,63 @@ def get_google_flow(): ) +def get_credentials_from_request(): + """Extract credentials from request header or body (stateless approach).""" + import base64 + + # Try X-Credentials header (for GET/DELETE) + creds_header = request.headers.get("X-Credentials") + if creds_header: + try: + creds_data = json.loads(base64.b64decode(creds_header)) + return creds_data + except Exception as e: + app.logger.error(f"Failed to decode X-Credentials header: {e}") + return None + + # Try _credentials in request body (for POST/PUT) + if request.is_json: + body = request.get_json(silent=True) + if body and "_credentials" in body: + return body["_credentials"] + + return None + + +def get_spreadsheet_id_from_request(): + """Extract spreadsheet_id from request credentials.""" + creds = get_credentials_from_request() + if creds: + return creds.get("spreadsheet_id") + return None + + def get_credentials(): - """Get Google credentials from session.""" - if "credentials" not in session: + """Get Google credentials from request (stateless).""" + creds_data = get_credentials_from_request() + if not creds_data: + return None + + try: + credentials = Credentials( + token=creds_data.get("token"), + refresh_token=creds_data.get("refresh_token"), + token_uri=creds_data.get("token_uri", "https://oauth2.googleapis.com/token"), + client_id=creds_data.get("client_id"), + client_secret=creds_data.get("client_secret"), + scopes=creds_data.get("scopes", []), + ) + + # Refresh token if expired + if credentials.expired and credentials.refresh_token: + from google.auth.transport.requests import Request + + credentials.refresh(Request()) + + return credentials + except Exception as e: + app.logger.error(f"Error creating credentials: {e}") return None - return Credentials( - token=session["credentials"]["token"], - refresh_token=session["credentials"].get("refresh_token"), - token_uri=session["credentials"]["token_uri"], - client_id=session["credentials"]["client_id"], - client_secret=session["credentials"]["client_secret"], - scopes=session["credentials"]["scopes"], - ) def get_sheets_service(): @@ -468,9 +272,15 @@ def get_drive_service(): return build("drive", "v3", credentials=credentials) -def use_google_sheets(): - """Check if we should use Google Sheets storage.""" - return "credentials" in session and "spreadsheet_id" in session +def is_logged_in(): + """Check if request has valid credentials (stateless).""" + creds = get_credentials_from_request() + return creds is not None and creds.get("token") and creds.get("spreadsheet_id") + + +# ============================================================================= +# Static Pages +# ============================================================================= @app.route("/") @@ -491,6 +301,11 @@ def terms(): return render_template("terms.html") +# ============================================================================= +# OAuth Authentication +# ============================================================================= + + @app.route("/auth/google") def auth_google(): """Initiate Google OAuth flow.""" @@ -504,6 +319,10 @@ def auth_google(): prompt="consent", ) session["oauth_state"] = state + # Store user-provided spreadsheet ID to use after callback + requested_spreadsheet_id = request.args.get("spreadsheet_id", "").strip() + if requested_spreadsheet_id: + session["requested_spreadsheet_id"] = requested_spreadsheet_id return redirect(authorization_url) except Exception as e: import traceback @@ -515,6 +334,13 @@ def auth_google(): def auth_callback(): """Handle Google OAuth callback.""" try: + # Validate OAuth state to prevent CSRF attacks + callback_state = request.args.get("state") + stored_state = session.pop("oauth_state", None) # Pop to ensure one-time use + if not callback_state or callback_state != stored_state: + app.logger.warning("OAuth state mismatch - possible CSRF attack") + return jsonify({"error": "Invalid OAuth state"}), HTTPStatus.BAD_REQUEST + flow = get_google_flow() if not flow: return jsonify({"error": "Google OAuth not configured"}), HTTPStatus.INTERNAL_SERVER_ERROR @@ -544,7 +370,7 @@ def auth_callback(): include_granted_scopes="false", # Request fresh scopes prompt="consent", # Force consent screen to get new scopes ) - session["state"] = state + session["oauth_state"] = state # Use consistent key name return redirect(authorization_url) # Get user info @@ -555,22 +381,30 @@ def auth_callback(): session["user_name"] = user_info.get("name") session["user_picture"] = user_info.get("picture") - # Check for existing spreadsheet_id (persisted by email) + # Priority: 1) User-provided spreadsheet ID, 2) Previously stored ID, 3) Create new + requested_spreadsheet_id = session.pop("requested_spreadsheet_id", None) stored_spreadsheet_id = get_stored_spreadsheet_id(user_email) - if stored_spreadsheet_id: - # Verify we can still access this spreadsheet (scope may have changed) + + spreadsheet_id_to_use = requested_spreadsheet_id or stored_spreadsheet_id + + if spreadsheet_id_to_use: + # Verify we can access this spreadsheet + # Note: Use credentials directly here since we're in OAuth callback, not using request-based auth try: - sheets_service = get_sheets_service() - sheets_service.spreadsheets().get(spreadsheetId=stored_spreadsheet_id).execute() - session["spreadsheet_id"] = stored_spreadsheet_id + sheets_service = build("sheets", "v4", credentials=credentials) + sheets_service.spreadsheets().get(spreadsheetId=spreadsheet_id_to_use).execute() + session["spreadsheet_id"] = spreadsheet_id_to_use session["spreadsheet_existed"] = True + # Save/update the mapping for future logins + save_spreadsheet_id(user_email, spreadsheet_id_to_use) except HttpError: - # Can't access old spreadsheet (deleted, permissions changed), need to create new one - stored_spreadsheet_id = None + # Can't access spreadsheet (deleted, permissions changed, wrong ID) + spreadsheet_id_to_use = None - if not stored_spreadsheet_id: + if not spreadsheet_id_to_use: # Create new spreadsheet using Drive API (required for drive.file scope) - drive_service = get_drive_service() + # Note: Use credentials directly here since we're in OAuth callback, not using request-based auth + drive_service = build("drive", "v3", credentials=credentials) file_metadata = { "name": "Acquacotta - Pomodoro Tracker", "mimeType": "application/vnd.google-apps.spreadsheet", @@ -583,18 +417,18 @@ def auth_callback(): ) .execute() ) - session["spreadsheet_id"] = spreadsheet["id"] - session["spreadsheet_existed"] = False + new_spreadsheet_id = spreadsheet["id"] + spreadsheet_existed = False # Save the mapping for future logins - save_spreadsheet_id(user_email, session["spreadsheet_id"]) + save_spreadsheet_id(user_email, new_spreadsheet_id) # Now use Sheets API to set up the sheets (we have access since we created the file) - sheets_service = get_sheets_service() + sheets_service = build("sheets", "v4", credentials=credentials) # Rename default Sheet1 to Pomodoros and add Settings sheet sheets_service.spreadsheets().batchUpdate( - spreadsheetId=session["spreadsheet_id"], + spreadsheetId=new_spreadsheet_id, body={ "requests": [ { @@ -610,7 +444,7 @@ def auth_callback(): # Add headers to Pomodoros sheet sheets_service.spreadsheets().values().update( - spreadsheetId=session["spreadsheet_id"], + spreadsheetId=new_spreadsheet_id, range="Pomodoros!A1:G1", valueInputOption="RAW", body={"values": [["id", "name", "type", "start_time", "end_time", "duration_minutes", "notes"]]}, @@ -618,17 +452,105 @@ def auth_callback(): # Add headers to Settings sheet sheets_service.spreadsheets().values().update( - spreadsheetId=session["spreadsheet_id"], + spreadsheetId=new_spreadsheet_id, range="Settings!A1:B1", valueInputOption="RAW", body={"values": [["key", "value"]]}, ).execute() + else: + new_spreadsheet_id = spreadsheet_id_to_use + spreadsheet_existed = True - # Don't sync automatically - let user decide what to do with existing data - # The frontend will check needs_initial_sync and show migration dialog - session["needs_initial_sync"] = True + # Build credentials data for frontend storage (AUTH store - ephemeral) + credentials_data = { + "token": credentials.token, + "refresh_token": credentials.refresh_token, + "token_uri": credentials.token_uri, + "client_id": credentials.client_id, + "client_secret": credentials.client_secret, + "scopes": list(credentials.scopes), + "user_email": user_email, + "user_name": user_info.get("name"), + "user_picture": user_info.get("picture"), + } + + # Settings data (SETTINGS store - persistent) + settings_data = { + "spreadsheet_id": new_spreadsheet_id, + "spreadsheet_existed": spreadsheet_existed, + } - return redirect("/") + # Clear server session - credentials will live in browser IndexedDB + session.clear() + + # Return HTML page that stores credentials in IndexedDB then redirects + # Note: DB_VERSION must match storage.js (currently 2) + return f""" + +Logging in... + +

Completing login...

+ + +""" except Exception as e: import traceback @@ -637,16 +559,7 @@ def auth_callback(): @app.route("/auth/logout") def auth_logout(): - """Log out and clear session, including user's local cache.""" - # Delete user's local database to prevent stale data on next login - if "user_email" in session: - user_db_path = get_user_db_path() - if user_db_path != DEFAULT_DB_PATH and user_db_path.exists(): - # Close any open connection first - if "db" in g: - g.db.close() - g.pop("db", None) - user_db_path.unlink() + """Log out and clear session.""" session.clear() return redirect("/") @@ -654,7 +567,7 @@ def auth_logout(): @app.route("/api/auth/status") def auth_status(): """Get current authentication status.""" - if use_google_sheets(): + if is_logged_in(): return jsonify( { "logged_in": True, @@ -673,664 +586,312 @@ def auth_status(): ) +@app.route("/api/auth/clear-initial-sync", methods=["POST"]) +def clear_initial_sync(): + """Clear the needs_initial_sync flag after frontend has synced.""" + session["needs_initial_sync"] = False + return jsonify({"status": "ok"}) + + @app.route("/api/auth/spreadsheet", methods=["POST"]) def update_spreadsheet(): """Update the spreadsheet ID for the current user.""" - if "user_email" not in session: + if not is_logged_in(): return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED - request_json = request.get_json() - new_spreadsheet_id = request_json.get("spreadsheet_id", "").strip() - - if not new_spreadsheet_id: + request_body = request.json + new_id = request_body.get("spreadsheet_id", "").strip() + if not new_id: return jsonify({"error": "Spreadsheet ID is required"}), HTTPStatus.BAD_REQUEST - # Validate that we can access this spreadsheet + # Verify we can access this spreadsheet try: sheets_service = get_sheets_service() - sheets_service.spreadsheets().get(spreadsheetId=new_spreadsheet_id).execute() - except Exception as e: - error_msg = str(e) - if "404" in error_msg or "not found" in error_msg.lower(): - return jsonify( - { - "error": "Cannot access this spreadsheet. With drive.file scope, you can only access spreadsheets created by this app instance." - } - ), HTTPStatus.BAD_REQUEST - return jsonify({"error": f"Cannot access spreadsheet: {error_msg}"}), HTTPStatus.BAD_REQUEST - - # Update session - session["spreadsheet_id"] = new_spreadsheet_id - - # Update the persistent mapping - save_spreadsheet_id(session["user_email"], new_spreadsheet_id) - - return jsonify({"status": "ok", "spreadsheet_id": new_spreadsheet_id}) - - -@app.route("/api/pomodoros", methods=["GET"]) -def get_pomodoros(): - """Get all pomodoros from SQLite cache, optionally filtered by date range.""" - start_date = request.args.get("start_date") - end_date = request.args.get("end_date") - - # Always read from SQLite (fast local cache) - db = get_db() - query = "SELECT id, name, type, start_time, end_time, duration_minutes, notes FROM pomodoros" - params = [] - - if start_date or end_date: - conditions = [] - if start_date: - conditions.append("start_time >= ?") - params.append(start_date) - if end_date: - conditions.append("start_time <= ?") - params.append(end_date) - query += " WHERE " + " AND ".join(conditions) - - query += " ORDER BY start_time DESC" - rows = db.execute(query, params).fetchall() - return jsonify([dict(row) for row in rows]) - - -@app.route("/api/pomodoros", methods=["POST"]) -def create_pomodoro(): - """Create a new pomodoro.""" - pomodoro_input = request.json - - pomodoro_id = str(uuid.uuid4()) - end_time = datetime.utcnow() - duration = pomodoro_input.get("duration_minutes", DEFAULT_POMODORO_DURATION) - start_time = end_time - timedelta(minutes=duration) - - pomodoro = { - "id": pomodoro_id, - "name": pomodoro_input.get("name") or "", - "type": pomodoro_input["type"], - "start_time": start_time.isoformat() + "Z", - "end_time": end_time.isoformat() + "Z", - "duration_minutes": duration, - "notes": pomodoro_input.get("notes"), - } - - # Always write to SQLite first (fast) - db = get_db() - db.execute( - """ - INSERT INTO pomodoros (id, name, type, start_time, end_time, duration_minutes, notes, synced) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - pomodoro_id, - pomodoro_input.get("name") or "", - pomodoro_input["type"], - start_time.isoformat() + "Z", - end_time.isoformat() + "Z", - duration, - pomodoro_input.get("notes"), - 0 if use_google_sheets() else 1, # Mark as unsynced if Google connected - ), - ) - db.commit() - - # Queue for background sync to Google Sheets - if use_google_sheets(): - db_path = get_user_db_path() - queue_sync_operation(db_path, "INSERT", "pomodoros", pomodoro_id, pomodoro) - start_background_sync(db_path, session["credentials"], session["spreadsheet_id"]) - - return jsonify(pomodoro) - - -@app.route("/api/pomodoros/", methods=["PUT"]) -def update_pomodoro(pomodoro_id): - """Update an existing pomodoro.""" - update_fields = request.json - - # Always update SQLite first (fast) - db = get_db() - db.execute( - """ - UPDATE pomodoros - SET name = ?, type = ?, notes = ?, start_time = ?, end_time = ?, duration_minutes = ?, synced = ? - WHERE id = ? - """, - ( - update_fields["name"], - update_fields["type"], - update_fields.get("notes"), - update_fields["start_time"], - update_fields["end_time"], - update_fields["duration_minutes"], - 0 if use_google_sheets() else 1, - pomodoro_id, - ), - ) - db.commit() - - # Queue for background sync to Google Sheets - if use_google_sheets(): - db_path = get_user_db_path() - queue_sync_operation(db_path, "UPDATE", "pomodoros", pomodoro_id, update_fields) - start_background_sync(db_path, session["credentials"], session["spreadsheet_id"]) - - return jsonify({"status": "ok"}) + sheets_service.spreadsheets().get(spreadsheetId=new_id).execute() + except HttpError: + return jsonify({"error": "Cannot access spreadsheet. Make sure you have edit access."}), HTTPStatus.BAD_REQUEST + # Update session and persisted mapping + session["spreadsheet_id"] = new_id + if session.get("user_email"): + save_spreadsheet_id(session["user_email"], new_id) -@app.route("/api/pomodoros/", methods=["DELETE"]) -def delete_pomodoro(pomodoro_id): - """Delete a pomodoro.""" - # Always delete from SQLite first (fast) - db = get_db() - db.execute("DELETE FROM pomodoros WHERE id = ?", (pomodoro_id,)) - db.commit() + return jsonify({"status": "ok", "spreadsheet_id": new_id}) - # Queue for background sync to Google Sheets - if use_google_sheets(): - db_path = get_user_db_path() - queue_sync_operation(db_path, "DELETE", "pomodoros", pomodoro_id) - start_background_sync(db_path, session["credentials"], session["spreadsheet_id"]) - return jsonify({"status": "ok"}) +# ============================================================================= +# Google Sheets Proxy Endpoints +# The server proxies all data operations to Google Sheets. +# No user data is stored on the server. +# ============================================================================= -@app.route("/api/pomodoros/manual", methods=["POST"]) -def create_manual_pomodoro(): - """Create a manual pomodoro with custom times.""" - pomodoro_input = request.json - pomodoro_id = str(uuid.uuid4()) - - pomodoro = { - "id": pomodoro_id, - "name": pomodoro_input.get("name") or "", - "type": pomodoro_input["type"], - "start_time": pomodoro_input["start_time"], - "end_time": pomodoro_input["end_time"], - "duration_minutes": pomodoro_input["duration_minutes"], - "notes": pomodoro_input.get("notes"), - } - - # Always write to SQLite first (fast) - db = get_db() - db.execute( - """ - INSERT INTO pomodoros (id, name, type, start_time, end_time, duration_minutes, notes, synced) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - pomodoro_id, - pomodoro_input.get("name") or "", - pomodoro_input["type"], - pomodoro_input["start_time"], - pomodoro_input["end_time"], - pomodoro_input["duration_minutes"], - pomodoro_input.get("notes"), - 0 if use_google_sheets() else 1, - ), - ) - db.commit() - - # Queue for background sync to Google Sheets - if use_google_sheets(): - db_path = get_user_db_path() - queue_sync_operation(db_path, "INSERT", "pomodoros", pomodoro_id, pomodoro) - start_background_sync(db_path, session["credentials"], session["spreadsheet_id"]) - - return jsonify(pomodoro) - - -@app.route("/api/settings", methods=["GET"]) -def get_settings(): - """Get user settings from SQLite cache.""" - defaults = { - "timer_preset_1": DEFAULT_SHORT_BREAK, - "timer_preset_2": TIMER_PRESET_MEDIUM, - "timer_preset_3": DEFAULT_LONG_BREAK, - "timer_preset_4": DEFAULT_POMODORO_DURATION, - "short_break_minutes": DEFAULT_SHORT_BREAK, - "long_break_minutes": DEFAULT_LONG_BREAK, - "pomodoros_until_long_break": DEFAULT_POMODOROS_UNTIL_LONG_BREAK, - "always_use_short_break": False, - "sound_enabled": True, - "notifications_enabled": True, - "pomodoro_types": DEFAULT_POMODORO_TYPES, - "auto_start_after_break": False, - "tick_sound_during_breaks": False, - "bell_at_pomodoro_end": True, - "bell_at_break_end": True, - "show_notes_field": False, - "working_hours_start": "08:00", - "working_hours_end": "17:00", - "clock_format": "auto", - "period_labels": "auto", - "daily_minutes_goal": DEFAULT_DAILY_GOAL, - } - - # Always read from SQLite (fast local cache) - db = get_db() - rows = db.execute("SELECT key, value FROM settings").fetchall() - settings = {row["key"]: json.loads(row["value"]) for row in rows} - defaults.update(settings) - return jsonify(defaults) - - -@app.route("/api/settings", methods=["POST"]) -def save_settings(): - """Save user settings.""" - settings_input = request.json - - # Always write to SQLite first (fast) - db = get_db() - for key, value in settings_input.items(): - db.execute( - "INSERT OR REPLACE INTO settings (key, value, synced) VALUES (?, ?, ?)", - (key, json.dumps(value), 0 if use_google_sheets() else 1), - ) - db.commit() - - # Queue for background sync to Google Sheets - if use_google_sheets(): - db_path = get_user_db_path() - queue_sync_operation(db_path, "UPDATE", "settings", "all", settings_input) - start_background_sync(db_path, session["credentials"], session["spreadsheet_id"]) - - return jsonify({"status": "ok"}) - - -def _parse_iso_date_range(start_iso, end_iso): - """Parse ISO date strings and build dates list.""" - start_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00")) - end_dt = datetime.fromisoformat(end_iso.replace("Z", "+00:00")) - - dates = [] - d = start_dt.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) - end_naive = end_dt.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) - while d < end_naive: - dates.append(d) - d += timedelta(days=1) - if not dates: - dates = [start_dt.replace(tzinfo=None)] - return dates, start_iso, end_iso - +@app.route("/api/sheets/pomodoros", methods=["GET"]) +def proxy_get_pomodoros(): + """Proxy read from Google Sheets - stateless, credentials from request.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED -def _calculate_period_date_range(period, ref_date): - """Calculate date range for a given period (day, week, month).""" try: - period_enum = ReportPeriod(period) - except ValueError: - return None, None, None - - if period_enum == ReportPeriod.DAY: - start = ref_date.replace(hour=0, minute=0, second=0, microsecond=0) - end = start + timedelta(days=1) - dates = [start] - elif period_enum == ReportPeriod.WEEK: - start = ref_date - timedelta(days=ref_date.weekday()) - start = start.replace(hour=0, minute=0, second=0, microsecond=0) - end = start + timedelta(days=7) - dates = [start + timedelta(days=i) for i in range(7)] - elif period_enum == ReportPeriod.MONTH: - start = ref_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - if ref_date.month == MONTHS_IN_YEAR: - end = start.replace(year=ref_date.year + 1, month=1) - else: - end = start.replace(month=ref_date.month + 1) - dates = [] - d = start - while d < end: - dates.append(d) - d += timedelta(days=1) - return dates, start.isoformat() + "Z", end.isoformat() + "Z" - - -def _calculate_report_stats(pomodoros, dates): - """Calculate report statistics from pomodoros.""" - total_minutes = sum(p["duration_minutes"] for p in pomodoros) - total_count = len(pomodoros) - - by_type = {} - for p in pomodoros: - t = p["type"] - by_type[t] = by_type.get(t, 0) + p["duration_minutes"] - - daily_totals = [] - for d in dates: - day_str = d.strftime("%Y-%m-%d") - day_pomodoros = [p for p in pomodoros if p["start_time"].startswith(day_str)] - daily_totals.append( - { - "date": day_str, - "minutes": sum(p["duration_minutes"] for p in day_pomodoros), - "count": len(day_pomodoros), - } - ) - - return total_minutes, total_count, by_type, daily_totals - - -@app.route("/api/reports/") -def get_report(period): - """Get report data for a given period (day, week, month).""" - start_iso = request.args.get("start_date") - end_iso = request.args.get("end_date") + service = get_sheets_service() + spreadsheet_id = get_spreadsheet_id_from_request() + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + pomodoros = sheets_storage.get_pomodoros(service, spreadsheet_id, start_date, end_date) + return jsonify(pomodoros) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - if start_iso and end_iso: - dates, start_iso, end_iso = _parse_iso_date_range(start_iso, end_iso) - else: - date_str = request.args.get("date", datetime.utcnow().strftime("%Y-%m-%d")) - ref_date = datetime.strptime(date_str, "%Y-%m-%d") - dates, start_iso, end_iso = _calculate_period_date_range(period, ref_date) - if dates is None: - return jsonify({"error": "Invalid period"}), HTTPStatus.BAD_REQUEST - - db = get_db() - rows = db.execute( - """SELECT id, name, type, start_time, end_time, duration_minutes, notes FROM pomodoros - WHERE start_time >= ? AND start_time < ? ORDER BY start_time""", - (start_iso, end_iso), - ).fetchall() - pomodoros = [dict(row) for row in rows] - - total_minutes, total_count, by_type, daily_totals = _calculate_report_stats(pomodoros, dates) - - return jsonify( - { - "period": period, - "total_minutes": total_minutes, - "total_pomodoros": total_count, - "by_type": by_type, - "daily_totals": daily_totals, - } - ) +@app.route("/api/sheets/pomodoros/count") +def proxy_get_pomodoro_count(): + """Get count of pomodoros in Google Sheets - efficient, only fetches IDs.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED -@app.route("/api/export") -def export_csv(): - """Export pomodoros as CSV from SQLite cache.""" - # Always read from SQLite (fast local cache) - db = get_db() - rows = db.execute( - "SELECT id, name, type, start_time, end_time, duration_minutes, notes FROM pomodoros ORDER BY start_time DESC" - ).fetchall() - pomodoros = [dict(row) for row in rows] - - lines = ["id,name,type,start_time,end_time,duration_minutes,notes"] - for p in pomodoros: - name = (p["name"] or "").replace('"', '""') - notes = (p.get("notes") or "").replace('"', '""') - lines.append( - f'"{p["id"]}","{name}","{p["type"]}","{p["start_time"]}",' - f'"{p["end_time"]}",{p["duration_minutes"]},"{notes}"' + try: + service = get_sheets_service() + spreadsheet_id = get_spreadsheet_id_from_request() + # Only fetch the ID column to count rows efficiently + sheets_response = ( + service.spreadsheets() + .values() + .get( + spreadsheetId=spreadsheet_id, + range="Pomodoros!A:A", + ) + .execute() ) + rows = sheets_response.get("values", []) + # Subtract 1 for header row, ensure non-negative + count = max(0, len(rows) - 1) + return jsonify({"count": count}) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - from flask import Response - return Response( - "\n".join(lines), - mimetype="text/csv", - headers={"Content-Disposition": "attachment;filename=pomodoros.csv"}, - ) +def get_request_data(): + """Get request JSON data, stripping _credentials if present.""" + request_body = request.json + if request_body and "_credentials" in request_body: + request_body = {k: v for k, v in request_body.items() if k != "_credentials"} + return request_body -@app.route("/api/local-pomodoro-count") -def get_local_pomodoro_count(): - """Get count of pomodoros in local SQLite database.""" - db = get_db() - count = db.execute("SELECT COUNT(*) FROM pomodoros").fetchone()[0] - return jsonify({"count": count}) +@app.route("/api/sheets/pomodoros", methods=["POST"]) +def proxy_create_pomodoro(): + """Proxy write to Google Sheets - stateless, credentials from request.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED + try: + service = get_sheets_service() + if not service: + return jsonify({"error": "Failed to create Sheets service - invalid credentials"}), HTTPStatus.UNAUTHORIZED + spreadsheet_id = get_spreadsheet_id_from_request() + if not spreadsheet_id: + return jsonify({"error": "No spreadsheet ID provided"}), HTTPStatus.BAD_REQUEST + pomodoro = get_request_data() + sheets_storage.save_pomodoro(service, spreadsheet_id, pomodoro) + return jsonify({"status": "ok", "id": pomodoro.get("id")}) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + except Exception as e: + import traceback -@app.route("/api/sync/status") -def get_sync_status(): - """Get current sync status.""" - db = get_db() + app.logger.error(f"Error in proxy_create_pomodoro: {e}\n{traceback.format_exc()}") + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - # Get pending sync count - pending_count = db.execute("SELECT COUNT(*) FROM sync_queue").fetchone()[0] - unsynced_count = db.execute("SELECT COUNT(*) FROM pomodoros WHERE synced = 0").fetchone()[0] - # Get last sync times - last_sync_row = db.execute("SELECT value FROM sync_status WHERE key = 'last_sync'").fetchone() - last_full_sync_row = db.execute("SELECT value FROM sync_status WHERE key = 'last_full_sync'").fetchone() +@app.route("/api/sheets/pomodoros/batch", methods=["POST"]) +def proxy_create_pomodoros_batch(): + """Batch upload pomodoros to Google Sheets - stateless.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED - return jsonify( - { - "syncing": sync_in_progress, - "pending_operations": pending_count, - "unsynced_pomodoros": unsynced_count, - "last_sync": last_sync_row["value"] if last_sync_row else None, - "last_full_sync": last_full_sync_row["value"] if last_full_sync_row else None, - "last_error": last_sync_error, - "google_connected": use_google_sheets(), - } - ) + try: + service = get_sheets_service() + if not service: + return jsonify({"error": "Failed to create Sheets service"}), HTTPStatus.UNAUTHORIZED + spreadsheet_id = get_spreadsheet_id_from_request() + if not spreadsheet_id: + return jsonify({"error": "No spreadsheet ID provided"}), HTTPStatus.BAD_REQUEST + batch_request = get_request_data() + pomodoros = batch_request.get("pomodoros", []) + count = sheets_storage.save_pomodoros_batch(service, spreadsheet_id, pomodoros) + return jsonify({"status": "ok", "count": count}) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + except Exception as e: + import traceback + app.logger.error(f"Error in proxy_create_pomodoros_batch: {e}\n{traceback.format_exc()}") + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR -@app.route("/api/sync/check") -def check_sync_sources(): - """Check data counts from both local SQLite and Google Sheets.""" - if not use_google_sheets(): - return jsonify({"error": "Not logged in to Google"}), HTTPStatus.UNAUTHORIZED - # Get user's local count (per-user database) - db = get_db() - local_count = db.execute("SELECT COUNT(*) FROM pomodoros").fetchone()[0] +@app.route("/api/sheets/pomodoros/", methods=["PUT"]) +def proxy_update_pomodoro(pomodoro_id): + """Proxy update to Google Sheets - stateless, credentials from request.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED - # Check shared cache (DEFAULT_DB_PATH) for pomodoros from before login - shared_cache_count = 0 - if DEFAULT_DB_PATH.exists() and get_user_db_path() != DEFAULT_DB_PATH: - try: - shared_db = sqlite3.connect(DEFAULT_DB_PATH) - shared_db.row_factory = sqlite3.Row - shared_cache_count = shared_db.execute("SELECT COUNT(*) FROM pomodoros").fetchone()[0] - shared_db.close() - except sqlite3.OperationalError: - shared_cache_count = 0 # Table doesn't exist in shared cache - - # Get Google Sheets count try: service = get_sheets_service() - sheets_pomodoros = sheets_storage.get_pomodoros(service, session["spreadsheet_id"]) - sheets_count = len(sheets_pomodoros) + spreadsheet_id = get_spreadsheet_id_from_request() + update_fields = get_request_data() + success = sheets_storage.update_pomodoro(service, spreadsheet_id, pomodoro_id, update_fields) + if success: + return jsonify({"status": "ok"}) + return jsonify({"error": "Pomodoro not found"}), HTTPStatus.NOT_FOUND except HttpError as e: - return jsonify({"error": f"Google Sheets API error: {e}"}), HTTPStatus.INTERNAL_SERVER_ERROR - - return jsonify( - { - "local_count": local_count, - "shared_cache_count": shared_cache_count, - "sheets_count": sheets_count, - "needs_initial_sync": session.get("needs_initial_sync", False), - } - ) - + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR -@app.route("/api/sync/now", methods=["POST"]) -def trigger_sync(): - """Manually trigger a sync with Google Sheets.""" - if not use_google_sheets(): - return jsonify({"error": "Not logged in to Google"}), HTTPStatus.UNAUTHORIZED - db_path = get_user_db_path() +@app.route("/api/sheets/pomodoros/", methods=["DELETE"]) +def proxy_delete_pomodoro(pomodoro_id): + """Proxy delete to Google Sheets - stateless, credentials from request.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED - # First pull from Google Sheets try: - count = sync_from_sheets(db_path, session["credentials"], session["spreadsheet_id"]) + service = get_sheets_service() + spreadsheet_id = get_spreadsheet_id_from_request() + success = sheets_storage.delete_pomodoro(service, spreadsheet_id, pomodoro_id) + if success: + return jsonify({"status": "ok"}) + return jsonify({"error": "Pomodoro not found"}), HTTPStatus.NOT_FOUND except HttpError as e: - return jsonify({"error": f"Google Sheets sync failed: {e}"}), HTTPStatus.INTERNAL_SERVER_ERROR - - # Then push any pending changes - start_background_sync(db_path, session["credentials"], session["spreadsheet_id"]) - - return jsonify({"status": "ok", "synced_from_sheets": count}) + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR -def _row_to_pomodoro(row): - """Convert a database row to a pomodoro dict.""" - return { - "id": row["id"], - "name": row["name"], - "type": row["type"], - "start_time": row["start_time"], - "end_time": row["end_time"], - "duration_minutes": row["duration_minutes"], - "notes": row["notes"], - } +@app.route("/api/sheets/settings", methods=["GET"]) +def proxy_get_settings(): + """Proxy settings read from Google Sheets - stateless.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED + try: + service = get_sheets_service() + spreadsheet_id = get_spreadsheet_id_from_request() + settings = sheets_storage.get_settings(service, spreadsheet_id, DEFAULT_SETTINGS) + return jsonify(settings) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR -def _migrate_shared_cache_to_sheets(service, spreadsheet_id): - """Migrate pomodoros from shared cache to Google Sheets.""" - if not DEFAULT_DB_PATH.exists() or get_user_db_path() == DEFAULT_DB_PATH: - return 0, 0 - shared_db = sqlite3.connect(DEFAULT_DB_PATH) - shared_db.row_factory = sqlite3.Row +@app.route("/api/sheets/settings", methods=["POST"]) +def proxy_save_settings(): + """Proxy settings write to Google Sheets - stateless.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED - existing_ids = {p["id"] for p in sheets_storage.get_pomodoros(service, spreadsheet_id)} - rows = shared_db.execute("SELECT * FROM pomodoros ORDER BY start_time").fetchall() + try: + service = get_sheets_service() + spreadsheet_id = get_spreadsheet_id_from_request() + settings_payload = get_request_data() + # Check for replace_all flag (used by "Overwrite Google" button) + replace_all = settings_payload.pop("_replace_all", False) if isinstance(settings_payload, dict) else False + sheets_storage.save_settings(service, spreadsheet_id, settings_payload, replace_all=replace_all) + return jsonify({"status": "ok"}) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - pomodoros_to_upload = [] - skipped = 0 - for row in rows: - if row["id"] in existing_ids: - skipped += 1 - else: - pomodoros_to_upload.append(_row_to_pomodoro(row)) - if pomodoros_to_upload: - sheets_storage.save_pomodoros_batch(service, spreadsheet_id, pomodoros_to_upload) +@app.route("/api/sheets/deduplicate", methods=["POST"]) +def proxy_deduplicate_pomodoros(): + """Remove duplicate pomodoros from Google Sheets - stateless.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED - shared_db.execute("DELETE FROM pomodoros") - shared_db.commit() - shared_db.close() - return len(pomodoros_to_upload), skipped + try: + service = get_sheets_service() + spreadsheet_id = get_spreadsheet_id_from_request() + dedup_result = sheets_storage.deduplicate_pomodoros(service, spreadsheet_id) + return jsonify(dedup_result) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR -def _migrate_local_to_sheets(db, service, spreadsheet_id): - """Migrate pomodoros from local SQLite to Google Sheets.""" - existing_ids = {p["id"] for p in sheets_storage.get_pomodoros(service, spreadsheet_id)} - rows = db.execute("SELECT * FROM pomodoros ORDER BY start_time").fetchall() +@app.route("/api/sheets/export") +def proxy_export_csv(): + """Export pomodoros as CSV from Google Sheets - stateless.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED - pomodoros_to_upload = [] - ids_to_mark_synced = [] - skipped = 0 - for row in rows: - if row["id"] in existing_ids: - skipped += 1 - else: - pomodoros_to_upload.append(_row_to_pomodoro(row)) - ids_to_mark_synced.append(row["id"]) - - if pomodoros_to_upload: - sheets_storage.save_pomodoros_batch(service, spreadsheet_id, pomodoros_to_upload) - for pomodoro_id in ids_to_mark_synced: - db.execute("UPDATE pomodoros SET synced = 1 WHERE id = ?", (pomodoro_id,)) - db.commit() - return len(pomodoros_to_upload), skipped - - -def _migrate_sheets_to_local(db, service, spreadsheet_id): - """Migrate pomodoros from Google Sheets to local SQLite.""" - sheets_pomodoros = sheets_storage.get_pomodoros(service, spreadsheet_id) - local_ids = {row["id"] for row in db.execute("SELECT id FROM pomodoros").fetchall()} - - migrated = 0 - skipped = 0 - for p in sheets_pomodoros: - if p["id"] in local_ids: - skipped += 1 - else: - db.execute( - """INSERT INTO pomodoros (id, name, type, start_time, end_time, duration_minutes, notes, synced) - VALUES (?, ?, ?, ?, ?, ?, ?, 1)""", - (p["id"], p["name"], p["type"], p["start_time"], p["end_time"], p["duration_minutes"], p.get("notes")), - ) - migrated += 1 - db.commit() - return migrated, skipped - - -def _migrate_settings(db, service, spreadsheet_id, direction): - """Migrate settings between local SQLite and Google Sheets.""" - if direction == "local_to_sheets": - settings_rows = db.execute("SELECT key, value FROM settings").fetchall() - if settings_rows: - settings_data = {row["key"]: json.loads(row["value"]) for row in settings_rows} - sheets_storage.save_settings(service, spreadsheet_id, settings_data) - return len(settings_rows) - return 0 - - if direction == "sheets_to_local": - defaults = { - "timer_preset_1": DEFAULT_SHORT_BREAK, - "timer_preset_2": TIMER_PRESET_MEDIUM, - "timer_preset_3": DEFAULT_LONG_BREAK, - "timer_preset_4": DEFAULT_POMODORO_DURATION, - "short_break_minutes": DEFAULT_SHORT_BREAK, - "long_break_minutes": DEFAULT_LONG_BREAK, - "pomodoros_until_long_break": DEFAULT_POMODOROS_UNTIL_LONG_BREAK, - "always_use_short_break": False, - "sound_enabled": True, - "notifications_enabled": True, - "pomodoro_types": DEFAULT_POMODORO_TYPES, - "auto_start_after_break": False, - "tick_sound_during_breaks": False, - "bell_at_pomodoro_end": True, - "bell_at_break_end": True, - "show_notes_field": False, - "working_hours_start": "08:00", - "working_hours_end": "17:00", - "clock_format": "auto", - "period_labels": "auto", - "daily_minutes_goal": DEFAULT_DAILY_GOAL, - } - sheets_settings = sheets_storage.get_settings(service, spreadsheet_id, defaults) - for key, value in sheets_settings.items(): - db.execute( - "INSERT OR REPLACE INTO settings (key, value, synced) VALUES (?, ?, 1)", (key, json.dumps(value)) + try: + service = get_sheets_service() + spreadsheet_id = get_spreadsheet_id_from_request() + pomodoros = sheets_storage.get_pomodoros(service, spreadsheet_id) + + lines = ["id,name,type,start_time,end_time,duration_minutes,notes"] + for p in pomodoros: + name = (p["name"] or "").replace('"', '""') + notes = (p.get("notes") or "").replace('"', '""') + lines.append( + f'"{p["id"]}","{name}","{p["type"]}","{p["start_time"]}",' + f'"{p["end_time"]}",{p["duration_minutes"]},"{notes}"' ) - db.commit() - return len(sheets_settings) - - return 0 + return Response( + "\n".join(lines), + mimetype="text/csv", + headers={"Content-Disposition": "attachment;filename=pomodoros.csv"}, + ) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR -@app.route("/api/migrate", methods=["POST"]) -def migrate_data(): - """Migrate data between SQLite and Google Sheets.""" - if not use_google_sheets(): - return jsonify({"error": "Not logged in to Google"}), HTTPStatus.UNAUTHORIZED - - migration_options = request.json or {} - pomodoros_direction = migration_options.get("pomodoros_direction", "skip") - settings_direction = migration_options.get("settings_direction", "skip") - - db = get_db() - service = get_sheets_service() - spreadsheet_id = session["spreadsheet_id"] - migrated_pomodoros, skipped_pomodoros = 0, 0 - if pomodoros_direction == "shared_cache_to_sheets": - migrated_pomodoros, skipped_pomodoros = _migrate_shared_cache_to_sheets(service, spreadsheet_id) - elif pomodoros_direction == "local_to_sheets": - migrated_pomodoros, skipped_pomodoros = _migrate_local_to_sheets(db, service, spreadsheet_id) - elif pomodoros_direction == "sheets_to_local": - migrated_pomodoros, skipped_pomodoros = _migrate_sheets_to_local(db, service, spreadsheet_id) +@app.route("/api/sheets/clear", methods=["POST"]) +def proxy_clear_sheets(): + """Clear all pomodoro data from Google Sheets (keeps headers) - stateless.""" + if not is_logged_in(): + return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED - settings_migrated = _migrate_settings(db, service, spreadsheet_id, settings_direction) - session["needs_initial_sync"] = False + try: + service = get_sheets_service() + spreadsheet_id = get_spreadsheet_id_from_request() + + # Get the sheet ID for Pomodoros sheet + spreadsheet = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() + pomodoros_sheet_id = None + for sheet in spreadsheet["sheets"]: + if sheet["properties"]["title"] == "Pomodoros": + pomodoros_sheet_id = sheet["properties"]["sheetId"] + break + + if pomodoros_sheet_id is None: + return jsonify({"error": "Pomodoros sheet not found"}), HTTPStatus.NOT_FOUND + + # Get current row count + values = service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range="Pomodoros!A:A").execute() + row_count = len(values.get("values", [])) + + if row_count <= 1: + # Only header or empty, nothing to clear + return jsonify({"status": "ok", "cleared": 0}) + + # Delete all data rows (keep header at row 1) + service.spreadsheets().batchUpdate( + spreadsheetId=spreadsheet_id, + body={ + "requests": [ + { + "deleteDimension": { + "range": { + "sheetId": pomodoros_sheet_id, + "dimension": "ROWS", + "startIndex": 1, # After header + "endIndex": row_count, + } + } + } + ] + }, + ).execute() - return jsonify( - { - "success": True, - "pomodoros_migrated": migrated_pomodoros, - "pomodoros_skipped": skipped_pomodoros, - "pomodoros_direction": pomodoros_direction, - "settings_migrated": settings_migrated, - "settings_direction": settings_direction, - } - ) + return jsonify({"status": "ok", "cleared": row_count - 1}) + except HttpError as e: + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR if __name__ == "__main__": diff --git a/docs/DATA_ARCHITECTURE.md b/docs/DATA_ARCHITECTURE.md new file mode 100644 index 0000000..5c59ea0 --- /dev/null +++ b/docs/DATA_ARCHITECTURE.md @@ -0,0 +1,420 @@ +# Acquacotta Data Architecture + +This document explains how Acquacotta stores and synchronizes your pomodoro data. It's written for multiple audiences: everyday users, software architects, developers, and system administrators. + +## Table of Contents + +1. [For Users: Where Does My Data Live?](#for-users-where-does-my-data-live) +2. [For Architects: System Design](#for-architects-system-design) +3. [For Developers: Implementation Details](#for-developers-implementation-details) +4. [For Sysadmins: Deployment & Operations](#for-sysadmins-deployment--operations) + +--- + +## For Users: Where Does My Data Live? + +### Demo Mode (Not Logged In) + +When you use Acquacotta without logging in: + +- **Your data stays on your device** - stored in your browser's local database (IndexedDB) +- **Nothing is sent to any server** - complete privacy +- **Data persists** until you clear your browser data +- **No account needed** - just start tracking + +### Logged In Mode (Google Account) + +When you log in with Google: + +- **Your data is stored in YOUR Google Sheets** - a spreadsheet in your own Google Drive +- **The server never stores your data** - it only helps you talk to Google +- **You own your data** - export, delete, or modify it anytime in Google Sheets +- **Syncs across devices** - access from any browser where you log in + +### What Gets Stored? + +| Data Type | Demo Mode | Logged In Mode | +|-----------|-----------|----------------| +| Pomodoros (tasks, times, notes) | Browser only | Browser + Your Google Sheet | +| Settings (timer presets, preferences) | Browser only | Browser + Your Google Sheet | +| Google credentials | N/A | Browser only (NOT on server) | + +### Privacy Guarantees + +1. **No analytics or tracking** - we don't know how you use the app +2. **No data on our servers** - the server is stateless +3. **Your Google Sheet = Your data** - we can't see it without you being logged in +4. **Minimal permissions** - we only ask for access to files we create + +--- + +## For Architects: System Design + +### Design Principles + +1. **Stateless Server** - The server stores no user data +2. **Browser-First Storage** - IndexedDB is the primary data store +3. **User-Owned Cloud Backup** - Google Sheets belongs to the user +4. **Offline-First** - Full functionality without internet + +### Component Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ USER'S BROWSER │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Acquacotta │ │ IndexedDB │ │ +│ │ Frontend │◄──►│ (Primary Store)│ │ +│ │ (JavaScript) │ │ │ │ +│ └────────┬────────┘ │ • pomodoros │ │ +│ │ │ • settings │ │ +│ │ │ • sync_queue │ │ +│ │ │ • auth (creds) │ │ +│ │ └─────────────────┘ │ +└───────────┼─────────────────────────────────────────────────────┘ + │ HTTPS (credentials in request) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ACQUACOTTA SERVER │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Flask Application │ │ +│ │ • OAuth flow handler (login/callback) │ │ +│ │ • Google Sheets API proxy │ │ +│ │ • NO user data storage │ │ +│ │ • NO database │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────┬─────────────────────────────────────────────────────┘ + │ Google APIs (OAuth tokens from browser) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ USER'S GOOGLE ACCOUNT │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Google Sheets (User's Drive) │ │ +│ │ │ │ +│ │ Spreadsheet: "Acquacotta Pomodoro Tracker" │ │ +│ │ ├── Sheet: "Pomodoros" │ │ +│ │ │ └── Columns: id, name, type, start_time, │ │ +│ │ │ end_time, duration_minutes, notes │ │ +│ │ └── Sheet: "Settings" │ │ +│ │ └── Columns: key, value │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Data Flow: Creating a Pomodoro + +``` +1. User completes timer + │ +2. Save to IndexedDB (immediate, works offline) + │ +3. Add to sync_queue in IndexedDB + │ +4. If online + logged in: + │ +5. POST /api/sheets/pomodoros + │ (credentials included in request body) + │ +6. Server proxies to Google Sheets API + │ (using user's OAuth token) + │ +7. On success: mark synced=true, remove from queue + On failure: retry with exponential backoff +``` + +### Sync Strategies + +| Scenario | Strategy | +|----------|----------| +| New Google Sheet | Push local data TO Sheet | +| Existing Google Sheet | Bidirectional sync by unique ID | +| Settings conflict | Sheet is authoritative (pull from Sheet) | +| Pomodoro conflict | Merge by ID (no overwrites) | +| Offline changes | Queue locally, sync when online | + +### Duplicate Prevention + +Duplicates are prevented at multiple layers: + +1. **Sync Queue** - Removes existing queue items for same record before adding +2. **Backend Check** - `save_pomodoro()` checks if ID exists before appending +3. **Initial Sync** - Runs deduplication on first connect to existing sheet + +--- + +## For Developers: Implementation Details + +### IndexedDB Schema + +```javascript +// Database: 'acquacotta', Version: 2 + +// Object Store: 'pomodoros' +// KeyPath: 'id' +// Indexes: 'start_time', 'type', 'synced' +{ + id: "uuid-v4", + name: "Task name", + type: "Product", + start_time: "2025-01-25T09:00:00.000Z", // ISO 8601 + end_time: "2025-01-25T09:25:00.000Z", + duration_minutes: 25, + notes: "Optional notes", + synced: false // true after synced to Sheets +} + +// Object Store: 'settings' +// KeyPath: 'key' +{ + key: "timer_preset_1", + value: 25, + synced: false +} + +// Object Store: 'sync_queue' +// KeyPath: 'id' (autoIncrement) +{ + id: 1, + operation: "create", // create, update, delete + store: "pomodoros", + record_id: "uuid", + data: { ... }, + created_at: "2025-01-25T09:00:00.000Z", + retries: 0 +} + +// Object Store: 'auth' +// KeyPath: 'key' +{ + key: "credentials", + token: "ya29...", + refresh_token: "1//...", + spreadsheet_id: "1xQm...", + user_email: "user@gmail.com", + // ... other OAuth data +} + +// Object Store: 'sync_status' +// KeyPath: 'key' +{ + key: "initial_sync_done", + value: true +} +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `static/js/storage.js` | IndexedDB operations, sync logic, Storage API | +| `app.py` | Flask server, OAuth flow, Sheets API proxy | +| `sheets_storage.py` | Google Sheets CRUD operations | +| `templates/index.html` | Single-page app with all UI logic | + +### API Endpoints + +#### Authentication +- `GET /auth/google` - Initiate OAuth flow +- `GET /auth/callback` - OAuth callback, stores credentials in browser +- `GET /auth/logout` - Clear session +- `GET /api/auth/status` - Check if Google is configured + +#### Sheets Proxy (all require credentials in request) +- `GET /api/sheets/pomodoros` - List pomodoros +- `GET /api/sheets/pomodoros/count` - Efficient count (IDs only) +- `POST /api/sheets/pomodoros` - Create pomodoro +- `PUT /api/sheets/pomodoros/` - Update pomodoro +- `DELETE /api/sheets/pomodoros/` - Delete pomodoro +- `GET /api/sheets/settings` - Get settings +- `POST /api/sheets/settings` - Save settings +- `POST /api/sheets/deduplicate` - Remove duplicate rows +- `GET /api/sheets/export` - Export as CSV + +### Credential Handling + +Credentials flow from browser to server with each request: + +```javascript +// For GET/DELETE: Base64-encoded header +headers['X-Credentials'] = btoa(JSON.stringify({ + token: "...", + refresh_token: "...", + spreadsheet_id: "...", + // ... +})); + +// For POST/PUT: Merged into request body +body._credentials = { + token: "...", + refresh_token: "...", + spreadsheet_id: "...", + // ... +}; +``` + +### Sync Queue Processing + +```javascript +async function processSyncQueue() { + // Promise-based lock prevents concurrent syncs + if (syncLockPromise) { + await syncLockPromise; + return; + } + + let resolveLock; + syncLockPromise = new Promise(r => resolveLock = r); + + try { + const queue = await getAllFromStore(STORES.SYNC_QUEUE); + queue.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + + for (const item of queue) { + await syncOperationToSheets(item); + await deleteFromStore(STORES.SYNC_QUEUE, item.id); + } + } finally { + syncLockPromise = null; + resolveLock(); + } +} +``` + +### Initial Sync Logic + +```javascript +if (storedCredentials.spreadsheet_existed) { + // Existing sheet: bidirectional merge + // 1. Deduplicate Sheet + // 2. Pull pomodoros from Sheet (add missing to local) + // 3. Push local pomodoros to Sheet (add missing to Sheet) + // 4. Pull settings from Sheet (Sheet is authoritative) + // 5. Save spreadsheet_id to Sheet settings +} else { + // New sheet: push local to Sheet + // 1. Queue all local pomodoros + // 2. Queue all local settings + // 3. Process sync queue +} +``` + +--- + +## For Sysadmins: Deployment & Operations + +### Container Architecture + +``` +┌─────────────────────────────────────────┐ +│ Container (Port 80) │ +│ ┌─────────────────────────────────┐ │ +│ │ Apache (Reverse Proxy) │ │ +│ │ Port 80 (external) │ │ +│ └──────────────┬──────────────────┘ │ +│ │ │ +│ ┌──────────────▼──────────────────┐ │ +│ │ Gunicorn (WSGI Server) │ │ +│ │ Port 5000 (internal) │ │ +│ └──────────────┬──────────────────┘ │ +│ │ │ +│ ┌──────────────▼──────────────────┐ │ +│ │ Flask Application │ │ +│ │ (Stateless) │ │ +│ └─────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +### Environment Variables + +```bash +# Required for Google OAuth +GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-client-secret + +# Flask configuration +FLASK_SECRET_KEY=random-secret-for-sessions +``` + +### Container Commands + +```bash +# Build +podman build -t acquacotta:dev -f Containerfile . + +# Run (map host:5000 to container:80) +podman run -d --name acquacotta-dev \ + -p 5000:80 \ + --env-file .env \ + acquacotta:dev + +# View logs +podman logs -f acquacotta-dev + +# Stop and remove +podman stop acquacotta-dev && podman rm acquacotta-dev +``` + +### What the Server DOES Store + +| Item | Storage | Purpose | +|------|---------|---------| +| Flask session cookie | Memory (not persisted) | CSRF protection during OAuth | +| Static files | Container filesystem | HTML, JS, CSS | + +### What the Server Does NOT Store + +- User pomodoro data +- User settings +- OAuth tokens (passed per-request from browser) +- Any persistent database + +### Scaling Considerations + +Since the server is stateless: + +- **Horizontal scaling** - Run multiple containers behind a load balancer +- **No shared state** - No database clustering needed +- **No sticky sessions** - Any container can handle any request +- **Container restarts** - No data loss (data lives in browser + Google Sheets) + +### Monitoring + +Key metrics to watch: + +- Google Sheets API quota usage (per user) +- OAuth token refresh failures +- 5xx error rates on `/api/sheets/*` endpoints + +### Backup & Recovery + +- **User data backup** - Users own their Google Sheet (Google handles backup) +- **Server backup** - Not needed (stateless, no persistent data) +- **Browser data loss** - User logs in again, data syncs from their Sheet + +--- + +## Appendix: Google Sheets Structure + +### Pomodoros Sheet + +| Column | Type | Description | +|--------|------|-------------| +| A: id | UUID | Unique identifier | +| B: name | String | Task/activity name | +| C: type | String | Category (Product, Learn, etc.) | +| D: start_time | ISO 8601 | When pomodoro started | +| E: end_time | ISO 8601 | When pomodoro ended | +| F: duration_minutes | Integer | Duration in minutes | +| G: notes | String | Optional notes | + +### Settings Sheet + +| Column | Type | Description | +|--------|------|-------------| +| A: key | String | Setting name | +| B: value | JSON | Setting value (JSON-encoded) | + +Example settings: +- `timer_preset_1`: `25` +- `pomodoro_types`: `["Product", "Learn", "Team"]` +- `spreadsheet_id`: `"1xQm..."` (for reconnection) diff --git a/pyproject.toml b/pyproject.toml index c8e79aa..c62577e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ max-complexity = 10 [tool.ruff.lint.per-file-ignores] "tests/*" = ["PLR2004"] # Magic values acceptable in test assertions +"app.py" = ["PLR0915"] # auth_callback is complex by nature (OAuth + IndexedDB setup) [tool.ruff.lint.isort] known-first-party = ["app", "sheets_storage"] diff --git a/sheets_storage.py b/sheets_storage.py index f2f6a76..b6f1126 100644 --- a/sheets_storage.py +++ b/sheets_storage.py @@ -50,7 +50,27 @@ def get_pomodoros(sheets_service, spreadsheet_id, start_date=None, end_date=None def save_pomodoro(sheets_service, spreadsheet_id, pomodoro): - """Save a new pomodoro to Google Sheets.""" + """Save a new pomodoro to Google Sheets (with duplicate check).""" + # First check if this ID already exists to prevent duplicates + id_lookup = ( + sheets_service.spreadsheets() + .values() + .get( + spreadsheetId=spreadsheet_id, + range="Pomodoros!A:A", + ) + .execute() + ) + + existing_ids = id_lookup.get("values", []) + pomodoro_id = pomodoro["id"] + + for row in existing_ids: + if row and row[0] == pomodoro_id: + # ID already exists, skip insert (could also update here) + return False + + # ID doesn't exist, append new row sheets_service.spreadsheets().values().append( spreadsheetId=spreadsheet_id, range="Pomodoros!A:G", @@ -59,7 +79,7 @@ def save_pomodoro(sheets_service, spreadsheet_id, pomodoro): body={ "values": [ [ - pomodoro["id"], + pomodoro_id, pomodoro["name"], pomodoro["type"], pomodoro["start_time"], @@ -70,26 +90,48 @@ def save_pomodoro(sheets_service, spreadsheet_id, pomodoro): ] }, ).execute() + return True def save_pomodoros_batch(sheets_service, spreadsheet_id, pomodoros): - """Save multiple pomodoros to Google Sheets in a single request.""" + """Save multiple pomodoros to Google Sheets in a single request (with duplicate check).""" if not pomodoros: - return + return 0 + # First get all existing IDs + id_lookup = ( + sheets_service.spreadsheets() + .values() + .get( + spreadsheetId=spreadsheet_id, + range="Pomodoros!A:A", + ) + .execute() + ) + + existing_ids = set() + for row in id_lookup.get("values", []): + if row: + existing_ids.add(row[0]) + + # Filter out pomodoros that already exist rows = [] for p in pomodoros: - rows.append( - [ - p["id"], - p["name"], - p["type"], - p["start_time"], - p["end_time"], - p["duration_minutes"], - p.get("notes") or "", - ] - ) + if p["id"] not in existing_ids: + rows.append( + [ + p["id"], + p["name"], + p["type"], + p["start_time"], + p["end_time"], + p["duration_minutes"], + p.get("notes") or "", + ] + ) + + if not rows: + return 0 sheets_service.spreadsheets().values().append( spreadsheetId=spreadsheet_id, @@ -98,6 +140,7 @@ def save_pomodoros_batch(sheets_service, spreadsheet_id, pomodoros): insertDataOption="INSERT_ROWS", body={"values": rows}, ).execute() + return len(rows) def update_pomodoro(sheets_service, spreadsheet_id, pomodoro_id, update_fields): @@ -240,8 +283,115 @@ def get_settings(sheets_service, spreadsheet_id, defaults): return settings -def save_settings(sheets_service, spreadsheet_id, settings_data): - """Save settings to Google Sheets.""" +def deduplicate_pomodoros(sheets_service, spreadsheet_id): + """Remove duplicate pomodoros from Google Sheets (keeps first occurrence of each ID). + + Returns: + dict: {'removed': count_removed, 'total': total_rows} + """ + # Get all pomodoros with their row indices + id_lookup = ( + sheets_service.spreadsheets() + .values() + .get( + spreadsheetId=spreadsheet_id, + range="Pomodoros!A:A", + ) + .execute() + ) + + rows = id_lookup.get("values", []) + + # Track seen IDs and rows to delete (0-indexed) + seen_ids = set() + rows_to_delete = [] + + for i, row in enumerate(rows): + if i == 0: + # Skip header row + continue + if row: + pomodoro_id = row[0] + if pomodoro_id in seen_ids: + rows_to_delete.append(i) + else: + seen_ids.add(pomodoro_id) + + if not rows_to_delete: + return {"removed": 0, "total": len(rows) - 1} # -1 for header + + # Get sheet ID + spreadsheet = sheets_service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() + + sheet_id = None + for sheet in spreadsheet["sheets"]: + if sheet["properties"]["title"] == "Pomodoros": + sheet_id = sheet["properties"]["sheetId"] + break + + if sheet_id is None: + return {"removed": 0, "total": len(rows) - 1, "error": "Pomodoros sheet not found"} + + # Delete rows in reverse order (so indices don't shift) + rows_to_delete.reverse() + + requests = [] + for row_index in rows_to_delete: + requests.append( + { + "deleteDimension": { + "range": { + "sheetId": sheet_id, + "dimension": "ROWS", + "startIndex": row_index, + "endIndex": row_index + 1, + } + } + } + ) + + # Execute batch delete + sheets_service.spreadsheets().batchUpdate( + spreadsheetId=spreadsheet_id, + body={"requests": requests}, + ).execute() + + return {"removed": len(rows_to_delete), "total": len(rows) - 1 - len(rows_to_delete)} + + +def save_settings(sheets_service, spreadsheet_id, settings_data, replace_all=False): + """Save settings to Google Sheets. + + Args: + sheets_service: Google Sheets API service + spreadsheet_id: ID of the spreadsheet + settings_data: Dictionary of settings to save + replace_all: If True, clear all settings first and replace with new data + """ + if replace_all: + # Clear all settings rows (keep header) and replace with new data + sheets_service.spreadsheets().values().clear( + spreadsheetId=spreadsheet_id, + range="Settings!A2:B", + ).execute() + + # Prepare all settings as rows + rows = [] + for key, value in settings_data.items(): + value_str = json.dumps(value) + rows.append([key, value_str]) + + # Write all settings at once + if rows: + sheets_service.spreadsheets().values().update( + spreadsheetId=spreadsheet_id, + range="Settings!A2:B", + valueInputOption="RAW", + body={"values": rows}, + ).execute() + return + + # Incremental update mode (default) # Get existing settings existing_settings = ( sheets_service.spreadsheets() diff --git a/static/js/storage.js b/static/js/storage.js new file mode 100644 index 0000000..3b46eb0 --- /dev/null +++ b/static/js/storage.js @@ -0,0 +1,1391 @@ +/** + * Acquacotta Storage Layer - IndexedDB Implementation + * + * Provides a unified interface for data storage using IndexedDB: + * - Demo mode: Browser IndexedDB only + * - Logged in: Browser IndexedDB + sync to/from Google Sheets + * + * The server is stateless - it only proxies API calls to Google Sheets. + * All user data lives in the browser's IndexedDB and optionally in their Google Sheets. + * + * Credit: kirkjerk (original idea for browser-side storage) + */ + +(function(global) { + 'use strict'; + + // IndexedDB configuration + const DB_NAME = 'acquacotta'; + const DB_VERSION = 2; // Bumped for auth store + + // Object store names + const STORES = { + POMODOROS: 'pomodoros', + SETTINGS: 'settings', + SYNC_QUEUE: 'sync_queue', + SYNC_STATUS: 'sync_status', + AUTH: 'auth' + }; + + // Default settings (mirrors backend defaults) + const DEFAULT_SETTINGS = { + timer_preset_1: 5, + timer_preset_2: 10, + timer_preset_3: 15, + timer_preset_4: 25, + short_break_minutes: 5, + long_break_minutes: 15, + pomodoros_until_long_break: 4, + always_use_short_break: false, + sound_enabled: true, + notifications_enabled: true, + pomodoro_types: [ + 'Content', + 'Customer/Partner/Community', + 'Learn/Train', + 'Product', + 'PTO', + 'Queued', + 'Social Media', + 'Team', + 'Travel', + 'Unqueued' + ], + auto_start_after_break: false, + tick_sound_during_breaks: false, + bell_at_pomodoro_end: true, + bell_at_break_end: true, + show_notes_field: false, + working_hours_start: '08:00', + working_hours_end: '17:00', + clock_format: 'auto', + period_labels: 'auto', + daily_minutes_goal: 300 + }; + + // Sync configuration + const SYNC_RETRY_DELAYS = [1000, 2000, 5000, 10000, 30000]; // Exponential backoff + const MAX_SYNC_RETRIES = 5; + + // Storage state + let db = null; + let authStatus = null; + let storedCredentials = null; // OAuth credentials from IndexedDB (ephemeral) + let cachedSpreadsheetId = null; // Spreadsheet ID from SETTINGS (persistent) + let isOnline = navigator.onLine; + let syncInProgress = false; + let syncLockPromise = null; // Promise-based lock to prevent race conditions + let pendingSyncCount = 0; + let lastSyncError = null; + + /** + * Generate a UUID v4 + */ + function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + /** + * Open IndexedDB database + */ + function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.error('IndexedDB error:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + db = request.result; + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const database = event.target.result; + + // Pomodoros store + if (!database.objectStoreNames.contains(STORES.POMODOROS)) { + const pomodorosStore = database.createObjectStore(STORES.POMODOROS, { keyPath: 'id' }); + pomodorosStore.createIndex('start_time', 'start_time', { unique: false }); + pomodorosStore.createIndex('type', 'type', { unique: false }); + pomodorosStore.createIndex('synced', 'synced', { unique: false }); + } + + // Settings store + if (!database.objectStoreNames.contains(STORES.SETTINGS)) { + database.createObjectStore(STORES.SETTINGS, { keyPath: 'key' }); + } + + // Sync queue store + if (!database.objectStoreNames.contains(STORES.SYNC_QUEUE)) { + const syncStore = database.createObjectStore(STORES.SYNC_QUEUE, { keyPath: 'id', autoIncrement: true }); + syncStore.createIndex('created_at', 'created_at', { unique: false }); + } + + // Sync status store + if (!database.objectStoreNames.contains(STORES.SYNC_STATUS)) { + database.createObjectStore(STORES.SYNC_STATUS, { keyPath: 'key' }); + } + + // Auth store (for OAuth credentials - keeps server stateless) + if (!database.objectStoreNames.contains(STORES.AUTH)) { + database.createObjectStore(STORES.AUTH, { keyPath: 'key' }); + } + }; + }); + } + + /** + * Generic IndexedDB transaction helper + */ + function dbTransaction(storeName, mode, callback) { + return new Promise((resolve, reject) => { + if (!db) { + reject(new Error('Database not initialized')); + return; + } + const transaction = db.transaction(storeName, mode); + const store = transaction.objectStore(storeName); + + try { + const result = callback(store); + if (result && result.onsuccess !== undefined) { + result.onsuccess = () => resolve(result.result); + result.onerror = () => reject(result.error); + } else { + transaction.oncomplete = () => resolve(result); + transaction.onerror = () => reject(transaction.error); + } + } catch (e) { + reject(e); + } + }); + } + + /** + * Get all records from a store + */ + function getAllFromStore(storeName) { + return dbTransaction(storeName, 'readonly', (store) => store.getAll()); + } + + /** + * Get a single record by key + */ + function getFromStore(storeName, key) { + return dbTransaction(storeName, 'readonly', (store) => store.get(key)); + } + + /** + * Put a record into a store + */ + function putInStore(storeName, record) { + return dbTransaction(storeName, 'readwrite', (store) => store.put(record)); + } + + /** + * Delete a record from a store + */ + function deleteFromStore(storeName, key) { + return dbTransaction(storeName, 'readwrite', (store) => store.delete(key)); + } + + /** + * Clear all records from a store + */ + function clearStore(storeName) { + return dbTransaction(storeName, 'readwrite', (store) => store.clear()); + } + + /** + * Load OAuth credentials from IndexedDB + */ + async function loadCredentials() { + try { + const creds = await getFromStore(STORES.AUTH, 'credentials'); + if (creds) { + storedCredentials = creds; + return creds; + } + } catch (e) { + console.error('Error loading credentials:', e); + } + return null; + } + + /** + * Clear OAuth credentials (logout) + */ + async function clearCredentials() { + storedCredentials = null; + try { + await deleteFromStore(STORES.AUTH, 'credentials'); + } catch (e) { + console.error('Error clearing credentials:', e); + } + } + + /** + * Make authenticated API call with stored credentials + * Spreadsheet ID comes from SETTINGS (persistent), credentials from AUTH (ephemeral) + */ + async function authenticatedFetch(url, options = {}) { + if (!storedCredentials) { + throw new Error('Not logged in'); + } + if (!cachedSpreadsheetId) { + throw new Error('No spreadsheet configured'); + } + + // Add credentials to request body for POST/PUT, or as header for GET/DELETE + const method = (options.method || 'GET').toUpperCase(); + + if (method === 'GET' || method === 'DELETE') { + // For GET/DELETE, send credentials as Authorization header (base64 encoded JSON) + options.headers = options.headers || {}; + options.headers['X-Credentials'] = btoa(JSON.stringify({ + token: storedCredentials.token, + refresh_token: storedCredentials.refresh_token, + token_uri: storedCredentials.token_uri, + client_id: storedCredentials.client_id, + client_secret: storedCredentials.client_secret, + scopes: storedCredentials.scopes, + spreadsheet_id: cachedSpreadsheetId + })); + } else { + // For POST/PUT, merge credentials into body + options.headers = options.headers || {}; + options.headers['Content-Type'] = 'application/json'; + const body = options.body ? JSON.parse(options.body) : {}; + body._credentials = { + token: storedCredentials.token, + refresh_token: storedCredentials.refresh_token, + token_uri: storedCredentials.token_uri, + client_id: storedCredentials.client_id, + client_secret: storedCredentials.client_secret, + scopes: storedCredentials.scopes, + spreadsheet_id: cachedSpreadsheetId + }; + options.body = JSON.stringify(body); + } + + return fetch(url, options); + } + + /** + * Add to sync queue (with duplicate prevention) + */ + async function addToSyncQueue(operation, store, recordId, data = null) { + // First, remove any existing queue items for this record to prevent duplicates + const existingQueue = await getAllFromStore(STORES.SYNC_QUEUE); + for (const item of existingQueue) { + if (item.store === store && item.record_id === recordId) { + await deleteFromStore(STORES.SYNC_QUEUE, item.id); + } + } + + const queueItem = { + operation: operation, // 'create', 'update', 'delete' + store: store, + record_id: recordId, + data: data, + created_at: new Date().toISOString(), + retries: 0 + }; + await putInStore(STORES.SYNC_QUEUE, queueItem); + await updatePendingCount(); + } + + /** + * Update pending sync count + */ + async function updatePendingCount() { + const queue = await getAllFromStore(STORES.SYNC_QUEUE); + pendingSyncCount = queue.length; + dispatchSyncStatusEvent(); + } + + /** + * Dispatch sync status event for UI updates + */ + function dispatchSyncStatusEvent() { + window.dispatchEvent(new CustomEvent('acquacotta-sync-status', { + detail: { + syncing: syncInProgress, + pending: pendingSyncCount, + online: isOnline, + error: lastSyncError, + loggedIn: authStatus && authStatus.logged_in + } + })); + } + + /** + * Filter pomodoros by date range + */ + function filterByDateRange(pomodoros, startDate, endDate) { + return pomodoros.filter(p => { + if (startDate && p.start_time < startDate) return false; + if (endDate && p.start_time > endDate) return false; + return true; + }); + } + + /** + * Calculate report statistics + */ + function calculateReportStats(pomodoros, dates) { + const totalMinutes = pomodoros.reduce((sum, p) => sum + p.duration_minutes, 0); + const totalCount = pomodoros.length; + + // Group by type + const byType = {}; + pomodoros.forEach(p => { + byType[p.type] = (byType[p.type] || 0) + p.duration_minutes; + }); + + // Daily totals + const dailyTotals = dates.map(d => { + const dayStr = d.toISOString().split('T')[0]; + const dayPomodoros = pomodoros.filter(p => p.start_time.startsWith(dayStr)); + return { + date: dayStr, + minutes: dayPomodoros.reduce((sum, p) => sum + p.duration_minutes, 0), + count: dayPomodoros.length + }; + }); + + return { + total_minutes: totalMinutes, + total_pomodoros: totalCount, + by_type: byType, + daily_totals: dailyTotals + }; + } + + /** + * Parse ISO date range and build dates list + */ + function parseDateRange(startIso, endIso) { + const startDt = new Date(startIso); + const endDt = new Date(endIso); + + const dates = []; + const d = new Date(startDt); + d.setHours(0, 0, 0, 0); + const endNaive = new Date(endDt); + endNaive.setHours(0, 0, 0, 0); + + while (d < endNaive) { + dates.push(new Date(d)); + d.setDate(d.getDate() + 1); + } + + if (dates.length === 0) { + dates.push(new Date(startDt)); + } + + return dates; + } + + /** + * Calculate period date range + */ + function calculatePeriodDateRange(period, dateStr) { + const refDate = new Date(dateStr + 'T00:00:00'); + let start, end, dates; + + if (period === 'day') { + start = new Date(refDate); + start.setHours(0, 0, 0, 0); + end = new Date(start); + end.setDate(end.getDate() + 1); + dates = [new Date(start)]; + } else if (period === 'week') { + start = new Date(refDate); + start.setDate(refDate.getDate() - refDate.getDay()); + start.setHours(0, 0, 0, 0); + end = new Date(start); + end.setDate(start.getDate() + 7); + dates = []; + for (let i = 0; i < 7; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + dates.push(d); + } + } else if (period === 'month') { + start = new Date(refDate.getFullYear(), refDate.getMonth(), 1); + if (refDate.getMonth() === 11) { + end = new Date(refDate.getFullYear() + 1, 0, 1); + } else { + end = new Date(refDate.getFullYear(), refDate.getMonth() + 1, 1); + } + dates = []; + const d = new Date(start); + while (d < end) { + dates.push(new Date(d)); + d.setDate(d.getDate() + 1); + } + } else { + return null; + } + + return { dates, start, end }; + } + + /** + * Sync a single operation to Google Sheets + */ + async function syncOperationToSheets(queueItem) { + const { operation, store, record_id, data } = queueItem; + + if (store !== 'pomodoros' && store !== 'settings') { + return true; // Unknown store, skip + } + + try { + if (store === 'pomodoros') { + if (operation === 'create') { + const res = await authenticatedFetch('/api/sheets/pomodoros', { + method: 'POST', + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } else if (operation === 'update') { + const res = await authenticatedFetch(`/api/sheets/pomodoros/${record_id}`, { + method: 'PUT', + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } else if (operation === 'delete') { + const res = await authenticatedFetch(`/api/sheets/pomodoros/${record_id}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } + } else if (store === 'settings') { + const res = await authenticatedFetch('/api/sheets/settings', { + method: 'POST', + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } + return true; + } catch (e) { + console.error('Sync operation failed:', e); + throw e; + } + } + + /** + * Process the sync queue - push local changes to Sheets + * Uses promise-based locking to prevent race conditions + */ + async function processSyncQueue() { + if (!authStatus || !authStatus.logged_in || !isOnline) { + return; + } + + // Promise-based lock to prevent concurrent sync operations + if (syncLockPromise) { + // Another sync is in progress, wait for it to complete then return + // (don't start another sync, let the current one handle all items) + await syncLockPromise; + return; + } + + let resolveLock; + syncLockPromise = new Promise(resolve => { resolveLock = resolve; }); + + syncInProgress = true; + lastSyncError = null; + dispatchSyncStatusEvent(); + + try { + const queue = await getAllFromStore(STORES.SYNC_QUEUE); + + // Sort by created_at to process in order + queue.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + + for (const item of queue) { + try { + await syncOperationToSheets(item); + + // Mark the pomodoro as synced + if (item.store === 'pomodoros' && item.operation !== 'delete') { + const pomo = await getFromStore(STORES.POMODOROS, item.record_id); + if (pomo) { + pomo.synced = true; + await putInStore(STORES.POMODOROS, pomo); + } + } + + // Remove from queue + await deleteFromStore(STORES.SYNC_QUEUE, item.id); + } catch (e) { + // Update retry count + item.retries = (item.retries || 0) + 1; + if (item.retries >= MAX_SYNC_RETRIES) { + console.error('Max retries reached for sync item:', item); + lastSyncError = `Failed to sync after ${MAX_SYNC_RETRIES} retries`; + // Remove failed item after max retries + await deleteFromStore(STORES.SYNC_QUEUE, item.id); + } else { + await putInStore(STORES.SYNC_QUEUE, item); + } + } + } + + // Update last sync time + await putInStore(STORES.SYNC_STATUS, { + key: 'last_push_sync', + value: new Date().toISOString() + }); + + } catch (e) { + console.error('Sync queue processing error:', e); + lastSyncError = e.message; + } finally { + syncInProgress = false; + syncLockPromise = null; + resolveLock(); // Release the lock + await updatePendingCount(); + } + } + + /** + * Pull data from Google Sheets to IndexedDB + */ + async function syncFromSheets() { + if (!authStatus || !authStatus.logged_in || !isOnline) { + return { success: false, error: 'Not logged in or offline' }; + } + + syncInProgress = true; + lastSyncError = null; + dispatchSyncStatusEvent(); + + try { + // Fetch pomodoros from Sheets + const pomosRes = await authenticatedFetch('/api/sheets/pomodoros'); + if (!pomosRes.ok) throw new Error(`HTTP ${pomosRes.status}`); + const sheetsPomodoros = await pomosRes.json(); + + // Fetch settings from Sheets + const settingsRes = await authenticatedFetch('/api/sheets/settings'); + if (!settingsRes.ok) throw new Error(`HTTP ${settingsRes.status}`); + const sheetsSettings = await settingsRes.json(); + + // Get local pomodoros to merge + const localPomodoros = await getAllFromStore(STORES.POMODOROS); + const localIds = new Set(localPomodoros.map(p => p.id)); + + // Add/update pomodoros from Sheets + let imported = 0; + for (const pomo of sheetsPomodoros) { + if (!localIds.has(pomo.id)) { + pomo.synced = true; + await putInStore(STORES.POMODOROS, pomo); + imported++; + } + } + + // Update settings from Sheets + for (const [key, value] of Object.entries(sheetsSettings)) { + await putInStore(STORES.SETTINGS, { key, value, synced: true }); + } + + // Preserve spreadsheet_id in SETTINGS (it's not in Sheet, it's the ID of the Sheet itself) + if (cachedSpreadsheetId) { + await putInStore(STORES.SETTINGS, { + key: 'spreadsheet_id', + value: cachedSpreadsheetId, + synced: true + }); + } + + // Update last sync time + await putInStore(STORES.SYNC_STATUS, { + key: 'last_pull_sync', + value: new Date().toISOString() + }); + + return { success: true, imported }; + } catch (e) { + console.error('Sync from Sheets error:', e); + lastSyncError = e.message; + return { success: false, error: e.message }; + } finally { + syncInProgress = false; + dispatchSyncStatusEvent(); + } + } + + /** + * Storage API + */ + const Storage = { + /** + * Initialize storage based on auth status + * @param {object} status - Auth status from /api/auth/status + */ + init: async function(status) { + // Open IndexedDB first + await openDatabase(); + + // Load credentials from AUTH store (ephemeral - OAuth tokens only) + const creds = await loadCredentials(); + + // Load spreadsheet_id from SETTINGS store (persistent) + const spreadsheetIdSetting = await getFromStore(STORES.SETTINGS, 'spreadsheet_id'); + cachedSpreadsheetId = spreadsheetIdSetting ? spreadsheetIdSetting.value : null; + + // Load spreadsheet_existed from SETTINGS store + const spreadsheetExistedSetting = await getFromStore(STORES.SETTINGS, 'spreadsheet_existed'); + const spreadsheetExisted = spreadsheetExistedSetting ? spreadsheetExistedSetting.value : false; + + // Build auth status from credentials (AUTH) + spreadsheet_id (SETTINGS) + if (creds && creds.token && cachedSpreadsheetId) { + authStatus = { + logged_in: true, + email: creds.user_email, + name: creds.user_name, + picture: creds.user_picture, + spreadsheet_id: cachedSpreadsheetId, + needs_initial_sync: !spreadsheetExisted + }; + } else { + authStatus = { + logged_in: false, + google_configured: status ? status.google_configured : true + }; + } + + // Set up online/offline listeners + window.addEventListener('online', () => { + isOnline = true; + dispatchSyncStatusEvent(); + // Trigger sync when coming back online + if (authStatus && authStatus.logged_in) { + this.syncToSheets(); + } + }); + + window.addEventListener('offline', () => { + isOnline = false; + dispatchSyncStatusEvent(); + }); + + // Update pending count + await updatePendingCount(); + + // Handle initial sync based on whether sheet existed + if (authStatus.logged_in && storedCredentials) { + if (spreadsheetExisted) { + // Existing sheet: bidirectional pomodoro sync + pull settings from Sheet + // Only on first load after login (check if we've done this already) + const syncStatus = await getFromStore(STORES.SYNC_STATUS, 'initial_sync_done'); + if (!syncStatus) { + // 0. First, deduplicate any existing duplicates in the Sheet + try { + const dedupeRes = await authenticatedFetch('/api/sheets/deduplicate', { + method: 'POST', + body: JSON.stringify({}) + }); + if (dedupeRes.ok) { + const dedupeResult = await dedupeRes.json(); + if (dedupeResult.removed > 0) { + console.log(`Removed ${dedupeResult.removed} duplicate pomodoros from Sheet`); + } + } + } catch (e) { + console.error('Deduplication during init failed:', e); + } + + // 1. Fetch pomodoros from Sheets + const pomosRes = await authenticatedFetch('/api/sheets/pomodoros'); + if (pomosRes.ok) { + const sheetsPomodoros = await pomosRes.json(); + const sheetsIds = new Set(sheetsPomodoros.map(p => p.id)); + + // 2. Get local pomodoros + const localPomodoros = await getAllFromStore(STORES.POMODOROS); + const localIds = new Set(localPomodoros.map(p => p.id)); + + // 3. Pull pomodoros from Sheets that don't exist locally + for (const pomo of sheetsPomodoros) { + if (!localIds.has(pomo.id)) { + pomo.synced = true; + await putInStore(STORES.POMODOROS, pomo); + } + } + + // 4. Push local pomodoros to Sheets that don't exist there + for (const pomo of localPomodoros) { + if (!sheetsIds.has(pomo.id)) { + await addToSyncQueue('create', 'pomodoros', pomo.id, pomo); + } else { + // Mark as synced since it exists in Sheets + pomo.synced = true; + await putInStore(STORES.POMODOROS, pomo); + } + } + } + + // 5. Pull settings from Sheets (Sheet is authoritative for settings) + const settingsRes = await authenticatedFetch('/api/sheets/settings'); + if (settingsRes.ok) { + const sheetsSettings = await settingsRes.json(); + // Clear local settings and replace with Sheet settings + await clearStore(STORES.SETTINGS); + for (const [key, value] of Object.entries(sheetsSettings)) { + await putInStore(STORES.SETTINGS, { key, value, synced: true }); + } + // Re-save spreadsheet_id after clearing (it's not in Sheet settings) + await putInStore(STORES.SETTINGS, { + key: 'spreadsheet_id', + value: cachedSpreadsheetId, + synced: false // Mark as unsynced so it gets pushed to Sheet + }); + // Queue the spreadsheet_id to be saved to the Sheet + await addToSyncQueue('update', 'settings', 'spreadsheet_id', { + spreadsheet_id: cachedSpreadsheetId + }); + } + + // 6. Process any queued pushes (including spreadsheet_id) + await processSyncQueue(); + + await putInStore(STORES.SYNC_STATUS, { key: 'initial_sync_done', value: true }); + } + } else { + // New sheet: push local data TO Sheets + // Check if we've done this already + const syncStatus = await getFromStore(STORES.SYNC_STATUS, 'initial_push_done'); + if (!syncStatus) { + // Push any existing local pomodoros and settings to the new sheet + const localPomodoros = await getAllFromStore(STORES.POMODOROS); + if (localPomodoros.length > 0) { + // Queue all local pomodoros for sync + for (const pomo of localPomodoros) { + if (!pomo.synced) { + await addToSyncQueue('create', 'pomodoros', pomo.id, pomo); + } + } + } + // Push local settings + const localSettings = await getAllFromStore(STORES.SETTINGS); + if (localSettings.length > 0) { + const settingsObj = {}; + for (const s of localSettings) { + settingsObj[s.key] = s.value; + } + await addToSyncQueue('update', 'settings', 'all', settingsObj); + } + // Process the queue + await processSyncQueue(); + // Mark initial push done and update spreadsheet_existed in SETTINGS + await putInStore(STORES.SYNC_STATUS, { key: 'initial_push_done', value: true }); + await putInStore(STORES.SETTINGS, { key: 'spreadsheet_existed', value: true, synced: true }); + } + } + } + }, + + /** + * Check if using local-only mode (not logged in) + * @returns {boolean} + */ + isLocalMode: function() { + return !authStatus || !authStatus.logged_in; + }, + + /** + * Get auth status + * @returns {object|null} + */ + getAuthStatus: function() { + return authStatus; + }, + + /** + * Check if online + * @returns {boolean} + */ + isOnline: function() { + return isOnline; + }, + + /** + * Get sync status + * @returns {object} + */ + getSyncStatus: function() { + return { + syncing: syncInProgress, + pending: pendingSyncCount, + online: isOnline, + error: lastSyncError, + loggedIn: authStatus && authStatus.logged_in + }; + }, + + /** + * Get stored spreadsheet ID from settings (for auto-fill on login) + * @returns {Promise} + */ + getStoredSpreadsheetId: async function() { + try { + // Ensure database is open + if (!db) { + await openDatabase(); + } + const setting = await getFromStore(STORES.SETTINGS, 'spreadsheet_id'); + return setting ? setting.value : null; + } catch (e) { + console.error('getStoredSpreadsheetId error:', e); + return null; + } + }, + + /** + * Logout - clear credentials and sync status from IndexedDB + * Spreadsheet_id in SETTINGS is preserved for re-login convenience + * @returns {Promise} + */ + logout: async function() { + await clearCredentials(); + cachedSpreadsheetId = null; // Clear cached value (will reload from SETTINGS on next init) + // Clear sync status so next login will re-sync from Sheet + await clearStore(STORES.SYNC_STATUS); + authStatus = { logged_in: false, google_configured: true }; + dispatchSyncStatusEvent(); + }, + + /** + * Update spreadsheet ID in IndexedDB (SETTINGS store only) + * @param {string} newId - New spreadsheet ID + * @returns {Promise} + */ + updateSpreadsheetId: async function(newId) { + // Update SETTINGS store (persistent) + await putInStore(STORES.SETTINGS, { + key: 'spreadsheet_id', + value: newId, + synced: false + }); + + // Update cached value for current session + cachedSpreadsheetId = newId; + + // Queue sync to save spreadsheet_id to Google Sheets Settings + await addToSyncQueue('update', 'settings', 'spreadsheet_id', { + spreadsheet_id: newId + }); + + // Process the queue + await processSyncQueue(); + }, + + /** + * Make authenticated fetch request (includes credentials) + * @param {string} url - URL to fetch + * @param {object} options - Fetch options + * @returns {Promise} + */ + authenticatedFetch: authenticatedFetch, + + /** + * Get count of local pomodoros + * @returns {Promise} + */ + getLocalPomodoroCount: async function() { + const pomodoros = await getAllFromStore(STORES.POMODOROS); + return pomodoros.length; + }, + + /** + * Check if there's local data + * @returns {Promise} + */ + hasLocalData: async function() { + const pomodoros = await getAllFromStore(STORES.POMODOROS); + return pomodoros.length > 0; + }, + + /** + * Fetch pomodoros with optional date filtering + * @param {string} startDate - Optional start date (ISO string) + * @param {string} endDate - Optional end date (ISO string) + * @returns {Promise} + */ + getPomodoros: async function(startDate, endDate) { + let pomodoros = await getAllFromStore(STORES.POMODOROS); + + if (startDate || endDate) { + pomodoros = filterByDateRange(pomodoros, startDate, endDate); + } + + // Sort by start_time descending (most recent first) + pomodoros.sort((a, b) => new Date(b.start_time) - new Date(a.start_time)); + return pomodoros; + }, + + /** + * Create a new pomodoro (timer completion) + * @param {object} data - Pomodoro data (name, type, duration_minutes, notes) + * @returns {Promise} + */ + createPomodoro: async function(data) { + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - (data.duration_minutes || 25) * 60 * 1000); + + const pomodoro = { + id: generateUUID(), + name: data.name || '', + type: data.type, + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + duration_minutes: data.duration_minutes || 25, + notes: data.notes || null, + synced: false + }; + + await putInStore(STORES.POMODOROS, pomodoro); + + // Queue for sync if logged in + if (authStatus && authStatus.logged_in) { + await addToSyncQueue('create', 'pomodoros', pomodoro.id, pomodoro); + this.syncToSheets(); // Trigger immediate sync attempt + } + + return pomodoro; + }, + + /** + * Create a manual pomodoro with custom times + * @param {object} data - Pomodoro data with start_time, end_time, duration_minutes + * @returns {Promise} + */ + createManualPomodoro: async function(data) { + const pomodoro = { + id: data.id || generateUUID(), // Use provided ID or generate new + name: data.name || '', + type: data.type, + start_time: data.start_time, + end_time: data.end_time, + duration_minutes: data.duration_minutes, + notes: data.notes || null, + synced: false + }; + + await putInStore(STORES.POMODOROS, pomodoro); + + // Queue for sync if logged in + if (authStatus && authStatus.logged_in) { + await addToSyncQueue('create', 'pomodoros', pomodoro.id, pomodoro); + this.syncToSheets(); + } + + return pomodoro; + }, + + /** + * Update an existing pomodoro + * @param {string} id - Pomodoro ID + * @param {object} data - Updated pomodoro data + * @returns {Promise} + */ + updatePomodoro: async function(id, data) { + const existing = await getFromStore(STORES.POMODOROS, id); + if (!existing) { + return { status: 'error', message: 'Pomodoro not found' }; + } + + const updated = { + ...existing, + name: data.name, + type: data.type, + start_time: data.start_time, + end_time: data.end_time, + duration_minutes: data.duration_minutes, + notes: data.notes || null, + synced: false + }; + + await putInStore(STORES.POMODOROS, updated); + + // Queue for sync if logged in + if (authStatus && authStatus.logged_in) { + await addToSyncQueue('update', 'pomodoros', id, updated); + this.syncToSheets(); + } + + return { status: 'ok' }; + }, + + /** + * Delete a pomodoro + * @param {string} id - Pomodoro ID + * @returns {Promise} + */ + deletePomodoro: async function(id) { + await deleteFromStore(STORES.POMODOROS, id); + + // Queue for sync if logged in + if (authStatus && authStatus.logged_in) { + await addToSyncQueue('delete', 'pomodoros', id); + this.syncToSheets(); + } + + return { status: 'ok' }; + }, + + /** + * Get settings + * @returns {Promise} + */ + getSettings: async function() { + const settingsRecords = await getAllFromStore(STORES.SETTINGS); + const settings = { ...DEFAULT_SETTINGS }; + + for (const record of settingsRecords) { + settings[record.key] = record.value; + } + + return settings; + }, + + /** + * Save settings + * @param {object} settings - Settings to save + * @returns {Promise} + */ + saveSettings: async function(settings) { + const allSettings = {}; + + for (const [key, value] of Object.entries(settings)) { + await putInStore(STORES.SETTINGS, { key, value, synced: false }); + allSettings[key] = value; + } + + // Queue for sync if logged in + if (authStatus && authStatus.logged_in) { + await addToSyncQueue('update', 'settings', 'all', allSettings); + this.syncToSheets(); + } + + return { status: 'ok' }; + }, + + /** + * Get report for a period + * @param {string} period - 'day', 'week', or 'month' + * @param {string} startDate - Start date (ISO string) + * @param {string} endDate - End date (ISO string) + * @param {string} dateStr - Reference date string (YYYY-MM-DD) + * @returns {Promise} + */ + getReport: async function(period, startDate, endDate, dateStr) { + let dates, startIso, endIso; + + if (startDate && endDate) { + dates = parseDateRange(startDate, endDate); + startIso = startDate; + endIso = endDate; + } else { + const range = calculatePeriodDateRange(period, dateStr || new Date().toISOString().split('T')[0]); + if (!range) { + return { error: 'Invalid period' }; + } + dates = range.dates; + startIso = range.start.toISOString(); + endIso = range.end.toISOString(); + } + + const allPomodoros = await getAllFromStore(STORES.POMODOROS); + const pomodoros = filterByDateRange(allPomodoros, startIso, endIso); + const stats = calculateReportStats(pomodoros, dates); + + return { + period: period, + ...stats + }; + }, + + /** + * Sync local changes to Google Sheets + * @returns {Promise} + */ + syncToSheets: async function() { + return processSyncQueue(); + }, + + /** + * Sync from Google Sheets to local IndexedDB + * @returns {Promise} + */ + syncFromSheets: async function() { + return syncFromSheets(); + }, + + /** + * Full bidirectional sync + * @returns {Promise} + */ + fullSync: async function() { + // First push local changes + await processSyncQueue(); + + // Then pull from Sheets + return syncFromSheets(); + }, + + /** + * Remove duplicate pomodoros from Google Sheets + * @returns {Promise} + */ + deduplicateSheets: async function() { + if (!authStatus || !authStatus.logged_in) { + return { error: 'Not logged in' }; + } + try { + const res = await authenticatedFetch('/api/sheets/deduplicate', { + method: 'POST', + body: JSON.stringify({}) + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (e) { + console.error('Deduplication error:', e); + return { error: e.message }; + } + }, + + /** + * Export data as CSV + * @returns {Promise} - CSV content + */ + exportCSV: async function() { + const pomodoros = await getAllFromStore(STORES.POMODOROS); + // Sort by start_time descending + pomodoros.sort((a, b) => new Date(b.start_time) - new Date(a.start_time)); + + const lines = ['id,name,type,start_time,end_time,duration_minutes,notes']; + pomodoros.forEach(p => { + const name = (p.name || '').replace(/"/g, '""'); + const notes = (p.notes || '').replace(/"/g, '""'); + lines.push( + `"${p.id}","${name}","${p.type}","${p.start_time}","${p.end_time}",${p.duration_minutes},"${notes}"` + ); + }); + return lines.join('\n'); + }, + + /** + * Download data as CSV file + */ + downloadCSV: async function() { + const csv = await this.exportCSV(); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'acquacotta_pomodoros.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + + /** + * Clear all local data + * @returns {Promise} + */ + clearAllData: async function() { + try { + await clearStore(STORES.POMODOROS); + await clearStore(STORES.SETTINGS); + await clearStore(STORES.SYNC_QUEUE); + await clearStore(STORES.SYNC_STATUS); + await updatePendingCount(); + return true; + } catch (e) { + console.error('Error clearing data:', e); + return false; + } + }, + + /** + * Get local pomodoros for migration (backwards compatibility) + * @returns {Promise} + */ + getLocalPomodorosForMigration: async function() { + return getAllFromStore(STORES.POMODOROS); + }, + + // Legacy method aliases for backwards compatibility + exportLocalCSV: function() { + return this.exportCSV(); + }, + + downloadLocalCSV: function() { + return this.downloadCSV(); + }, + + clearLocalData: function() { + return this.clearAllData(); + }, + + /** + * Migrate data from old localStorage format to IndexedDB + * This handles backwards compatibility with pre-IndexedDB versions + * @returns {Promise} + */ + migrateFromLocalStorage: async function() { + try { + // Check for old localStorage data + const oldPomodoros = localStorage.getItem('acquacotta_pomodoros'); + const oldSettings = localStorage.getItem('acquacotta_settings'); + + if (!oldPomodoros && !oldSettings) { + return { migrated: 0 }; + } + + let migrated = 0; + + // Migrate pomodoros + if (oldPomodoros) { + const pomodoros = JSON.parse(oldPomodoros); + for (const pomo of pomodoros) { + // Check if already exists in IndexedDB + const existing = await getFromStore(STORES.POMODOROS, pomo.id); + if (!existing) { + pomo.synced = false; + await putInStore(STORES.POMODOROS, pomo); + migrated++; + } + } + // Clear old localStorage after successful migration + localStorage.removeItem('acquacotta_pomodoros'); + } + + // Migrate settings + if (oldSettings) { + const settings = JSON.parse(oldSettings); + for (const [key, value] of Object.entries(settings)) { + await putInStore(STORES.SETTINGS, { key, value, synced: false }); + } + localStorage.removeItem('acquacotta_settings'); + } + + return { migrated }; + } catch (e) { + console.error('Error migrating from localStorage:', e); + return { migrated: 0, error: e.message }; + } + }, + + /** + * Migrate local IndexedDB data to Google Sheets (on login) + * Uses batch upload for efficiency + * @returns {Promise} + */ + migrateLocalToBackend: async function() { + if (!authStatus || !authStatus.logged_in) { + return { migrated: 0, skipped: 0, error: 'Not logged in' }; + } + + const pomodoros = await getAllFromStore(STORES.POMODOROS); + if (pomodoros.length === 0) { + return { migrated: 0, skipped: 0 }; + } + + try { + // Use batch endpoint - server handles duplicate checking + const res = await authenticatedFetch('/api/sheets/pomodoros/batch', { + method: 'POST', + body: JSON.stringify({ pomodoros: pomodoros }) + }); + + if (res.ok) { + const result = await res.json(); + // Mark all as synced + for (const pomo of pomodoros) { + pomo.synced = true; + await putInStore(STORES.POMODOROS, pomo); + } + return { migrated: result.count || 0, skipped: pomodoros.length - (result.count || 0) }; + } else { + return { migrated: 0, skipped: 0, error: `HTTP ${res.status}` }; + } + } catch (e) { + console.error('Error migrating pomodoros:', e); + return { migrated: 0, skipped: 0, error: e.message }; + } + }, + + /** + * Migrate local settings to backend + * @param {boolean} replaceAll - If true, replace all settings in Sheet (used by Overwrite Google) + * @returns {Promise} + */ + migrateLocalSettingsToBackend: async function(replaceAll = false) { + if (!authStatus || !authStatus.logged_in) { + return { migrated: false, error: 'Not logged in' }; + } + + const settingsRecords = await getAllFromStore(STORES.SETTINGS); + if (settingsRecords.length === 0) { + return { migrated: false }; + } + + const settings = {}; + for (const record of settingsRecords) { + settings[record.key] = record.value; + } + + // Add replace_all flag if requested + if (replaceAll) { + settings._replace_all = true; + } + + try { + const res = await authenticatedFetch('/api/sheets/settings', { + method: 'POST', + body: JSON.stringify(settings) + }); + + if (res.ok) { + // Mark all settings as synced + for (const record of settingsRecords) { + record.synced = true; + await putInStore(STORES.SETTINGS, record); + } + return { migrated: true }; + } else { + return { migrated: false, error: `HTTP ${res.status}` }; + } + } catch (e) { + console.error('Error migrating settings:', e); + return { migrated: false, error: e.message }; + } + } + }; + + // Export + if (typeof window !== 'undefined') { + window.Storage = Storage; + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = Storage; + } + +})(this); diff --git a/templates/index.html b/templates/index.html index b8a53af..6d8bc3e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -456,6 +456,7 @@ } +
@@ -467,9 +468,15 @@
Pomodoro Tracker
-
-
-
+
+ +
+
+
+