diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json index 008fd60..4b608c6 100644 --- a/.eslintrc-auto-import.json +++ b/.eslintrc-auto-import.json @@ -10,6 +10,7 @@ "Chat": true, "Dashboard": true, "MainLayout": true, + "Pomodoro": true, "Settings": true, "createRef": true, "forwardRef": true, diff --git a/.project.md b/.project.md index 2b932ec..345325c 100644 --- a/.project.md +++ b/.project.md @@ -29,15 +29,39 @@ const activities = await getActivities({ limit: 50 }) Always use auto-generated functions from `@/lib/client/apiClient` for type safety and automatic camelCase↔snake_case conversion. -### 2. Always Define Structured Request/Response Models +### 2. Always Use Relative Import for `api_handler` + +**CRITICAL: Handler modules MUST use relative import to avoid circular import issues.** + +```python +# ✅ CORRECT: Use relative import +from . import api_handler + +# ❌ WRONG: Causes circular import +from handlers import api_handler +from backend.handlers import api_handler +``` + +**Why:** The `handlers/__init__.py` imports all handler modules at the bottom. Using absolute import causes Python to reload the package while it's still initializing, resulting in `api_handler` being undefined or import failures. + +### 3. Always Define Structured Request/Response Models **CRITICAL RULE:** All handlers must use Pydantic models inheriting from `backend.models.base.BaseModel`. **NEVER use `Dict[str, Any]` as return type** - this prevents TypeScript type generation for the frontend. ```python -from backend.handlers import api_handler -from backend.models.base import BaseModel, TimedOperationResponse +from datetime import datetime +from typing import Optional + +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# ✅ CORRECT: Use relative import +from . import api_handler + +logger = get_logger(__name__) # ❌ WRONG - Dict prevents TypeScript type generation @api_handler() @@ -154,6 +178,15 @@ When frontend needs data: **Data Flow:** RawRecords (60s memory) → Events (LLM) → Activities (10min aggregation) → Tasks (AI-generated) +**Pomodoro Mode:** + +- **Core Principle**: Pomodoro mode ONLY controls whether perception layer (keyboard/mouse/screenshots) is running +- **Idle Mode (Default)**: Perception layer is stopped, no data captured +- **Active Mode (Pomodoro)**: Perception layer is running, captures user activity +- **No Configuration**: Pomodoro has NO system configuration parameters (e.g., default duration, behavior settings) +- **User-Controlled**: Duration is specified per session when starting, not a global config +- **Behavior Unchanged**: Capture behavior (smart capture, deduplication, etc.) follows normal settings and is NOT modified by Pomodoro mode + ## Development Commands ```bash @@ -182,23 +215,48 @@ pnpm sign-macos # Code signing (after bundle) ### Adding API Handler +**CRITICAL: Always use relative import `from . import api_handler` in handler modules to avoid circular import issues.** + ```python # 1. backend/handlers/my_feature.py -from backend.handlers import api_handler -from backend.models.base import BaseModel +from datetime import datetime + +from core.coordinator import get_coordinator +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# ✅ CORRECT: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + class MyRequest(BaseModel): user_input: str + @api_handler(body=MyRequest, method="POST", path="/endpoint", tags=["module"]) -async def my_handler(body: MyRequest) -> dict: - return {"data": body.user_input} +async def my_handler(body: MyRequest) -> TimedOperationResponse: + return TimedOperationResponse( + success=True, + message="Operation completed", + timestamp=datetime.now().isoformat() + ) # 2. Import in backend/handlers/__init__.py -# 3. Run: pnpm setup-backend +# 3. Run: pnpm tauri:dev:gen-ts (to regenerate TypeScript bindings) # 4. Use: import { myHandler } from '@/lib/client/apiClient' ``` +**Why relative import is required:** + +- `from . import api_handler` ✅ Correct - imports from current package (`handlers`) +- `from handlers import api_handler` ❌ Wrong - causes circular import because `handlers/__init__.py` is importing your module +- `from backend.handlers import api_handler` ❌ Wrong - same circular import issue + +The `handlers/__init__.py` file imports all handler modules at the bottom (line 207-218). If your handler uses absolute import, Python will try to reload the `handlers` package while it's still being initialized, causing import failures. + ### Adding i18n ```typescript diff --git a/backend/agents/action_agent.py b/backend/agents/action_agent.py index 21f7cc5..329e1aa 100644 --- a/backend/agents/action_agent.py +++ b/backend/agents/action_agent.py @@ -92,6 +92,36 @@ async def extract_and_save_actions( try: logger.debug(f"ActionAgent: Processing {len(records)} records") + # Pre-persist all screenshots to prevent cache eviction during LLM processing + screenshot_records = [ + r for r in records if r.type == RecordType.SCREENSHOT_RECORD + ] + screenshot_hashes = [ + r.data.get("hash") + for r in screenshot_records + if r.data and r.data.get("hash") + ] + + if screenshot_hashes: + logger.debug( + f"ActionAgent: Pre-persisting {len(screenshot_hashes)} screenshots " + f"before LLM call to prevent cache eviction" + ) + persist_results = self.image_manager.persist_images_batch(screenshot_hashes) + + # Log pre-persistence results + success_count = sum(1 for success in persist_results.values() if success) + if success_count < len(screenshot_hashes): + logger.warning( + f"ActionAgent: Pre-persistence incomplete: " + f"{success_count}/{len(screenshot_hashes)} images persisted. " + f"Some images may already be evicted from cache." + ) + else: + logger.debug( + f"ActionAgent: Successfully pre-persisted all {len(screenshot_hashes)} screenshots" + ) + # Step 1: Extract actions using LLM actions = await self._extract_actions( records, input_usage_hint, keyboard_records, mouse_records, enable_supervisor @@ -102,10 +132,7 @@ async def extract_and_save_actions( return 0 # Step 2: Validate and resolve screenshot hashes - screenshot_records = [ - r for r in records if r.type == RecordType.SCREENSHOT_RECORD - ] - + # (screenshot_records already created above for pre-persistence) resolved_actions: List[Dict[str, Any]] = [] for action_data in actions: action_hashes = self._resolve_action_screenshot_hashes( @@ -455,12 +482,24 @@ def _persist_action_screenshots(self, screenshot_hashes: list[str]) -> None: results = self.image_manager.persist_images_batch(screenshot_hashes) - # Log warnings for failed persists + # Enhanced logging for failed persists failed = [h for h, success in results.items() if not success] if failed: - logger.warning( - f"Failed to persist {len(failed)} screenshots (likely evicted from memory): " - f"{[h[:8] for h in failed]}" + logger.error( + f"ActionAgent: Image persistence FAILURE: {len(failed)}/{len(screenshot_hashes)} images lost. " + f"Action will be saved with broken image references. " + f"\nFailed hashes: {[h[:8] + '...' for h in failed[:5]]}" + f"{' (and ' + str(len(failed) - 5) + ' more)' if len(failed) > 5 else ''}" + f"\nRoot cause: Images evicted from memory cache before persistence." + f"\nRecommendations:" + f"\n 1. Increase memory_ttl in config.toml (current: {self.image_manager.memory_ttl}s, recommended: ≥180s)" + f"\n 2. Run GET /image/persistence-health to check system health" + f"\n 3. Run POST /image/cleanup-broken-actions to fix existing issues" + f"\n 4. Consider increasing memory_cache_size (current: {self.image_manager.memory_cache_size}, recommended: ≥1000)" + ) + else: + logger.debug( + f"ActionAgent: Successfully persisted all {len(screenshot_hashes)} screenshots" ) except Exception as e: diff --git a/backend/config/config.toml b/backend/config/config.toml index 2f33b91..94e6cb7 100644 --- a/backend/config/config.toml +++ b/backend/config/config.toml @@ -28,9 +28,9 @@ enable_phash = true # Memory-first storage configuration enable_memory_first = true # Master switch -memory_ttl_multiplier = 2.5 # TTL = processing_interval * multiplier +memory_ttl_multiplier = 5.0 # TTL = processing_interval * multiplier (increased for better persistence) memory_ttl_min = 60 # Minimum TTL (seconds) -memory_ttl_max = 120 # Maximum TTL (seconds) +memory_ttl_max = 300 # Maximum TTL (seconds) (increased from 120 to prevent eviction during LLM processing) # Screenshot configuration [screenshot] @@ -141,7 +141,8 @@ crop_threshold = 30 # Memory cache size (images) # Description: Cache recent image base64 data in memory # Recommendation: 200-500 (memory usage ~100-250MB) -memory_cache_size = 500 +# Increased to 1000 for better persistence reliability (memory usage ~500-1000MB) +memory_cache_size = 1000 # ========== Optimization Effect Estimation ========== # Based on default configuration (aggressive + hybrid), with 20 original screenshots as example: diff --git a/backend/core/coordinator.py b/backend/core/coordinator.py index cc7a8e0..bfa8a0d 100644 --- a/backend/core/coordinator.py +++ b/backend/core/coordinator.py @@ -43,6 +43,11 @@ def __init__(self, config: Dict[str, Any]): self.knowledge_agent = None self.diary_agent = None self.cleanup_agent = None + self.pomodoro_manager = None + + # Pomodoro mode state + self.pomodoro_mode = False + self.current_pomodoro_session_id: Optional[str] = None # Running state self.is_running = False @@ -315,6 +320,11 @@ def _init_managers(self): ), ) + if self.pomodoro_manager is None: + from core.pomodoro_manager import PomodoroManager + + self.pomodoro_manager = PomodoroManager(self) + # Link agents if self.processing_pipeline: # Link action_agent to pipeline for action extraction @@ -401,13 +411,15 @@ async def start(self) -> None: raise Exception("Cleanup agent initialization failed") # Start all components in parallel (they are independent) + # NOTE: Perception manager is NOT started by default - it will be started + # when a Pomodoro session begins (Active mode strategy) logger.debug( - "Starting perception manager, processing pipeline, agents in parallel..." + "Starting processing pipeline and agents (perception will start with Pomodoro)..." ) start_time = datetime.now() await asyncio.gather( - self.perception_manager.start(), + # self.perception_manager.start(), # Disabled: starts with Pomodoro self.processing_pipeline.start(), self.event_agent.start(), self.session_agent.start(), @@ -420,6 +432,12 @@ async def start(self) -> None: f"All components started (took {elapsed:.2f}s)" ) + # Check for orphaned Pomodoro sessions from previous run + if self.pomodoro_manager: + orphaned_count = await self.pomodoro_manager.check_orphaned_sessions() + if orphaned_count > 0: + logger.info(f"✓ Recovered {orphaned_count} orphaned Pomodoro session(s)") + # Start scheduled processing loop self.is_running = True self._set_state(mode="running", error=None) @@ -663,6 +681,106 @@ def get_stats(self) -> Dict[str, Any]: return {"error": str(e)} + async def enter_pomodoro_mode(self, session_id: str) -> None: + """ + Enter Pomodoro mode - start perception and disable continuous processing + + Changes: + 1. Start perception manager (if not already running) + 2. Stop processing_loop (cancel task) + 3. Set pomodoro_mode = True + 4. Set current_pomodoro_session_id + 5. Perception captures and tags records + 6. Records are saved to DB instead of processed + + Args: + session_id: Pomodoro session identifier + """ + logger.info(f"→ Entering Pomodoro mode: {session_id}") + + self.pomodoro_mode = True + self.current_pomodoro_session_id = session_id + + # Start perception manager if not already running + if self.perception_manager and not self.perception_manager.is_running: + try: + logger.info("Starting perception manager for Pomodoro mode...") + await self.perception_manager.start() + logger.info("✓ Perception manager started") + except Exception as e: + logger.error(f"Failed to start perception manager: {e}", exc_info=True) + raise + elif not self.perception_manager: + logger.error("Perception manager is None, cannot start") + else: + logger.debug("Perception manager already running") + + # Keep processing loop running - do NOT cancel it + # This allows Actions (30s) and Events (10min) to continue normally + + # Pause only SessionAgent (activity generation deferred) + try: + if self.session_agent: + self.session_agent.pause() + logger.debug("✓ SessionAgent paused (activity generation deferred)") + except Exception as e: + logger.error(f"Failed to pause SessionAgent: {e}") + + # Notify perception manager of Pomodoro mode (for tagging records) + if self.perception_manager: + self.perception_manager.set_pomodoro_session(session_id) + + logger.info( + "✓ Pomodoro mode active - normal processing continues, " + "activity generation paused until session ends" + ) + + async def exit_pomodoro_mode(self) -> None: + """ + Exit Pomodoro mode - stop perception and trigger activity generation + + When Pomodoro ends: + - Stop perception manager + - Resume SessionAgent + - Trigger immediate activity aggregation for accumulated Events + """ + logger.info("→ Exiting Pomodoro mode") + + self.pomodoro_mode = False + session_id = self.current_pomodoro_session_id + self.current_pomodoro_session_id = None + + # Stop perception manager + if self.perception_manager and self.perception_manager.is_running: + try: + logger.debug("Stopping perception manager...") + await self.perception_manager.stop() + logger.debug("✓ Perception manager stopped") + except Exception as e: + logger.error(f"Failed to stop perception manager: {e}") + + # Processing loop is still running - no need to resume + + # Resume SessionAgent and trigger immediate activity aggregation + try: + if self.session_agent: + self.session_agent.resume() + logger.debug("✓ SessionAgent resumed") + + # Trigger immediate activity aggregation for Pomodoro session + logger.info("→ Triggering activity aggregation for Pomodoro session...") + await self.session_agent._aggregate_sessions() + logger.info("✓ Activity aggregation complete for Pomodoro session") + except Exception as e: + logger.error(f"Failed to resume SessionAgent or aggregate activities: {e}") + + # Notify perception manager to exit Pomodoro mode + if self.perception_manager: + self.perception_manager.clear_pomodoro_session() + + logger.info(f"✓ Idle mode resumed - perception stopped (exited session: {session_id})") + + def get_coordinator() -> PipelineCoordinator: """Get global coordinator singleton""" global _coordinator diff --git a/backend/core/db/__init__.py b/backend/core/db/__init__.py index 9f3c36f..a6352cd 100644 --- a/backend/core/db/__init__.py +++ b/backend/core/db/__init__.py @@ -16,12 +16,15 @@ # Three-layer architecture repositories from .actions import ActionsRepository from .activities import ActivitiesRepository +from .activity_ratings import ActivityRatingsRepository from .base import BaseRepository from .conversations import ConversationsRepository, MessagesRepository from .diaries import DiariesRepository from .events import EventsRepository from .knowledge import KnowledgeRepository from .models import LLMModelsRepository +from .pomodoro_sessions import PomodoroSessionsRepository +from .raw_records import RawRecordsRepository from .session_preferences import SessionPreferencesRepository from .settings import SettingsRepository from .todos import TodosRepository @@ -69,76 +72,40 @@ def __init__(self, db_path: Path): self.actions = ActionsRepository(db_path) self.session_preferences = SessionPreferencesRepository(db_path) + # Pomodoro feature repositories + self.pomodoro_sessions = PomodoroSessionsRepository(db_path) + self.raw_records = RawRecordsRepository(db_path) + + # Activity ratings repository + self.activity_ratings = ActivityRatingsRepository(db_path) + logger.debug(f"✓ DatabaseManager initialized with path: {db_path}") def _initialize_database(self): """ - Initialize database schema - create all tables and indexes + Initialize database schema using version-based migrations This is called automatically when DatabaseManager is instantiated. - It ensures all required tables and indexes exist. + It runs all pending migrations to ensure database is up to date. """ - import sqlite3 - - from core.sqls import migrations, schema + from migrations import MigrationRunner try: - conn = sqlite3.connect(str(self.db_path)) - cursor = conn.cursor() + # Create migration runner + runner = MigrationRunner(self.db_path) - # Create all tables - for table_sql in schema.ALL_TABLES: - cursor.execute(table_sql) - - # Create all indexes - for index_sql in schema.ALL_INDEXES: - cursor.execute(index_sql) - - # Run migrations for new columns - self._run_migrations(cursor) - - conn.commit() - conn.close() + # Run all pending migrations + executed_count = runner.run_migrations() - logger.debug(f"✓ Database schema initialized: {len(schema.ALL_TABLES)} tables, {len(schema.ALL_INDEXES)} indexes") + if executed_count > 0: + logger.info(f"✓ Database schema initialized: {executed_count} migration(s) executed") + else: + logger.debug("✓ Database schema up to date") except Exception as e: logger.error(f"Failed to initialize database schema: {e}", exc_info=True) raise - def _run_migrations(self, cursor): - """ - Run database migrations to add new columns to existing tables - - Args: - cursor: Database cursor - """ - import sqlite3 - - from core.sqls import migrations - - # List of migrations to run (column name, migration SQL) - migration_list = [ - ("actions.extract_knowledge", migrations.ADD_ACTIONS_EXTRACT_KNOWLEDGE_COLUMN), - ("actions.knowledge_extracted", migrations.ADD_ACTIONS_KNOWLEDGE_EXTRACTED_COLUMN), - ("knowledge.source_action_id", migrations.ADD_KNOWLEDGE_SOURCE_ACTION_ID_COLUMN), - ] - - for column_desc, migration_sql in migration_list: - try: - cursor.execute(migration_sql) - logger.info(f"✓ Migration applied: {column_desc}") - except sqlite3.OperationalError as e: - error_msg = str(e).lower() - # Column might already exist, which is fine - if "duplicate column" in error_msg or "already exists" in error_msg: - logger.debug(f"Column {column_desc} already exists, skipping") - else: - # Real error, log as warning but continue - logger.warning(f"Migration failed for {column_desc}: {e}") - except Exception as e: - # Unexpected error - logger.error(f"Unexpected error in migration for {column_desc}: {e}", exc_info=True) def get_connection(self): """ @@ -380,6 +347,9 @@ def switch_database(new_db_path: str) -> bool: "LLMModelsRepository", "ActionsRepository", "SessionPreferencesRepository", + "PomodoroSessionsRepository", + "RawRecordsRepository", + "ActivityRatingsRepository", # Unified manager "DatabaseManager", # Global access functions diff --git a/backend/core/db/actions.py b/backend/core/db/actions.py index ae0ea98..fbe19b5 100644 --- a/backend/core/db/actions.py +++ b/backend/core/db/actions.py @@ -508,3 +508,90 @@ def get_all_referenced_image_hashes(self) -> set: except Exception as e: logger.error(f"Failed to get referenced image hashes: {e}", exc_info=True) return set() + + async def get_all_actions_with_screenshots( + self, limit: Optional[int] = None + ) -> List[Dict[str, Any]]: + """Get all actions that have screenshot references + + Used for image persistence health checks to validate that referenced + images actually exist on disk. + + Args: + limit: Maximum number of actions to return (None = unlimited) + + Returns: + List of {id, created_at, screenshots: [...hashes]} + """ + try: + query = """ + SELECT DISTINCT a.id, a.created_at + FROM actions a + INNER JOIN action_images ai ON a.id = ai.action_id + WHERE a.deleted = 0 + ORDER BY a.created_at DESC + """ + if limit: + query += f" LIMIT {limit}" + + with self._get_conn() as conn: + cursor = conn.execute(query) + rows = cursor.fetchall() + + actions = [] + for row in rows: + screenshots = await self._load_screenshots(row["id"]) + if screenshots: # Only include if has screenshots + actions.append({ + "id": row["id"], + "created_at": row["created_at"], + "screenshots": screenshots, + }) + + logger.debug( + f"Found {len(actions)} actions with screenshots" + + (f" (limit: {limit})" if limit else "") + ) + return actions + + except Exception as e: + logger.error(f"Failed to get actions with screenshots: {e}", exc_info=True) + return [] + + async def remove_screenshots(self, action_id: str) -> int: + """Remove all screenshot references from an action + + Deletes all entries in action_images table for the given action, + effectively clearing the image references while keeping the action itself. + + Args: + action_id: Action ID + + Returns: + Number of references removed + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + "SELECT COUNT(*) as count FROM action_images WHERE action_id = ?", + (action_id,), + ) + count = cursor.fetchone()["count"] + + conn.execute( + "DELETE FROM action_images WHERE action_id = ?", + (action_id,), + ) + conn.commit() + + logger.debug( + f"Removed {count} screenshot references from action {action_id}" + ) + return count + + except Exception as e: + logger.error( + f"Failed to remove screenshots from action {action_id}: {e}", + exc_info=True, + ) + raise diff --git a/backend/core/db/activity_ratings.py b/backend/core/db/activity_ratings.py new file mode 100644 index 0000000..6665271 --- /dev/null +++ b/backend/core/db/activity_ratings.py @@ -0,0 +1,199 @@ +""" +ActivityRatings Repository - Handles multi-dimensional activity ratings +Manages user ratings for activities across different dimensions (focus, productivity, etc.) +""" + +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional + +from core.logger import get_logger + +from .base import BaseRepository + +logger = get_logger(__name__) + + +class ActivityRatingsRepository(BaseRepository): + """Repository for managing activity ratings in the database""" + + def __init__(self, db_path: Path): + super().__init__(db_path) + + async def save_rating( + self, + activity_id: str, + dimension: str, + rating: int, + note: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Save or update a rating for an activity dimension + + Args: + activity_id: Activity ID + dimension: Rating dimension (e.g., 'focus_level', 'productivity') + rating: Rating value (1-5) + note: Optional note/comment + + Returns: + The saved rating record + + Raises: + ValueError: If rating is out of range (1-5) + """ + if not 1 <= rating <= 5: + raise ValueError(f"Rating must be between 1 and 5, got {rating}") + + try: + rating_id = str(uuid.uuid4()) + + with self._get_conn() as conn: + # Use INSERT OR REPLACE to handle updates + # SQLite will replace if (activity_id, dimension) already exists + conn.execute( + """ + INSERT INTO activity_ratings ( + id, activity_id, dimension, rating, note, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(activity_id, dimension) + DO UPDATE SET + rating = excluded.rating, + note = excluded.note, + updated_at = CURRENT_TIMESTAMP + """, + (rating_id, activity_id, dimension, rating, note), + ) + conn.commit() + + # Fetch the saved rating + cursor = conn.execute( + """ + SELECT id, activity_id, dimension, rating, note, + created_at, updated_at + FROM activity_ratings + WHERE activity_id = ? AND dimension = ? + """, + (activity_id, dimension), + ) + row = cursor.fetchone() + + logger.debug( + f"Saved rating for activity {activity_id}, " + f"dimension {dimension}: {rating}" + ) + + if not row: + raise ValueError(f"Failed to retrieve saved rating") + + result = self._row_to_dict(row) + if not result: + raise ValueError(f"Failed to convert rating to dict") + + return result + + except Exception as e: + logger.error( + f"Failed to save rating for activity {activity_id}: {e}", + exc_info=True, + ) + raise + + async def get_ratings_by_activity( + self, activity_id: str + ) -> List[Dict[str, Any]]: + """ + Get all ratings for an activity + + Args: + activity_id: Activity ID + + Returns: + List of rating records + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT id, activity_id, dimension, rating, note, + created_at, updated_at + FROM activity_ratings + WHERE activity_id = ? + ORDER BY dimension + """, + (activity_id,), + ) + rows = cursor.fetchall() + return [self._row_to_dict(row) for row in rows] + + except Exception as e: + logger.error( + f"Failed to get ratings for activity {activity_id}: {e}", + exc_info=True, + ) + raise + + async def delete_rating(self, activity_id: str, dimension: str) -> None: + """ + Delete a specific rating + + Args: + activity_id: Activity ID + dimension: Rating dimension + """ + try: + with self._get_conn() as conn: + conn.execute( + """ + DELETE FROM activity_ratings + WHERE activity_id = ? AND dimension = ? + """, + (activity_id, dimension), + ) + conn.commit() + logger.debug( + f"Deleted rating for activity {activity_id}, dimension {dimension}" + ) + + except Exception as e: + logger.error( + f"Failed to delete rating for activity {activity_id}: {e}", + exc_info=True, + ) + raise + + async def get_average_ratings_by_dimension( + self, start_date: str, end_date: str + ) -> Dict[str, float]: + """ + Get average ratings by dimension for a date range + + Args: + start_date: Start date (YYYY-MM-DD) + end_date: End date (YYYY-MM-DD) + + Returns: + Dict mapping dimension to average rating + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT ar.dimension, AVG(ar.rating) as avg_rating + FROM activity_ratings ar + JOIN activities a ON ar.activity_id = a.id + WHERE DATE(a.start_time) >= ? AND DATE(a.start_time) <= ? + GROUP BY ar.dimension + """, + (start_date, end_date), + ) + rows = cursor.fetchall() + return {row[0]: row[1] for row in rows} + + except Exception as e: + logger.error( + f"Failed to get average ratings for date range {start_date} to {end_date}: {e}", + exc_info=True, + ) + raise diff --git a/backend/core/db/pomodoro_sessions.py b/backend/core/db/pomodoro_sessions.py new file mode 100644 index 0000000..422908f --- /dev/null +++ b/backend/core/db/pomodoro_sessions.py @@ -0,0 +1,546 @@ +""" +PomodoroSessions Repository - Handles Pomodoro session lifecycle +Manages session metadata, status tracking, and processing state +""" + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + +from core.logger import get_logger + +from .base import BaseRepository + +logger = get_logger(__name__) + + +class PomodoroSessionsRepository(BaseRepository): + """Repository for managing Pomodoro sessions in the database""" + + def __init__(self, db_path: Path): + super().__init__(db_path) + + async def create( + self, + session_id: str, + user_intent: str, + planned_duration_minutes: int, + start_time: str, + status: str = "active", + associated_todo_id: Optional[str] = None, + work_duration_minutes: int = 25, + break_duration_minutes: int = 5, + total_rounds: int = 4, + ) -> None: + """ + Create a new Pomodoro session + + Args: + session_id: Unique session identifier + user_intent: User's description of what they plan to work on + planned_duration_minutes: Planned session duration (total for all rounds) + start_time: ISO format start timestamp + status: Session status (default: 'active') + associated_todo_id: Optional TODO ID to associate with this session + work_duration_minutes: Duration of each work phase (default: 25) + break_duration_minutes: Duration of each break phase (default: 5) + total_rounds: Total number of work rounds (default: 4) + """ + try: + with self._get_conn() as conn: + conn.execute( + """ + INSERT INTO pomodoro_sessions ( + id, user_intent, planned_duration_minutes, + start_time, status, associated_todo_id, + work_duration_minutes, break_duration_minutes, total_rounds, + current_round, current_phase, phase_start_time, completed_rounds, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'work', ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + ( + session_id, + user_intent, + planned_duration_minutes, + start_time, + status, + associated_todo_id, + work_duration_minutes, + break_duration_minutes, + total_rounds, + start_time, # phase_start_time = start_time initially + ), + ) + conn.commit() + logger.debug(f"Created Pomodoro session: {session_id}") + except Exception as e: + logger.error(f"Failed to create Pomodoro session {session_id}: {e}", exc_info=True) + raise + + async def update(self, session_id: str, **kwargs) -> None: + """ + Update Pomodoro session fields + + Args: + session_id: Session ID to update + **kwargs: Fields to update (e.g., end_time, status, processing_status) + """ + try: + if not kwargs: + return + + set_clauses = [] + params = [] + + for key, value in kwargs.items(): + set_clauses.append(f"{key} = ?") + params.append(value) + + set_clauses.append("updated_at = CURRENT_TIMESTAMP") + params.append(session_id) + + query = f""" + UPDATE pomodoro_sessions + SET {', '.join(set_clauses)} + WHERE id = ? + """ + + with self._get_conn() as conn: + conn.execute(query, params) + conn.commit() + logger.debug(f"Updated Pomodoro session {session_id}: {list(kwargs.keys())}") + except Exception as e: + logger.error(f"Failed to update Pomodoro session {session_id}: {e}", exc_info=True) + raise + + async def get_by_id(self, session_id: str) -> Optional[Dict[str, Any]]: + """ + Get session by ID + + Args: + session_id: Session ID + + Returns: + Session dictionary or None if not found + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE id = ? AND deleted = 0 + """, + (session_id,), + ) + row = cursor.fetchone() + return self._row_to_dict(row) + except Exception as e: + logger.error(f"Failed to get Pomodoro session {session_id}: {e}", exc_info=True) + raise + + async def get_by_status( + self, + status: str, + limit: int = 100, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Get sessions by status + + Args: + status: Session status ('active', 'completed', 'abandoned', etc.) + limit: Maximum number of results + offset: Number of results to skip + + Returns: + List of session dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE status = ? AND deleted = 0 + ORDER BY start_time DESC + LIMIT ? OFFSET ? + """, + (status, limit, offset), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error(f"Failed to get sessions by status {status}: {e}", exc_info=True) + raise + + async def get_by_processing_status( + self, + processing_status: str, + limit: int = 100, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Get sessions by processing status + + Args: + processing_status: Processing status ('pending', 'processing', 'completed', 'failed') + limit: Maximum number of results + offset: Number of results to skip + + Returns: + List of session dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE processing_status = ? AND deleted = 0 + ORDER BY start_time DESC + LIMIT ? OFFSET ? + """, + (processing_status, limit, offset), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error( + f"Failed to get sessions by processing status {processing_status}: {e}", + exc_info=True, + ) + raise + + async def get_recent( + self, + limit: int = 10, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Get recent Pomodoro sessions + + Args: + limit: Maximum number of results + offset: Number of results to skip + + Returns: + List of session dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE deleted = 0 + ORDER BY start_time DESC + LIMIT ? OFFSET ? + """, + (limit, offset), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error(f"Failed to get recent Pomodoro sessions: {e}", exc_info=True) + raise + + async def get_stats( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + """ + Get Pomodoro session statistics + + Args: + start_date: Optional start date (ISO format) + end_date: Optional end date (ISO format) + + Returns: + Dictionary with statistics (total, completed, abandoned, avg_duration, etc.) + """ + try: + with self._get_conn() as conn: + where_clauses = ["deleted = 0"] + params = [] + + if start_date: + where_clauses.append("start_time >= ?") + params.append(start_date) + if end_date: + where_clauses.append("start_time <= ?") + params.append(end_date) + + where_sql = " AND ".join(where_clauses) + + cursor = conn.execute( + f""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END) as abandoned, + SUM(CASE WHEN status = 'interrupted' THEN 1 ELSE 0 END) as interrupted, + AVG(actual_duration_minutes) as avg_duration, + SUM(actual_duration_minutes) as total_duration + FROM pomodoro_sessions + WHERE {where_sql} + """, + params, + ) + row = cursor.fetchone() + return self._row_to_dict(row) if row else None + except Exception as e: + logger.error(f"Failed to get Pomodoro session stats: {e}", exc_info=True) + raise + + async def soft_delete(self, session_id: str) -> None: + """ + Soft delete a session + + Args: + session_id: Session ID to delete + """ + try: + with self._get_conn() as conn: + conn.execute( + """ + UPDATE pomodoro_sessions + SET deleted = 1, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (session_id,), + ) + conn.commit() + logger.debug(f"Soft deleted Pomodoro session: {session_id}") + except Exception as e: + logger.error(f"Failed to soft delete Pomodoro session {session_id}: {e}", exc_info=True) + raise + + async def hard_delete_old(self, days: int = 90) -> int: + """ + Hard delete old completed sessions + + Args: + days: Delete sessions older than this many days + + Returns: + Number of sessions deleted + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + DELETE FROM pomodoro_sessions + WHERE deleted = 1 + AND created_at < datetime('now', '-' || ? || ' days') + """, + (days,), + ) + conn.commit() + deleted_count = cursor.rowcount + logger.debug(f"Hard deleted {deleted_count} old Pomodoro sessions") + return deleted_count + except Exception as e: + logger.error(f"Failed to hard delete old sessions: {e}", exc_info=True) + raise + + async def update_todo_association( + self, session_id: str, todo_id: Optional[str] + ) -> None: + """ + Update the associated TODO for a Pomodoro session + + Args: + session_id: Session ID + todo_id: TODO ID to associate (None to clear association) + """ + try: + with self._get_conn() as conn: + conn.execute( + """ + UPDATE pomodoro_sessions + SET associated_todo_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (todo_id, session_id), + ) + conn.commit() + logger.debug( + f"Updated TODO association for session {session_id}: {todo_id}" + ) + except Exception as e: + logger.error( + f"Failed to update TODO association for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def get_sessions_by_todo(self, todo_id: str) -> List[Dict[str, Any]]: + """ + Get all sessions associated with a TODO + + Args: + todo_id: TODO ID + + Returns: + List of session dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE associated_todo_id = ? AND deleted = 0 + ORDER BY start_time DESC + """, + (todo_id,), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error( + f"Failed to get sessions for TODO {todo_id}: {e}", exc_info=True + ) + raise + + async def get_daily_stats(self, date: str) -> Dict[str, Any]: + """ + Get Pomodoro statistics for a specific date + + Args: + date: Date in YYYY-MM-DD format + + Returns: + Dictionary with daily statistics: + - completed_count: Number of completed sessions + - total_focus_minutes: Total focus time in minutes + - average_duration_minutes: Average session duration + - sessions: List of sessions for that day + """ + try: + with self._get_conn() as conn: + # Get aggregated stats + cursor = conn.execute( + """ + SELECT + COUNT(*) as completed_count, + COALESCE(SUM(actual_duration_minutes), 0) as total_focus_minutes, + COALESCE(AVG(actual_duration_minutes), 0) as average_duration_minutes + FROM pomodoro_sessions + WHERE DATE(start_time) = ? + AND status = 'completed' + AND deleted = 0 + """, + (date,), + ) + stats_row = cursor.fetchone() + + # Get session list for the day + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE DATE(start_time) = ? AND deleted = 0 + ORDER BY start_time DESC + """, + (date,), + ) + sessions = self._rows_to_dicts(cursor.fetchall()) + + return { + "completed_count": stats_row[0] if stats_row else 0, + "total_focus_minutes": int(stats_row[1]) if stats_row else 0, + "average_duration_minutes": int(stats_row[2]) if stats_row else 0, + "sessions": sessions, + } + except Exception as e: + logger.error(f"Failed to get daily stats for {date}: {e}", exc_info=True) + raise + + async def switch_phase( + self, session_id: str, new_phase: str, phase_start_time: str + ) -> Dict[str, Any]: + """ + Switch to next phase in Pomodoro session + + Phase transitions: + - work → break: Increment completed_rounds + - break → work: Increment current_round + - Automatically mark as completed if all rounds finished + + Args: + session_id: Session ID + new_phase: New phase ('work' or 'break') + phase_start_time: ISO timestamp when new phase starts + + Returns: + Updated session record + """ + try: + with self._get_conn() as conn: + # Get current session state + cursor = conn.execute( + """ + SELECT current_phase, current_round, completed_rounds, total_rounds + FROM pomodoro_sessions + WHERE id = ? + """, + (session_id,), + ) + row = cursor.fetchone() + if not row: + raise ValueError(f"Session {session_id} not found") + + current_phase, current_round, completed_rounds, total_rounds = row + + # Calculate new state based on phase transition + if current_phase == "work" and new_phase == "break": + # Completed a work phase + completed_rounds += 1 + # current_round stays the same during break + + elif current_phase == "break" and new_phase == "work": + # Starting next work round + current_round += 1 + + # Check if all rounds are completed + new_status = "active" + if completed_rounds >= total_rounds and new_phase == "break": + # After completing the last work round, mark as completed + new_status = "completed" + new_phase = "completed" + + # Update session + conn.execute( + """ + UPDATE pomodoro_sessions + SET current_phase = ?, + current_round = ?, + completed_rounds = ?, + phase_start_time = ?, + status = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + new_phase, + current_round, + completed_rounds, + phase_start_time, + new_status, + session_id, + ), + ) + conn.commit() + + logger.debug( + f"Switched session {session_id} to phase '{new_phase}', " + f"round {current_round}/{total_rounds}, " + f"completed {completed_rounds}" + ) + + # Return updated session + return await self.get_by_id(session_id) or {} + + except Exception as e: + logger.error( + f"Failed to switch phase for session {session_id}: {e}", + exc_info=True, + ) + raise diff --git a/backend/core/db/raw_records.py b/backend/core/db/raw_records.py new file mode 100644 index 0000000..83fdc88 --- /dev/null +++ b/backend/core/db/raw_records.py @@ -0,0 +1,204 @@ +""" +RawRecords Repository - Handles raw record persistence for Pomodoro sessions +Raw records are temporary storage for screenshots, keyboard, and mouse activity +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from core.logger import get_logger + +from .base import BaseRepository + +logger = get_logger(__name__) + + +class RawRecordsRepository(BaseRepository): + """Repository for managing raw records in the database""" + + def __init__(self, db_path: Path): + super().__init__(db_path) + + async def save( + self, + timestamp: str, + record_type: str, + data: str, + pomodoro_session_id: Optional[str] = None, + ) -> Optional[int]: + """ + Save a raw record to database + + Args: + timestamp: ISO format timestamp + record_type: Type of record (SCREENSHOT_RECORD, KEYBOARD_RECORD, MOUSE_RECORD) + data: JSON string of record data + pomodoro_session_id: Optional Pomodoro session ID + + Returns: + Record ID if successful, None otherwise + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + INSERT INTO raw_records ( + timestamp, type, data, pomodoro_session_id, created_at + ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + (timestamp, record_type, data, pomodoro_session_id), + ) + conn.commit() + record_id = cursor.lastrowid + logger.debug( + f"Saved raw record: {record_id}, " + f"type={record_type}, pomodoro_session={pomodoro_session_id}" + ) + return record_id + except Exception as e: + logger.error(f"Failed to save raw record: {e}", exc_info=True) + raise + + async def get_by_session( + self, + session_id: str, + limit: int = 100, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Get raw records for a specific Pomodoro session + + Args: + session_id: Pomodoro session ID + limit: Maximum number of records to return + offset: Number of records to skip + + Returns: + List of raw record dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM raw_records + WHERE pomodoro_session_id = ? + ORDER BY timestamp ASC + LIMIT ? OFFSET ? + """, + (session_id, limit, offset), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error( + f"Failed to get raw records for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def count_by_session(self, session_id: str) -> int: + """ + Count raw records for a session + + Args: + session_id: Pomodoro session ID + + Returns: + Number of raw records + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT COUNT(*) as count FROM raw_records + WHERE pomodoro_session_id = ? + """, + (session_id,), + ) + row = cursor.fetchone() + return row["count"] if row else 0 + except Exception as e: + logger.error( + f"Failed to count raw records for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def delete_by_session(self, session_id: str) -> int: + """ + Delete raw records for a session + + Args: + session_id: Pomodoro session ID + + Returns: + Number of records deleted + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + DELETE FROM raw_records + WHERE pomodoro_session_id = ? + """, + (session_id,), + ) + conn.commit() + deleted_count = cursor.rowcount + logger.debug( + f"Deleted {deleted_count} raw records for session {session_id}" + ) + return deleted_count + except Exception as e: + logger.error( + f"Failed to delete raw records for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def get_by_time_range( + self, + start_time: str, + end_time: str, + record_type: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """ + Get raw records within a time range + + Args: + start_time: Start timestamp (ISO format) + end_time: End timestamp (ISO format) + record_type: Optional filter by record type + + Returns: + List of raw record dictionaries + """ + try: + with self._get_conn() as conn: + if record_type: + cursor = conn.execute( + """ + SELECT * FROM raw_records + WHERE timestamp >= ? AND timestamp <= ? AND type = ? + ORDER BY timestamp ASC + """, + (start_time, end_time, record_type), + ) + else: + cursor = conn.execute( + """ + SELECT * FROM raw_records + WHERE timestamp >= ? AND timestamp <= ? + ORDER BY timestamp ASC + """, + (start_time, end_time), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error( + f"Failed to get raw records by time range: {e}", exc_info=True + ) + raise diff --git a/backend/core/db/todos.py b/backend/core/db/todos.py index bc3c8d8..560b82d 100644 --- a/backend/core/db/todos.py +++ b/backend/core/db/todos.py @@ -85,6 +85,55 @@ async def save( raise + async def get_by_id(self, todo_id: str) -> Optional[Dict[str, Any]]: + """ + Get a single todo by ID + + Args: + todo_id: Todo ID + + Returns: + Todo dict or None if not found + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT id, title, description, keywords, + created_at, completed, deleted, scheduled_date, scheduled_time, + scheduled_end_time, recurrence_rule + FROM todos + WHERE id = ? + """, + (todo_id,), + ) + row = cursor.fetchone() + + if row: + return { + "id": row["id"], + "title": row["title"], + "description": row["description"], + "keywords": json.loads(row["keywords"]) + if row["keywords"] + else [], + "created_at": row["created_at"], + "completed": bool(row["completed"]), + "deleted": bool(row["deleted"]), + "scheduled_date": row["scheduled_date"], + "scheduled_time": row["scheduled_time"], + "scheduled_end_time": row["scheduled_end_time"], + "recurrence_rule": json.loads(row["recurrence_rule"]) + if row["recurrence_rule"] + else None, + } + + return None + + except Exception as e: + logger.error(f"Failed to get todo by ID {todo_id}: {e}", exc_info=True) + return None + async def get_list( self, include_completed: bool = False ) -> List[Dict[str, Any]]: diff --git a/backend/core/events.py b/backend/core/events.py index e60d194..f3d0b38 100644 --- a/backend/core/events.py +++ b/backend/core/events.py @@ -544,3 +544,123 @@ def emit_todo_deleted(todo_id: str, timestamp: Optional[str] = None) -> bool: if success: logger.debug(f"✅ TODO deletion event sent: {todo_id}") return success + + + +def emit_pomodoro_processing_progress( + session_id: str, job_id: str, processed: int +) -> bool: + """ + Send Pomodoro processing progress event to frontend + + Args: + session_id: Pomodoro session ID + job_id: Processing job ID + processed: Number of records processed + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "job_id": job_id, + "processed": processed, + } + + logger.debug( + f"[emit_pomodoro_processing_progress] Session: {session_id}, " + f"Job: {job_id}, Processed: {processed}" + ) + return _emit("pomodoro-processing-progress", payload) + + +def emit_pomodoro_processing_complete( + session_id: str, job_id: str, total_processed: int +) -> bool: + """ + Send Pomodoro processing completion event to frontend + + Args: + session_id: Pomodoro session ID + job_id: Processing job ID + total_processed: Total number of records processed + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "job_id": job_id, + "total_processed": total_processed, + } + + logger.debug( + f"[emit_pomodoro_processing_complete] Session: {session_id}, " + f"Job: {job_id}, Total: {total_processed}" + ) + return _emit("pomodoro-processing-complete", payload) + + +def emit_pomodoro_processing_failed( + session_id: str, job_id: str, error: str +) -> bool: + """ + Send Pomodoro processing failure event to frontend + + Args: + session_id: Pomodoro session ID + job_id: Processing job ID + error: Error message + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "job_id": job_id, + "error": error, + } + + logger.debug( + f"[emit_pomodoro_processing_failed] Session: {session_id}, " + f"Job: {job_id}, Error: {error}" + ) + return _emit("pomodoro-processing-failed", payload) + + +def emit_pomodoro_phase_switched( + session_id: str, + new_phase: str, + current_round: int, + total_rounds: int, + completed_rounds: int, +) -> bool: + """ + Send Pomodoro phase switch event to frontend + + Emitted when session automatically switches between work/break phases. + + Args: + session_id: Pomodoro session ID + new_phase: New phase ('work', 'break', or 'completed') + current_round: Current round number (1-based) + total_rounds: Total number of rounds + completed_rounds: Number of completed work rounds + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "new_phase": new_phase, + "current_round": current_round, + "total_rounds": total_rounds, + "completed_rounds": completed_rounds, + } + + logger.debug( + f"[emit_pomodoro_phase_switched] Session: {session_id}, " + f"Phase: {new_phase}, Round: {current_round}/{total_rounds}, " + f"Completed: {completed_rounds}" + ) + return _emit("pomodoro-phase-switched", payload) diff --git a/backend/core/pomodoro_manager.py b/backend/core/pomodoro_manager.py new file mode 100644 index 0000000..1512d6b --- /dev/null +++ b/backend/core/pomodoro_manager.py @@ -0,0 +1,710 @@ +""" +Pomodoro Manager - Manages Pomodoro session lifecycle + +Responsibilities: +1. Start/stop Pomodoro sessions +2. Coordinate with PipelineCoordinator (enter/exit Pomodoro mode) +3. Trigger deferred batch processing after session completion +4. Track session metadata and handle orphaned sessions +""" + +import asyncio +import uuid +from datetime import datetime +from typing import Any, Dict, Optional + +from core.db import get_db +from core.logger import get_logger +from core.models import RawRecord + +logger = get_logger(__name__) + + +class PomodoroSession: + """Pomodoro session data class""" + + def __init__( + self, + session_id: str, + user_intent: str, + duration_minutes: int, + start_time: datetime, + ): + self.id = session_id + self.user_intent = user_intent + self.duration_minutes = duration_minutes + self.start_time = start_time + + +class PomodoroManager: + """ + Pomodoro session manager + + Handles Pomodoro lifecycle and coordinates with coordinator + """ + + def __init__(self, coordinator): + """ + Initialize Pomodoro manager + + Args: + coordinator: Reference to PipelineCoordinator instance + """ + self.coordinator = coordinator + self.db = get_db() + self.current_session: Optional[PomodoroSession] = None + self.is_active = False + self._processing_tasks: Dict[str, asyncio.Task] = {} + + async def start_pomodoro( + self, + user_intent: str, + duration_minutes: int = 25, + associated_todo_id: Optional[str] = None, + work_duration_minutes: int = 25, + break_duration_minutes: int = 5, + total_rounds: int = 4, + ) -> str: + """ + Start a new Pomodoro session with rounds + + Actions: + 1. Create pomodoro_sessions record + 2. Signal coordinator to enter "pomodoro mode" + 3. Start phase timer for automatic work/break switching + 4. Coordinator disables continuous processing + 5. PerceptionManager captures during work phase only + + Args: + user_intent: User's description of what they plan to work on + duration_minutes: Total planned duration (calculated from rounds) + associated_todo_id: Optional TODO ID to associate with this session + work_duration_minutes: Duration of each work phase (default: 25) + break_duration_minutes: Duration of each break phase (default: 5) + total_rounds: Total number of work rounds (default: 4) + + Returns: + session_id + + Raises: + ValueError: If a Pomodoro session is already active + """ + if self.is_active: + raise ValueError("A Pomodoro session is already active") + + # Check if previous session is still processing + processing_sessions = await self.db.pomodoro_sessions.get_by_processing_status( + "processing", limit=1 + ) + if processing_sessions: + raise ValueError( + "Previous Pomodoro session is still being analyzed. " + "Please wait for completion before starting a new session." + ) + + session_id = str(uuid.uuid4()) + start_time = datetime.now() + + # Calculate total duration: (work + break) * rounds - last break + total_duration = (work_duration_minutes + break_duration_minutes) * total_rounds - break_duration_minutes + + try: + # Save to database + await self.db.pomodoro_sessions.create( + session_id=session_id, + user_intent=user_intent, + planned_duration_minutes=total_duration, + start_time=start_time.isoformat(), + status="active", + associated_todo_id=associated_todo_id, + work_duration_minutes=work_duration_minutes, + break_duration_minutes=break_duration_minutes, + total_rounds=total_rounds, + ) + + # Create session object + self.current_session = PomodoroSession( + session_id=session_id, + user_intent=user_intent, + duration_minutes=total_duration, + start_time=start_time, + ) + self.is_active = True + + # Signal coordinator to enter pomodoro mode (work phase) + await self.coordinator.enter_pomodoro_mode(session_id) + + # Start phase timer for automatic switching + self._start_phase_timer(session_id, work_duration_minutes) + + logger.info( + f"✓ Pomodoro session started: {session_id}, " + f"intent='{user_intent}', rounds={total_rounds}, " + f"work={work_duration_minutes}min, break={break_duration_minutes}min" + ) + + return session_id + + except Exception as e: + logger.error(f"Failed to start Pomodoro session: {e}", exc_info=True) + # Cleanup on failure + self.is_active = False + self.current_session = None + raise + + def _start_phase_timer(self, session_id: str, duration_minutes: int) -> None: + """ + Start a timer for current phase + + When timer expires, automatically switch to next phase. + + Args: + session_id: Session ID + duration_minutes: Duration of current phase in minutes + """ + # Cancel any existing timer for this session + if session_id in self._processing_tasks: + self._processing_tasks[session_id].cancel() + + # Create async task for phase timer + async def phase_timer(): + try: + # Wait for phase duration + await asyncio.sleep(duration_minutes * 60) + + # Switch to next phase + await self._auto_switch_phase(session_id) + + except asyncio.CancelledError: + logger.debug(f"Phase timer cancelled for session {session_id}") + except Exception as e: + logger.error( + f"Error in phase timer for session {session_id}: {e}", + exc_info=True, + ) + + # Store task reference + task = asyncio.create_task(phase_timer()) + self._processing_tasks[session_id] = task + + async def _auto_switch_phase(self, session_id: str) -> None: + """ + Automatically switch to next phase when current phase completes + + Phase transitions: + - work → break: Stop perception, start break timer + - break → work: Start perception, start work timer + - If all rounds completed: End session + + Args: + session_id: Session ID + """ + try: + # Get current session state + session = await self.db.pomodoro_sessions.get_by_id(session_id) + if not session: + logger.warning(f"Session {session_id} not found for phase switch") + return + + current_phase = session.get("current_phase", "work") + current_round = session.get("current_round", 1) + total_rounds = session.get("total_rounds", 4) + work_duration = session.get("work_duration_minutes", 25) + break_duration = session.get("break_duration_minutes", 5) + + logger.info( + f"Auto-switching phase for session {session_id}: " + f"current_phase={current_phase}, round={current_round}/{total_rounds}" + ) + + # Determine next phase + if current_phase == "work": + # Work phase completed, switch to break + new_phase = "break" + next_duration = break_duration + + # Stop perception during break + await self.coordinator.exit_pomodoro_mode() + + # Check if this was the last round + if current_round >= total_rounds: + # All rounds completed, end session + await self._complete_session(session_id) + return + + elif current_phase == "break": + # Break completed, switch to next work round + new_phase = "work" + next_duration = work_duration + + # Resume perception for work phase + await self.coordinator.enter_pomodoro_mode(session_id) + + else: + logger.warning(f"Unknown phase '{current_phase}' for session {session_id}") + return + + # Update session phase in database + phase_start_time = datetime.now().isoformat() + updated_session = await self.db.pomodoro_sessions.switch_phase( + session_id, new_phase, phase_start_time + ) + + # Start timer for next phase + self._start_phase_timer(session_id, next_duration) + + # Emit phase switch event to frontend + from core.events import emit_pomodoro_phase_switched + + emit_pomodoro_phase_switched( + session_id=session_id, + new_phase=new_phase, + current_round=updated_session.get("current_round", current_round), + total_rounds=total_rounds, + completed_rounds=updated_session.get("completed_rounds", 0), + ) + + logger.info( + f"✓ Switched to {new_phase} phase for session {session_id}, " + f"duration={next_duration}min" + ) + + except Exception as e: + logger.error( + f"Failed to auto-switch phase for session {session_id}: {e}", + exc_info=True, + ) + + async def _complete_session(self, session_id: str) -> None: + """ + Complete a Pomodoro session after all rounds finished + + Args: + session_id: Session ID + """ + try: + logger.info(f"Completing Pomodoro session {session_id}: all rounds finished") + + # Mark session as completed + end_time = datetime.now() + session = await self.db.pomodoro_sessions.get_by_id(session_id) + + if session: + start_time = datetime.fromisoformat(session["start_time"]) + actual_duration = int((end_time - start_time).total_seconds() / 60) + + await self.db.pomodoro_sessions.update( + session_id, + status="completed", + end_time=end_time.isoformat(), + actual_duration_minutes=actual_duration, + current_phase="completed", + ) + + # Cleanup + self.is_active = False + self.current_session = None + + # Cancel phase timer + if session_id in self._processing_tasks: + self._processing_tasks[session_id].cancel() + del self._processing_tasks[session_id] + + # Exit pomodoro mode + await self.coordinator.exit_pomodoro_mode() + + # Trigger batch processing + await self._trigger_batch_processing(session_id) + + logger.info(f"✓ Session {session_id} completed successfully") + + except Exception as e: + logger.error(f"Failed to complete session {session_id}: {e}", exc_info=True) + + async def end_pomodoro(self, status: str = "completed") -> Dict[str, Any]: + """ + End current Pomodoro session (manual termination) + + Actions: + 1. Cancel phase timer + 2. Update pomodoro_sessions record + 3. Signal coordinator to exit "pomodoro mode" + 4. Trigger deferred batch processing + 5. Return processing job ID + + Args: + status: Session status ('completed', 'abandoned', 'interrupted') + + Returns: + { + "session_id": str, + "processing_job_id": str, + "raw_records_count": int + } + + Raises: + ValueError: If no active Pomodoro session + """ + if not self.is_active or not self.current_session: + raise ValueError("No active Pomodoro session") + + session_id = self.current_session.id + end_time = datetime.now() + duration = (end_time - self.current_session.start_time).total_seconds() / 60 + + # Cancel phase timer if running + if session_id in self._processing_tasks: + self._processing_tasks[session_id].cancel() + del self._processing_tasks[session_id] + logger.debug(f"Cancelled phase timer for manual end of session {session_id}") + + try: + # Check if session is too short (< 2 minutes) + if duration < 2: + logger.warning( + f"Pomodoro session {session_id} too short ({duration:.1f}min), skipping analysis" + ) + await self.db.pomodoro_sessions.update( + session_id=session_id, + end_time=end_time.isoformat(), + actual_duration_minutes=int(duration), + status="too_short", + processing_status="skipped", + ) + + # Exit pomodoro mode + await self.coordinator.exit_pomodoro_mode() + + self.is_active = False + self.current_session = None + + return { + "session_id": session_id, + "processing_job_id": None, + "raw_records_count": 0, + "message": "Session too short, data discarded", + } + + # Update database + await self.db.pomodoro_sessions.update( + session_id=session_id, + end_time=end_time.isoformat(), + actual_duration_minutes=int(duration), + status=status, + processing_status="pending", + ) + + # Exit pomodoro mode + await self.coordinator.exit_pomodoro_mode() + + # Count raw records for this session + raw_count = await self.db.raw_records.count_by_session(session_id) + + logger.info( + f"✓ Pomodoro session ended: {session_id}, " + f"status={status}, duration={duration:.1f}min, records={raw_count}" + ) + + # Trigger batch processing in background + job_id = await self._trigger_batch_processing(session_id) + + self.is_active = False + self.current_session = None + + return { + "session_id": session_id, + "processing_job_id": job_id, + "raw_records_count": raw_count, + } + + except Exception as e: + logger.error(f"Failed to end Pomodoro session: {e}", exc_info=True) + raise + + async def _trigger_batch_processing(self, session_id: str) -> str: + """ + Trigger background batch processing for Pomodoro session + + Creates async task that: + 1. Loads all RawRecords with pomodoro_session_id + 2. Processes through normal pipeline (deferred) + 3. Updates processing_status as it progresses + 4. Emits events for frontend to track progress + + Args: + session_id: Pomodoro session ID + + Returns: + job_id: Processing job identifier + """ + job_id = str(uuid.uuid4()) + + # Create background task + task = asyncio.create_task(self._process_pomodoro_batch(session_id, job_id)) + + # Store task reference + self._processing_tasks[job_id] = task + + logger.debug(f"✓ Batch processing triggered: job={job_id}, session={session_id}") + + return job_id + + async def _process_pomodoro_batch(self, session_id: str, job_id: str): + """ + Background task to process Pomodoro session data + + Steps: + 1. Update status to 'processing' + 2. Load RawRecords in chunks (to avoid memory issues) + 3. Process through pipeline + 4. Update status to 'completed' + 5. Emit completion event + + Args: + session_id: Pomodoro session ID + job_id: Processing job ID + """ + try: + await self.db.pomodoro_sessions.update( + session_id=session_id, + processing_status="processing", + processing_started_at=datetime.now().isoformat(), + ) + + logger.info(f"→ Processing Pomodoro session: {session_id}") + + # Load raw records in chunks + chunk_size = 100 + offset = 0 + total_processed = 0 + + while True: + records = await self.db.raw_records.get_by_session( + session_id=session_id, + limit=chunk_size, + offset=offset, + ) + + if not records: + break + + # Convert DB records back to RawRecord objects + raw_records = [] + for r in records: + try: + import json + + raw_record = RawRecord( + timestamp=datetime.fromisoformat(r["timestamp"]), + type=r["type"], + data=json.loads(r["data"]), + ) + raw_records.append(raw_record) + except Exception as e: + logger.warning(f"Failed to parse raw record {r['id']}: {e}") + + # Process through pipeline + if raw_records: + await self.coordinator.processing_pipeline.process_raw_records( + raw_records + ) + + total_processed += len(records) + offset += chunk_size + + # Emit progress event + self._emit_progress_event(session_id, job_id, total_processed) + + # Update status + await self.db.pomodoro_sessions.update( + session_id=session_id, + processing_status="completed", + processing_completed_at=datetime.now().isoformat(), + ) + + logger.info( + f"✓ Pomodoro session processed: {session_id}, records={total_processed}" + ) + + # Emit completion event + self._emit_completion_event(session_id, job_id, total_processed) + + # Cleanup task reference + self._processing_tasks.pop(job_id, None) + + except Exception as e: + logger.error( + f"✗ Pomodoro batch processing failed: {e}", exc_info=True + ) + await self.db.pomodoro_sessions.update( + session_id=session_id, + processing_status="failed", + processing_error=str(e), + ) + + # Emit failure event + self._emit_failure_event(session_id, job_id, str(e)) + + # Cleanup task reference + self._processing_tasks.pop(job_id, None) + + def _emit_progress_event( + self, session_id: str, job_id: str, processed: int + ) -> None: + """Emit progress event for frontend""" + try: + from core.events import emit_pomodoro_processing_progress + + emit_pomodoro_processing_progress(session_id, job_id, processed) + except Exception as e: + logger.debug(f"Failed to emit progress event: {e}") + + def _emit_completion_event( + self, session_id: str, job_id: str, total_processed: int + ) -> None: + """Emit completion event for frontend""" + try: + from core.events import emit_pomodoro_processing_complete + + emit_pomodoro_processing_complete(session_id, job_id, total_processed) + except Exception as e: + logger.debug(f"Failed to emit completion event: {e}") + + def _emit_failure_event( + self, session_id: str, job_id: str, error: str + ) -> None: + """Emit failure event for frontend""" + try: + from core.events import emit_pomodoro_processing_failed + + emit_pomodoro_processing_failed(session_id, job_id, error) + except Exception as e: + logger.debug(f"Failed to emit failure event: {e}") + + async def check_orphaned_sessions(self) -> int: + """ + Check for orphaned sessions from previous runs + + Orphaned sessions are active sessions that were not properly closed + (e.g., due to app crash or system shutdown). + + This should be called on application startup. + + Returns: + Number of orphaned sessions found and recovered + """ + try: + orphaned = await self.db.pomodoro_sessions.get_by_status("active") + + if not orphaned: + return 0 + + logger.warning(f"Found {len(orphaned)} orphaned Pomodoro session(s)") + + for session in orphaned: + session_id = session["id"] + + # Auto-end as 'interrupted' + await self.db.pomodoro_sessions.update( + session_id=session_id, + end_time=datetime.now().isoformat(), + status="interrupted", + processing_status="pending", + ) + + # Trigger batch processing + await self._trigger_batch_processing(session_id) + + logger.info( + f"✓ Recovered orphaned session: {session_id}, triggering analysis" + ) + + return len(orphaned) + + except Exception as e: + logger.error(f"Failed to check orphaned sessions: {e}", exc_info=True) + return 0 + + async def get_current_session_info(self) -> Optional[Dict[str, Any]]: + """ + Get current session information with rounds and phase data + + Returns: + Session info dict or None if no active session + """ + if not self.is_active or not self.current_session: + return None + + # Fetch full session info from database to get all fields + session_record = await self.db.pomodoro_sessions.get_by_id( + self.current_session.id + ) + + if not session_record: + return None + + now = datetime.now() + elapsed_minutes = ( + now - self.current_session.start_time + ).total_seconds() / 60 + + # Get phase information + current_phase = session_record.get("current_phase", "work") + phase_start_time_str = session_record.get("phase_start_time") + work_duration = session_record.get("work_duration_minutes", 25) + break_duration = session_record.get("break_duration_minutes", 5) + + # Calculate remaining time in current phase + remaining_phase_seconds = None + if phase_start_time_str: + try: + phase_start = datetime.fromisoformat(phase_start_time_str) + phase_elapsed = (now - phase_start).total_seconds() + + # Determine phase duration + phase_duration_seconds = ( + work_duration * 60 + if current_phase == "work" + else break_duration * 60 + ) + + remaining_phase_seconds = max( + 0, int(phase_duration_seconds - phase_elapsed) + ) + except Exception as e: + logger.warning(f"Failed to calculate remaining time: {e}") + + session_info = { + "session_id": self.current_session.id, + "user_intent": self.current_session.user_intent, + "start_time": self.current_session.start_time.isoformat(), + "elapsed_minutes": int(elapsed_minutes), + "planned_duration_minutes": self.current_session.duration_minutes, + "associated_todo_id": session_record.get("associated_todo_id"), + "associated_todo_title": None, + # Rounds data + "work_duration_minutes": work_duration, + "break_duration_minutes": break_duration, + "total_rounds": session_record.get("total_rounds", 4), + "current_round": session_record.get("current_round", 1), + "current_phase": current_phase, + "phase_start_time": phase_start_time_str, + "completed_rounds": session_record.get("completed_rounds", 0), + "remaining_phase_seconds": remaining_phase_seconds, + } + + # If there's an associated TODO, fetch its title + todo_id = session_info["associated_todo_id"] + if todo_id: + try: + # Ensure todo_id is a string for type safety + todo_id_str = str(todo_id) if not isinstance(todo_id, str) else todo_id + todo = await self.db.todos.get_by_id(todo_id_str) + if todo and not todo.get("deleted"): + session_info["associated_todo_title"] = todo.get("title") + except Exception as e: + logger.warning( + f"Failed to fetch TODO title for session {self.current_session.id}: {e}" + ) + + return session_info diff --git a/backend/core/sqls/migrations.py b/backend/core/sqls/migrations.py index ad9b878..a967e35 100644 --- a/backend/core/sqls/migrations.py +++ b/backend/core/sqls/migrations.py @@ -1,6 +1,13 @@ """ -Database migration SQL statements -Contains all ALTER TABLE and data migration statements +DEPRECATED: This file is no longer used + +Database migration system has been moved to version-based migrations. +See: backend/migrations/ + +All migrations should now be created as versioned files in: +backend/migrations/versions/ + +This file is kept for reference only and will be removed in a future version. """ # Events table migrations @@ -176,3 +183,59 @@ ADD_KNOWLEDGE_SOURCE_ACTION_ID_COLUMN = """ ALTER TABLE knowledge ADD COLUMN source_action_id TEXT """ + +# ============ Pomodoro Feature Migrations ============ + +# Add pomodoro_session_id to raw_records +ADD_RAW_RECORDS_POMODORO_SESSION_ID_COLUMN = """ + ALTER TABLE raw_records ADD COLUMN pomodoro_session_id TEXT +""" + +# Add pomodoro_session_id to actions +ADD_ACTIONS_POMODORO_SESSION_ID_COLUMN = """ + ALTER TABLE actions ADD COLUMN pomodoro_session_id TEXT +""" + +# Add pomodoro_session_id to events +ADD_EVENTS_POMODORO_SESSION_ID_COLUMN = """ + ALTER TABLE events ADD COLUMN pomodoro_session_id TEXT +""" + +# Add pomodoro-related columns to activities +ADD_ACTIVITIES_POMODORO_SESSION_ID_COLUMN = """ + ALTER TABLE activities ADD COLUMN pomodoro_session_id TEXT +""" + +ADD_ACTIVITIES_USER_INTENT_COLUMN = """ + ALTER TABLE activities ADD COLUMN user_intent TEXT +""" + +ADD_ACTIVITIES_POMODORO_STATUS_COLUMN = """ + ALTER TABLE activities ADD COLUMN pomodoro_status TEXT +""" + +# Create indexes for pomodoro_session_id columns +CREATE_RAW_RECORDS_POMODORO_SESSION_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_raw_records_pomodoro_session + ON raw_records(pomodoro_session_id) +""" + +CREATE_ACTIONS_POMODORO_SESSION_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_actions_pomodoro_session + ON actions(pomodoro_session_id) +""" + +CREATE_EVENTS_POMODORO_SESSION_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_events_pomodoro_session + ON events(pomodoro_session_id) +""" + +CREATE_ACTIVITIES_POMODORO_SESSION_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_session + ON activities(pomodoro_session_id) +""" + +CREATE_ACTIVITIES_POMODORO_STATUS_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_status + ON activities(pomodoro_status) +""" diff --git a/backend/core/sqls/schema.py b/backend/core/sqls/schema.py index 8f31740..f17bb0d 100644 --- a/backend/core/sqls/schema.py +++ b/backend/core/sqls/schema.py @@ -228,6 +228,29 @@ ) """ +CREATE_POMODORO_SESSIONS_TABLE = """ + CREATE TABLE IF NOT EXISTS pomodoro_sessions ( + id TEXT PRIMARY KEY, + user_intent TEXT NOT NULL, + planned_duration_minutes INTEGER DEFAULT 25, + actual_duration_minutes INTEGER, + start_time TEXT NOT NULL, + end_time TEXT, + status TEXT NOT NULL, + processing_status TEXT DEFAULT 'pending', + processing_started_at TEXT, + processing_completed_at TEXT, + processing_error TEXT, + interruption_count INTEGER DEFAULT 0, + interruption_reasons TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT 0, + CHECK(status IN ('active', 'completed', 'abandoned', 'interrupted', 'too_short')), + CHECK(processing_status IN ('pending', 'processing', 'completed', 'failed', 'skipped')) + ) +""" + CREATE_KNOWLEDGE_CREATED_INDEX = """ CREATE INDEX IF NOT EXISTS idx_knowledge_created ON knowledge(created_at DESC) @@ -386,6 +409,28 @@ ON session_preferences(confidence_score DESC) """ +# ============ Pomodoro Sessions Indexes ============ + +CREATE_POMODORO_SESSIONS_STATUS_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_status + ON pomodoro_sessions(status) +""" + +CREATE_POMODORO_SESSIONS_PROCESSING_STATUS_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_processing_status + ON pomodoro_sessions(processing_status) +""" + +CREATE_POMODORO_SESSIONS_START_TIME_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_start_time + ON pomodoro_sessions(start_time DESC) +""" + +CREATE_POMODORO_SESSIONS_CREATED_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_created + ON pomodoro_sessions(created_at DESC) +""" + # All table creation statements in order ALL_TABLES = [ CREATE_RAW_RECORDS_TABLE, @@ -405,6 +450,8 @@ CREATE_ACTIONS_TABLE, CREATE_ACTION_IMAGES_TABLE, CREATE_SESSION_PREFERENCES_TABLE, + # Pomodoro feature + CREATE_POMODORO_SESSIONS_TABLE, ] # All index creation statements @@ -441,4 +488,9 @@ CREATE_ACTION_IMAGES_HASH_INDEX, CREATE_SESSION_PREFERENCES_TYPE_INDEX, CREATE_SESSION_PREFERENCES_CONFIDENCE_INDEX, + # Pomodoro sessions indexes + CREATE_POMODORO_SESSIONS_STATUS_INDEX, + CREATE_POMODORO_SESSIONS_PROCESSING_STATUS_INDEX, + CREATE_POMODORO_SESSIONS_START_TIME_INDEX, + CREATE_POMODORO_SESSIONS_CREATED_INDEX, ] diff --git a/backend/handlers/__init__.py b/backend/handlers/__init__.py index 7f4dbcf..a0a3a4e 100644 --- a/backend/handlers/__init__.py +++ b/backend/handlers/__init__.py @@ -206,11 +206,15 @@ def register_fastapi_routes(app: "FastAPI", prefix: str = "/api") -> None: # ruff: noqa: E402 from . import ( activities, + activity_ratings, agents, chat, events, insights, monitoring, + pomodoro, + pomodoro_presets, + pomodoro_stats, processing, resources, system, @@ -222,11 +226,15 @@ def register_fastapi_routes(app: "FastAPI", prefix: str = "/api") -> None: "register_fastapi_routes", "get_registered_handlers", "activities", + "activity_ratings", "agents", "chat", "events", "insights", "monitoring", + "pomodoro", + "pomodoro_presets", + "pomodoro_stats", "processing", "resources", "system", diff --git a/backend/handlers/activity_ratings.py b/backend/handlers/activity_ratings.py new file mode 100644 index 0000000..f1235ec --- /dev/null +++ b/backend/handlers/activity_ratings.py @@ -0,0 +1,211 @@ +""" +Activity Ratings Handler - API endpoints for multi-dimensional activity ratings + +Endpoints: +- POST /activities/rating/save - Save or update an activity rating +- POST /activities/rating/get - Get all ratings for an activity +- POST /activities/rating/delete - Delete a specific rating +""" + +from datetime import datetime +from typing import List, Optional + +from core.db import get_db +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +# ============ Request Models ============ + + +class SaveActivityRatingRequest(BaseModel): + """Request to save or update an activity rating""" + + activity_id: str + dimension: str + rating: int # 1-5 + note: Optional[str] = None + + +class GetActivityRatingsRequest(BaseModel): + """Request to get ratings for an activity""" + + activity_id: str + + +class DeleteActivityRatingRequest(BaseModel): + """Request to delete a specific rating""" + + activity_id: str + dimension: str + + +# ============ Response Models ============ + + +class ActivityRatingData(BaseModel): + """Individual rating record""" + + id: str + activity_id: str + dimension: str + rating: int + note: Optional[str] = None + created_at: str + updated_at: str + + +class SaveActivityRatingResponse(TimedOperationResponse): + """Response after saving a rating""" + + data: Optional[ActivityRatingData] = None + + +class GetActivityRatingsResponse(TimedOperationResponse): + """Response with list of ratings""" + + data: Optional[List[ActivityRatingData]] = None + + +# ============ API Handlers ============ + + +@api_handler( + body=SaveActivityRatingRequest, + method="POST", + path="/activities/rating/save", + tags=["activities"], +) +async def save_activity_rating( + body: SaveActivityRatingRequest, +) -> SaveActivityRatingResponse: + """ + Save or update an activity rating + + Supports multi-dimensional ratings: + - focus_level: How focused were you? (1-5) + - productivity: How productive was this session? (1-5) + - importance: How important was this activity? (1-5) + - satisfaction: How satisfied are you with the outcome? (1-5) + """ + try: + db = get_db() + + # Validate rating range + if not 1 <= body.rating <= 5: + return SaveActivityRatingResponse( + success=False, + message="Rating must be between 1 and 5", + timestamp=datetime.now().isoformat(), + ) + + # Save rating + rating_record = await db.activity_ratings.save_rating( + activity_id=body.activity_id, + dimension=body.dimension, + rating=body.rating, + note=body.note, + ) + + logger.info( + f"Saved activity rating: {body.activity_id} - " + f"{body.dimension} = {body.rating}" + ) + + return SaveActivityRatingResponse( + success=True, + message="Rating saved successfully", + data=ActivityRatingData(**rating_record), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to save activity rating: {e}", exc_info=True) + return SaveActivityRatingResponse( + success=False, + message=f"Failed to save rating: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=GetActivityRatingsRequest, + method="POST", + path="/activities/rating/get", + tags=["activities"], +) +async def get_activity_ratings( + body: GetActivityRatingsRequest, +) -> GetActivityRatingsResponse: + """ + Get all ratings for an activity + + Returns ratings for all dimensions that have been rated. + """ + try: + db = get_db() + + # Fetch ratings + ratings = await db.activity_ratings.get_ratings_by_activity(body.activity_id) + + logger.debug(f"Retrieved {len(ratings)} ratings for activity {body.activity_id}") + + return GetActivityRatingsResponse( + success=True, + message=f"Retrieved {len(ratings)} rating(s)", + data=[ActivityRatingData(**r) for r in ratings], + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to get activity ratings: {e}", exc_info=True) + return GetActivityRatingsResponse( + success=False, + message=f"Failed to get ratings: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=DeleteActivityRatingRequest, + method="POST", + path="/activities/rating/delete", + tags=["activities"], +) +async def delete_activity_rating( + body: DeleteActivityRatingRequest, +) -> TimedOperationResponse: + """ + Delete a specific activity rating + + Removes the rating for a specific dimension. + """ + try: + db = get_db() + + # Delete rating + await db.activity_ratings.delete_rating(body.activity_id, body.dimension) + + logger.info( + f"Deleted activity rating: {body.activity_id} - {body.dimension}" + ) + + return TimedOperationResponse( + success=True, + message="Rating deleted successfully", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to delete activity rating: {e}", exc_info=True) + return TimedOperationResponse( + success=False, + message=f"Failed to delete rating: {str(e)}", + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/pomodoro.py b/backend/handlers/pomodoro.py new file mode 100644 index 0000000..ae4b40e --- /dev/null +++ b/backend/handlers/pomodoro.py @@ -0,0 +1,275 @@ +""" +Pomodoro timer API handlers + +Endpoints: +- POST /pomodoro/start - Start a Pomodoro session +- POST /pomodoro/end - End current Pomodoro session +- GET /pomodoro/status - Get current Pomodoro session status +""" + +from datetime import datetime +from typing import Optional + +from core.coordinator import get_coordinator +from core.logger import get_logger +from models.base import BaseModel +from models.responses import ( + EndPomodoroData, + EndPomodoroResponse, + GetPomodoroStatusResponse, + PomodoroSessionData, + StartPomodoroResponse, +) + +from . import api_handler + +logger = get_logger(__name__) + + +class StartPomodoroRequest(BaseModel): + """Start Pomodoro request with rounds configuration""" + + user_intent: str + duration_minutes: int = 25 # Legacy field, calculated from rounds + associated_todo_id: Optional[str] = None # Optional TODO association + work_duration_minutes: int = 25 # Duration of work phase + break_duration_minutes: int = 5 # Duration of break phase + total_rounds: int = 4 # Number of work rounds + + +class EndPomodoroRequest(BaseModel): + """End Pomodoro request""" + + status: str = "completed" # completed, abandoned, interrupted + + +@api_handler( + body=StartPomodoroRequest, + method="POST", + path="/pomodoro/start", + tags=["pomodoro"], +) +async def start_pomodoro(body: StartPomodoroRequest) -> StartPomodoroResponse: + """ + Start a new Pomodoro session + + Args: + body: Request containing user_intent and duration_minutes + + Returns: + StartPomodoroResponse with session data + + Raises: + ValueError: If a Pomodoro session is already active or previous session is still processing + """ + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return StartPomodoroResponse( + success=False, + message="Pomodoro manager not initialized", + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # Start Pomodoro session + session_id = await coordinator.pomodoro_manager.start_pomodoro( + user_intent=body.user_intent, + duration_minutes=body.duration_minutes, + associated_todo_id=body.associated_todo_id, + work_duration_minutes=body.work_duration_minutes, + break_duration_minutes=body.break_duration_minutes, + total_rounds=body.total_rounds, + ) + + # Get session info + session_info = await coordinator.pomodoro_manager.get_current_session_info() + + if not session_info: + return StartPomodoroResponse( + success=False, + message="Failed to retrieve session info", + error="Failed to retrieve session info after starting", + timestamp=datetime.now().isoformat(), + ) + + logger.info( + f"Pomodoro session started via API: {session_id}, intent='{body.user_intent}'" + ) + + return StartPomodoroResponse( + success=True, + message="Pomodoro session started successfully", + data=PomodoroSessionData( + session_id=session_info["session_id"], + user_intent=session_info["user_intent"], + start_time=session_info["start_time"], + elapsed_minutes=session_info["elapsed_minutes"], + planned_duration_minutes=session_info["planned_duration_minutes"], + associated_todo_id=session_info.get("associated_todo_id"), + associated_todo_title=session_info.get("associated_todo_title"), + work_duration_minutes=session_info.get("work_duration_minutes", 25), + break_duration_minutes=session_info.get("break_duration_minutes", 5), + total_rounds=session_info.get("total_rounds", 4), + current_round=session_info.get("current_round", 1), + current_phase=session_info.get("current_phase", "work"), + phase_start_time=session_info.get("phase_start_time"), + completed_rounds=session_info.get("completed_rounds", 0), + remaining_phase_seconds=session_info.get("remaining_phase_seconds"), + ), + timestamp=datetime.now().isoformat(), + ) + + except ValueError as e: + # Expected errors (session already active, previous processing) + logger.warning(f"Failed to start Pomodoro session: {e}") + return StartPomodoroResponse( + success=False, + message=str(e), + error=str(e), + timestamp=datetime.now().isoformat(), + ) + except Exception as e: + logger.error(f"Unexpected error starting Pomodoro session: {e}", exc_info=True) + return StartPomodoroResponse( + success=False, + message="Failed to start Pomodoro session", + error=str(e), + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=EndPomodoroRequest, + method="POST", + path="/pomodoro/end", + tags=["pomodoro"], +) +async def end_pomodoro(body: EndPomodoroRequest) -> EndPomodoroResponse: + """ + End current Pomodoro session + + Args: + body: Request containing status (completed/abandoned/interrupted) + + Returns: + EndPomodoroResponse with processing job info + + Raises: + ValueError: If no active Pomodoro session + """ + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return EndPomodoroResponse( + success=False, + message="Pomodoro manager not initialized", + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # End Pomodoro session + result = await coordinator.pomodoro_manager.end_pomodoro(status=body.status) + + logger.info( + f"Pomodoro session ended via API: {result['session_id']}, status={body.status}" + ) + + return EndPomodoroResponse( + success=True, + message="Pomodoro session ended successfully", + data=EndPomodoroData( + session_id=result["session_id"], + processing_job_id=result.get("processing_job_id"), + raw_records_count=result["raw_records_count"], + message=result.get("message", ""), + ), + timestamp=datetime.now().isoformat(), + ) + + except ValueError as e: + # Expected error (no active session) + logger.warning(f"Failed to end Pomodoro session: {e}") + return EndPomodoroResponse( + success=False, + message=str(e), + error=str(e), + timestamp=datetime.now().isoformat(), + ) + except Exception as e: + logger.error(f"Unexpected error ending Pomodoro session: {e}", exc_info=True) + return EndPomodoroResponse( + success=False, + message="Failed to end Pomodoro session", + error=str(e), + timestamp=datetime.now().isoformat(), + ) + + +@api_handler(method="GET", path="/pomodoro/status", tags=["pomodoro"]) +async def get_pomodoro_status() -> GetPomodoroStatusResponse: + """ + Get current Pomodoro session status + + Returns: + GetPomodoroStatusResponse with current session info or None if no active session + """ + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return GetPomodoroStatusResponse( + success=False, + message="Pomodoro manager not initialized", + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # Get current session info + session_info = await coordinator.pomodoro_manager.get_current_session_info() + + if not session_info: + # No active session + return GetPomodoroStatusResponse( + success=True, + message="No active Pomodoro session", + data=None, + timestamp=datetime.now().isoformat(), + ) + + return GetPomodoroStatusResponse( + success=True, + message="Active Pomodoro session found", + data=PomodoroSessionData( + session_id=session_info["session_id"], + user_intent=session_info["user_intent"], + start_time=session_info["start_time"], + elapsed_minutes=session_info["elapsed_minutes"], + planned_duration_minutes=session_info["planned_duration_minutes"], + # Add all missing fields for complete session state + associated_todo_id=session_info.get("associated_todo_id"), + associated_todo_title=session_info.get("associated_todo_title"), + work_duration_minutes=session_info.get("work_duration_minutes", 25), + break_duration_minutes=session_info.get("break_duration_minutes", 5), + total_rounds=session_info.get("total_rounds", 4), + current_round=session_info.get("current_round", 1), + current_phase=session_info.get("current_phase", "work"), + phase_start_time=session_info.get("phase_start_time"), + completed_rounds=session_info.get("completed_rounds", 0), + remaining_phase_seconds=session_info.get("remaining_phase_seconds"), + ), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error( + f"Unexpected error getting Pomodoro status: {e}", exc_info=True + ) + return GetPomodoroStatusResponse( + success=False, + message="Failed to get Pomodoro status", + error=str(e), + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/pomodoro_presets.py b/backend/handlers/pomodoro_presets.py new file mode 100644 index 0000000..c23ca13 --- /dev/null +++ b/backend/handlers/pomodoro_presets.py @@ -0,0 +1,123 @@ +""" +Pomodoro Configuration Presets - API endpoint for getting preset configurations + +Provides predefined Pomodoro configurations for common use cases. +""" + +from datetime import datetime +from typing import List + +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +# ============ Response Models ============ + + +class PomodoroPreset(BaseModel): + """Pomodoro configuration preset""" + + id: str + name: str + description: str + work_duration_minutes: int + break_duration_minutes: int + total_rounds: int + icon: str = "⏱️" + + +class GetPomodoroPresetsResponse(TimedOperationResponse): + """Response with list of Pomodoro presets""" + + data: List[PomodoroPreset] = [] + + +# ============ Preset Definitions ============ + +POMODORO_PRESETS = [ + PomodoroPreset( + id="classic", + name="Classic Pomodoro", + description="Traditional 25/5 technique - 4 rounds", + work_duration_minutes=25, + break_duration_minutes=5, + total_rounds=4, + icon="🍅", + ), + PomodoroPreset( + id="deep-work", + name="Deep Work", + description="Extended focus sessions - 50/10 for intense work", + work_duration_minutes=50, + break_duration_minutes=10, + total_rounds=3, + icon="🎯", + ), + PomodoroPreset( + id="quick-sprint", + name="Quick Sprint", + description="Short bursts - 15/3 for quick tasks", + work_duration_minutes=15, + break_duration_minutes=3, + total_rounds=6, + icon="⚡", + ), + PomodoroPreset( + id="ultra-focus", + name="Ultra Focus", + description="Maximum concentration - 90/15 for deep thinking", + work_duration_minutes=90, + break_duration_minutes=15, + total_rounds=2, + icon="🧠", + ), + PomodoroPreset( + id="balanced", + name="Balanced Flow", + description="Moderate pace - 40/8 for sustained productivity", + work_duration_minutes=40, + break_duration_minutes=8, + total_rounds=4, + icon="⚖️", + ), +] + + +# ============ API Handler ============ + + +@api_handler(method="GET", path="/pomodoro/presets", tags=["pomodoro"]) +async def get_pomodoro_presets() -> GetPomodoroPresetsResponse: + """ + Get available Pomodoro configuration presets + + Returns a list of predefined configurations including: + - Classic Pomodoro (25/5) + - Deep Work (50/10) + - Quick Sprint (15/3) + - Ultra Focus (90/15) + - Balanced Flow (40/8) + """ + try: + logger.debug(f"Returning {len(POMODORO_PRESETS)} Pomodoro presets") + + return GetPomodoroPresetsResponse( + success=True, + message=f"Retrieved {len(POMODORO_PRESETS)} presets", + data=POMODORO_PRESETS, + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to get Pomodoro presets: {e}", exc_info=True) + return GetPomodoroPresetsResponse( + success=False, + message=f"Failed to get presets: {str(e)}", + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/pomodoro_stats.py b/backend/handlers/pomodoro_stats.py new file mode 100644 index 0000000..21f8f55 --- /dev/null +++ b/backend/handlers/pomodoro_stats.py @@ -0,0 +1,133 @@ +""" +Pomodoro Statistics Handler - API endpoints for Pomodoro session statistics + +Endpoints: +- POST /pomodoro/stats - Get Pomodoro statistics for a specific date +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from core.db import get_db +from core.logger import get_logger +from models.base import BaseModel +from models.responses import PomodoroSessionData, TimedOperationResponse + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +# ============ Request Models ============ + + +class GetPomodoroStatsRequest(BaseModel): + """Request to get Pomodoro statistics for a specific date""" + + date: str # YYYY-MM-DD format + + +# ============ Response Models ============ + + +class PomodoroStatsData(BaseModel): + """Pomodoro statistics for a specific date""" + + date: str + completed_count: int + total_focus_minutes: int + average_duration_minutes: int + sessions: List[Dict[str, Any]] # Recent sessions for the day + + +class GetPomodoroStatsResponse(TimedOperationResponse): + """Response with Pomodoro statistics""" + + data: Optional[PomodoroStatsData] = None + + +# ============ API Handlers ============ + + +@api_handler( + body=GetPomodoroStatsRequest, + method="POST", + path="/pomodoro/stats", + tags=["pomodoro"], +) +async def get_pomodoro_stats( + body: GetPomodoroStatsRequest, +) -> GetPomodoroStatsResponse: + """ + Get Pomodoro statistics for a specific date + + Returns: + - Number of completed sessions + - Total focus time (minutes) + - Average session duration (minutes) + - List of all sessions for that day + """ + try: + db = get_db() + + # Validate date format + try: + datetime.fromisoformat(body.date) + except ValueError: + return GetPomodoroStatsResponse( + success=False, + message="Invalid date format. Expected YYYY-MM-DD", + timestamp=datetime.now().isoformat(), + ) + + # Get daily stats from repository + stats = await db.pomodoro_sessions.get_daily_stats(body.date) + + # Optionally fetch associated TODO titles for sessions + sessions_with_todos = [] + for session in stats.get("sessions", []): + session_data = dict(session) + + # If session has associated_todo_id, fetch TODO title + if session_data.get("associated_todo_id"): + try: + todo = await db.todos.get_by_id(session_data["associated_todo_id"]) + if todo and not todo.get("deleted"): + session_data["associated_todo_title"] = todo.get("title") + else: + session_data["associated_todo_title"] = None + except Exception as e: + logger.warning( + f"Failed to fetch TODO for session {session_data.get('id')}: {e}" + ) + session_data["associated_todo_title"] = None + + sessions_with_todos.append(session_data) + + logger.debug( + f"Retrieved Pomodoro stats for {body.date}: " + f"{stats['completed_count']} completed, " + f"{stats['total_focus_minutes']} minutes" + ) + + return GetPomodoroStatsResponse( + success=True, + message=f"Retrieved statistics for {body.date}", + data=PomodoroStatsData( + date=body.date, + completed_count=stats["completed_count"], + total_focus_minutes=stats["total_focus_minutes"], + average_duration_minutes=stats["average_duration_minutes"], + sessions=sessions_with_todos, + ), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to get Pomodoro stats: {e}", exc_info=True) + return GetPomodoroStatsResponse( + success=False, + message=f"Failed to get statistics: {str(e)}", + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/resources.py b/backend/handlers/resources.py index 746dcf1..d970176 100644 --- a/backend/handlers/resources.py +++ b/backend/handlers/resources.py @@ -21,6 +21,7 @@ from core.settings import get_settings from models.base import OperationResponse, TimedOperationResponse from models.requests import ( + CleanupBrokenActionsRequest, CleanupImagesRequest, CreateModelRequest, DeleteModelRequest, @@ -36,10 +37,13 @@ ) from models.responses import ( CachedImagesResponse, + CleanupBrokenActionsResponse, CleanupImagesResponse, ClearMemoryCacheResponse, ImageOptimizationConfigResponse, ImageOptimizationStatsResponse, + ImagePersistenceHealthData, + ImagePersistenceHealthResponse, ImageStatsResponse, ReadImageFileResponse, UpdateImageOptimizationConfigResponse, @@ -373,6 +377,231 @@ async def read_image_file(body: ReadImageFileRequest) -> ReadImageFileResponse: return ReadImageFileResponse(success=False, error=str(e)) +@api_handler( + body=None, method="GET", path="/image/persistence-health", tags=["image"] +) +async def check_image_persistence_health() -> ImagePersistenceHealthResponse: + """ + Check health of image persistence system + + Analyzes all actions with screenshots to determine how many have missing + image files on disk. Provides statistics for diagnostics. + + Returns: + Health check results with statistics + """ + try: + db = get_db() + image_manager = get_image_manager() + + # Get all actions with screenshots (limit to 1000 for performance) + actions = await db.actions.get_all_actions_with_screenshots(limit=1000) + + total_actions = len(actions) + actions_all_ok = 0 + actions_partial_missing = 0 + actions_all_missing = 0 + total_references = 0 + images_found = 0 + images_missing = 0 + actions_with_issues = [] + + for action in actions: + screenshots = action.get("screenshots", []) + if not screenshots: + continue + + total_references += len(screenshots) + missing_hashes = [] + + # Check each screenshot + for img_hash in screenshots: + thumbnail_path = image_manager.thumbnails_dir / f"{img_hash}.jpg" + if thumbnail_path.exists(): + images_found += 1 + else: + images_missing += 1 + missing_hashes.append(img_hash) + + # Classify action based on missing images + if not missing_hashes: + actions_all_ok += 1 + elif len(missing_hashes) == len(screenshots): + actions_all_missing += 1 + # Sample first 10 actions with all images missing + if len(actions_with_issues) < 10: + actions_with_issues.append({ + "id": action["id"], + "created_at": action["created_at"], + "total_screenshots": len(screenshots), + "missing_screenshots": len(missing_hashes), + "status": "all_missing", + }) + else: + actions_partial_missing += 1 + # Sample first 10 actions with partial missing + if len(actions_with_issues) < 10: + actions_with_issues.append({ + "id": action["id"], + "created_at": action["created_at"], + "total_screenshots": len(screenshots), + "missing_screenshots": len(missing_hashes), + "status": "partial_missing", + }) + + # Calculate missing rate + missing_rate = ( + (images_missing / total_references * 100) if total_references > 0 else 0.0 + ) + + # Get cache stats + cache_stats = image_manager.get_stats() + + data = ImagePersistenceHealthData( + total_actions=total_actions, + actions_with_screenshots=total_actions, + actions_all_images_ok=actions_all_ok, + actions_partial_missing=actions_partial_missing, + actions_all_missing=actions_all_missing, + total_image_references=total_references, + images_found=images_found, + images_missing=images_missing, + missing_rate_percent=round(missing_rate, 2), + memory_cache_current_size=cache_stats.get("cache_count", 0), + memory_cache_max_size=cache_stats.get("cache_limit", 0), + memory_ttl_seconds=cache_stats.get("memory_ttl", 0), + actions_with_issues=actions_with_issues, + ) + + logger.info( + f"Image persistence health check: {images_missing}/{total_references} images missing " + f"({missing_rate:.2f}%), {actions_all_missing} actions with all images missing" + ) + + return ImagePersistenceHealthResponse( + success=True, + message=f"Health check completed: {missing_rate:.2f}% images missing", + data=data, + ) + + except Exception as e: + logger.error(f"Failed to check image persistence health: {e}", exc_info=True) + return ImagePersistenceHealthResponse(success=False, error=str(e)) + + +@api_handler( + body=CleanupBrokenActionsRequest, + method="POST", + path="/image/cleanup-broken-actions", + tags=["image"], +) +async def cleanup_broken_action_images( + body: CleanupBrokenActionsRequest, +) -> CleanupBrokenActionsResponse: + """ + Clean up actions with missing image references + + Supports three strategies: + - delete_actions: Soft-delete actions with all images missing + - remove_references: Clear image references, keep action metadata + - dry_run: Report what would be cleaned without making changes + + Args: + body: Cleanup request with strategy and optional action IDs + + Returns: + Cleanup results with statistics + """ + try: + db = get_db() + image_manager = get_image_manager() + + # Get actions to process + if body.action_ids: + # Process specific actions + actions = [] + for action_id in body.action_ids: + action = await db.actions.get(action_id) + if action: + actions.append(action) + else: + # Process all actions with screenshots + actions = await db.actions.get_all_actions_with_screenshots(limit=10000) + + actions_processed = 0 + actions_deleted = 0 + references_removed = 0 + + for action in actions: + screenshots = action.get("screenshots", []) + if not screenshots: + continue + + # Check which images are missing + missing_hashes = [] + for img_hash in screenshots: + thumbnail_path = image_manager.thumbnails_dir / f"{img_hash}.jpg" + if not thumbnail_path.exists(): + missing_hashes.append(img_hash) + + if not missing_hashes: + continue # All images present + + actions_processed += 1 + all_missing = len(missing_hashes) == len(screenshots) + + if body.strategy == "delete_actions" and all_missing: + # Only delete if all images are missing + logger.info( + f"Deleted action {action['id']} with {len(screenshots)} missing images" + ) + await db.actions.delete(action["id"]) + actions_deleted += 1 + + elif body.strategy == "remove_references": + # Remove screenshot references + logger.info( + f"Removed screenshot references from action {action['id']}" + ) + removed = await db.actions.remove_screenshots(action["id"]) + references_removed += removed + + elif body.strategy == "dry_run": + # Dry run - just log what would be done + if all_missing: + logger.info( + f"[DRY RUN] Would delete action {action['id']} " + f"with {len(screenshots)} missing images" + ) + else: + logger.info( + f"[DRY RUN] Would remove {len(missing_hashes)} " + f"screenshot references from action {action['id']}" + ) + + message = f"Cleanup completed ({body.strategy}): " + if body.strategy == "delete_actions": + message += f"deleted {actions_deleted} actions" + elif body.strategy == "remove_references": + message += f"removed {references_removed} references" + else: # dry_run + message += f"would process {actions_processed} actions" + + logger.info(message) + + return CleanupBrokenActionsResponse( + success=True, + message=message, + actions_processed=actions_processed, + actions_deleted=actions_deleted, + references_removed=references_removed, + ) + + except Exception as e: + logger.error(f"Failed to cleanup broken actions: {e}", exc_info=True) + return CleanupBrokenActionsResponse(success=False, error=str(e)) + + # ============================================================================ # Model Management # ============================================================================ diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py new file mode 100644 index 0000000..947e87b --- /dev/null +++ b/backend/migrations/__init__.py @@ -0,0 +1,12 @@ +""" +Database migrations module - Version-based migration system + +This module provides a versioned migration system that: +1. Tracks applied migrations in schema_migrations table +2. Runs migrations in order by version number +3. Supports both SQL-based and Python-based migrations +""" + +from .runner import MigrationRunner + +__all__ = ["MigrationRunner"] diff --git a/backend/migrations/base.py b/backend/migrations/base.py new file mode 100644 index 0000000..00a8e6b --- /dev/null +++ b/backend/migrations/base.py @@ -0,0 +1,51 @@ +""" +Base migration class + +All migrations should inherit from this base class +""" + +import sqlite3 +from abc import ABC, abstractmethod +from typing import Optional + + +class BaseMigration(ABC): + """ + Base class for database migrations + + Each migration must: + 1. Define a unique version string (e.g., "0001", "0002") + 2. Provide a description + 3. Implement the up() method + 4. Optionally implement the down() method for rollbacks + """ + + # Must be overridden in subclass + version: str = "" + description: str = "" + + @abstractmethod + def up(self, cursor: sqlite3.Cursor) -> None: + """ + Execute migration (upgrade database) + + Args: + cursor: SQLite cursor for executing SQL commands + """ + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback migration (downgrade database) + + Args: + cursor: SQLite cursor for executing SQL commands + + Note: + This is optional. Many migrations cannot be safely rolled back. + If not implemented, rollback will be skipped with a warning. + """ + pass + + def __repr__(self) -> str: + return f"" diff --git a/backend/migrations/runner.py b/backend/migrations/runner.py new file mode 100644 index 0000000..04681d9 --- /dev/null +++ b/backend/migrations/runner.py @@ -0,0 +1,265 @@ +""" +Migration runner - Manages database schema versioning + +Responsibilities: +1. Create schema_migrations table if not exists +2. Discover all migration files +3. Determine which migrations need to run +4. Execute migrations in order +5. Record successful migrations +""" + +import importlib +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Type + +from core.logger import get_logger + +from .base import BaseMigration + +logger = get_logger(__name__) + + +class MigrationRunner: + """ + Database migration runner with version tracking + + Usage: + runner = MigrationRunner(db_path) + runner.run_migrations() + """ + + SCHEMA_MIGRATIONS_TABLE = """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + description TEXT NOT NULL, + applied_at TEXT NOT NULL + ) + """ + + def __init__(self, db_path: Path): + """ + Initialize migration runner + + Args: + db_path: Path to SQLite database + """ + self.db_path = db_path + self.migrations: Dict[str, Type[BaseMigration]] = {} + + def _ensure_schema_migrations_table(self, cursor: sqlite3.Cursor) -> None: + """ + Create schema_migrations table if it doesn't exist + + Args: + cursor: Database cursor + """ + cursor.execute(self.SCHEMA_MIGRATIONS_TABLE) + logger.debug("✓ schema_migrations table ready") + + def _get_applied_versions(self, cursor: sqlite3.Cursor) -> set: + """ + Get set of already-applied migration versions + + Args: + cursor: Database cursor + + Returns: + Set of version strings + """ + cursor.execute("SELECT version FROM schema_migrations") + rows = cursor.fetchall() + return {row[0] for row in rows} + + def _discover_migrations(self) -> List[Type[BaseMigration]]: + """ + Discover all migration classes from versions directory + + Returns: + List of migration classes sorted by version + """ + migrations_dir = Path(__file__).parent / "versions" + + if not migrations_dir.exists(): + logger.warning(f"Migrations directory not found: {migrations_dir}") + return [] + + discovered = [] + + # Import all Python files in versions directory + for migration_file in sorted(migrations_dir.glob("*.py")): + if migration_file.name.startswith("_"): + continue # Skip __init__.py and other private files + + module_name = f"migrations.versions.{migration_file.stem}" + + try: + module = importlib.import_module(module_name) + + # Find migration class in module + for attr_name in dir(module): + attr = getattr(module, attr_name) + + # Check if it's a migration class + if ( + isinstance(attr, type) + and issubclass(attr, BaseMigration) + and attr is not BaseMigration + ): + discovered.append(attr) + logger.debug(f"Discovered migration: {attr.version} - {attr.description}") + + except Exception as e: + logger.error(f"Failed to load migration {migration_file}: {e}", exc_info=True) + + # Sort by version + discovered.sort(key=lambda m: m.version) + + return discovered + + def _record_migration( + self, cursor: sqlite3.Cursor, migration: BaseMigration + ) -> None: + """ + Record successful migration in schema_migrations table + + Args: + cursor: Database cursor + migration: Migration instance + """ + cursor.execute( + """ + INSERT INTO schema_migrations (version, description, applied_at) + VALUES (?, ?, ?) + """, + ( + migration.version, + migration.description, + datetime.now().isoformat(), + ), + ) + logger.info(f"✓ Recorded migration: {migration.version}") + + def run_migrations(self) -> int: + """ + Run all pending migrations + + Returns: + Number of migrations executed + """ + try: + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + # Ensure tracking table exists + self._ensure_schema_migrations_table(cursor) + conn.commit() + + # Get applied versions + applied_versions = self._get_applied_versions(cursor) + logger.debug(f"Applied migrations: {applied_versions}") + + # Discover all migrations + all_migrations = self._discover_migrations() + + if not all_migrations: + logger.info("No migrations found") + conn.close() + return 0 + + # Filter to pending migrations + pending_migrations = [ + m for m in all_migrations if m.version not in applied_versions + ] + + if not pending_migrations: + logger.info("✓ All migrations up to date") + conn.close() + return 0 + + logger.info(f"Found {len(pending_migrations)} pending migration(s)") + + # Execute each pending migration + executed_count = 0 + for migration_class in pending_migrations: + migration = migration_class() + + logger.info(f"Running migration {migration.version}: {migration.description}") + + try: + # Execute migration + migration.up(cursor) + + # Record success + self._record_migration(cursor, migration) + conn.commit() + + executed_count += 1 + logger.info(f"✓ Migration {migration.version} completed successfully") + + except Exception as e: + logger.error( + f"✗ Migration {migration.version} failed: {e}", + exc_info=True, + ) + conn.rollback() + raise + + conn.close() + + logger.info(f"✓ Successfully executed {executed_count} migration(s)") + return executed_count + + except Exception as e: + logger.error(f"Migration runner failed: {e}", exc_info=True) + raise + + def get_migration_status(self) -> Dict[str, Any]: + """ + Get current migration status + + Returns: + Dictionary with migration status information + """ + try: + conn = sqlite3.connect(str(self.db_path)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # Ensure tracking table exists + self._ensure_schema_migrations_table(cursor) + + # Get applied migrations + cursor.execute( + """ + SELECT version, description, applied_at + FROM schema_migrations + ORDER BY version + """ + ) + applied = [dict(row) for row in cursor.fetchall()] + + # Discover all migrations + all_migrations = self._discover_migrations() + + applied_versions = {m["version"] for m in applied} + pending = [ + {"version": m.version, "description": m.description} + for m in all_migrations + if m.version not in applied_versions + ] + + conn.close() + + return { + "applied_count": len(applied), + "pending_count": len(pending), + "applied": applied, + "pending": pending, + } + + except Exception as e: + logger.error(f"Failed to get migration status: {e}", exc_info=True) + raise diff --git a/backend/migrations/versions/0001_initial_schema.py b/backend/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..c1c730c --- /dev/null +++ b/backend/migrations/versions/0001_initial_schema.py @@ -0,0 +1,33 @@ +""" +Migration 0001: Initial database schema + +Creates all base tables and indexes for the iDO application +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0001" + description = "Initial database schema with all base tables" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Create all initial tables and indexes""" + from core.sqls import schema + + # Create all tables + for table_sql in schema.ALL_TABLES: + cursor.execute(table_sql) + + # Create all indexes + for index_sql in schema.ALL_INDEXES: + cursor.execute(index_sql) + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported for initial schema + Would require dropping all tables + """ + pass diff --git a/backend/migrations/versions/0002_add_knowledge_actions_columns.py b/backend/migrations/versions/0002_add_knowledge_actions_columns.py new file mode 100644 index 0000000..27a8f45 --- /dev/null +++ b/backend/migrations/versions/0002_add_knowledge_actions_columns.py @@ -0,0 +1,53 @@ +""" +Migration 0002: Add knowledge extraction columns to actions table + +Adds columns to support knowledge extraction feature +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0002" + description = "Add knowledge extraction columns to actions and knowledge tables" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add columns for knowledge extraction feature""" + + # List of column additions (with error handling for already-exists) + columns_to_add = [ + ( + "actions", + "extract_knowledge", + "ALTER TABLE actions ADD COLUMN extract_knowledge BOOLEAN DEFAULT 0", + ), + ( + "actions", + "knowledge_extracted", + "ALTER TABLE actions ADD COLUMN knowledge_extracted BOOLEAN DEFAULT 0", + ), + ( + "knowledge", + "source_action_id", + "ALTER TABLE knowledge ADD COLUMN source_action_id TEXT", + ), + ] + + for table, column, sql in columns_to_add: + try: + cursor.execute(sql) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN in older versions) + """ + pass diff --git a/backend/migrations/versions/0003_add_pomodoro_feature.py b/backend/migrations/versions/0003_add_pomodoro_feature.py new file mode 100644 index 0000000..97534b2 --- /dev/null +++ b/backend/migrations/versions/0003_add_pomodoro_feature.py @@ -0,0 +1,125 @@ +""" +Migration 0003: Add Pomodoro feature + +Adds columns to existing tables for Pomodoro session tracking: +- pomodoro_session_id to raw_records, actions, events, activities +- user_intent and pomodoro_status to activities + +Also creates indexes for efficient querying +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0003" + description = "Add Pomodoro feature columns and indexes" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add Pomodoro-related columns and indexes""" + + # Column additions + columns_to_add = [ + ( + "raw_records", + "pomodoro_session_id", + "ALTER TABLE raw_records ADD COLUMN pomodoro_session_id TEXT", + ), + ( + "actions", + "pomodoro_session_id", + "ALTER TABLE actions ADD COLUMN pomodoro_session_id TEXT", + ), + ( + "events", + "pomodoro_session_id", + "ALTER TABLE events ADD COLUMN pomodoro_session_id TEXT", + ), + ( + "activities", + "pomodoro_session_id", + "ALTER TABLE activities ADD COLUMN pomodoro_session_id TEXT", + ), + ( + "activities", + "user_intent", + "ALTER TABLE activities ADD COLUMN user_intent TEXT", + ), + ( + "activities", + "pomodoro_status", + "ALTER TABLE activities ADD COLUMN pomodoro_status TEXT", + ), + ] + + for table, column, sql in columns_to_add: + try: + cursor.execute(sql) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # Index creation + indexes_to_create = [ + ( + "idx_raw_records_pomodoro_session", + """ + CREATE INDEX IF NOT EXISTS idx_raw_records_pomodoro_session + ON raw_records(pomodoro_session_id) + """, + ), + ( + "idx_actions_pomodoro_session", + """ + CREATE INDEX IF NOT EXISTS idx_actions_pomodoro_session + ON actions(pomodoro_session_id) + """, + ), + ( + "idx_events_pomodoro_session", + """ + CREATE INDEX IF NOT EXISTS idx_events_pomodoro_session + ON events(pomodoro_session_id) + """, + ), + ( + "idx_activities_pomodoro_session", + """ + CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_session + ON activities(pomodoro_session_id) + """, + ), + ( + "idx_activities_pomodoro_status", + """ + CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_status + ON activities(pomodoro_status) + """, + ), + ] + + for index_name, sql in indexes_to_create: + try: + cursor.execute(sql) + except Exception as e: + # Index creation failures are usually safe to ignore + # (index might already exist) + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN easily) + + To rollback, you would need to: + 1. Create new tables without the columns + 2. Copy data + 3. Drop old tables + 4. Rename new tables + """ + pass diff --git a/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py b/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py new file mode 100644 index 0000000..5d7162c --- /dev/null +++ b/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py @@ -0,0 +1,107 @@ +""" +Migration 0004: Add Pomodoro-TODO association and Activity ratings + +Changes: +1. Add associated_todo_id column to pomodoro_sessions table +2. Create activity_ratings table for multi-dimensional activity ratings +3. Add indexes for efficient querying +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0004" + description = "Add Pomodoro-TODO association and Activity ratings" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add Pomodoro-TODO association and activity ratings tables""" + + # 1. Add associated_todo_id column to pomodoro_sessions + try: + cursor.execute( + """ + ALTER TABLE pomodoro_sessions + ADD COLUMN associated_todo_id TEXT + """ + ) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # 2. Create index for associated_todo_id + try: + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_todo + ON pomodoro_sessions(associated_todo_id) + """ + ) + except Exception: + # Index creation failures are usually safe to ignore + pass + + # 3. Create activity_ratings table + try: + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS activity_ratings ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + dimension TEXT NOT NULL, + rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5), + note TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + UNIQUE(activity_id, dimension) + ) + """ + ) + except Exception as e: + # Table might already exist + pass + + # 4. Create indexes for activity_ratings + indexes_to_create = [ + ( + "idx_activity_ratings_activity", + """ + CREATE INDEX IF NOT EXISTS idx_activity_ratings_activity + ON activity_ratings(activity_id) + """ + ), + ( + "idx_activity_ratings_dimension", + """ + CREATE INDEX IF NOT EXISTS idx_activity_ratings_dimension + ON activity_ratings(dimension) + """ + ), + ] + + for index_name, sql in indexes_to_create: + try: + cursor.execute(sql) + except Exception: + # Index creation failures are usually safe to ignore + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN easily) + + To rollback, you would need to: + 1. Drop activity_ratings table + 2. Create new pomodoro_sessions table without associated_todo_id + 3. Copy data + 4. Drop old pomodoro_sessions table + 5. Rename new table + """ + pass diff --git a/backend/migrations/versions/0005_add_pomodoro_rounds.py b/backend/migrations/versions/0005_add_pomodoro_rounds.py new file mode 100644 index 0000000..6923782 --- /dev/null +++ b/backend/migrations/versions/0005_add_pomodoro_rounds.py @@ -0,0 +1,98 @@ +""" +Migration 0005: Add Pomodoro rounds and phase management + +Adds support for multi-round Pomodoro sessions with work/break phases: +- work_duration_minutes: Duration of work phase (default 25) +- break_duration_minutes: Duration of break phase (default 5) +- total_rounds: Total number of work rounds to complete (default 4) +- current_round: Current round number (1-based) +- current_phase: Current phase (work/break/completed) +- phase_start_time: When current phase started +- completed_rounds: Number of completed work rounds +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0005" + description = "Add Pomodoro rounds and phase management" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add Pomodoro rounds-related columns""" + + # Column additions for round management + columns_to_add = [ + ( + "pomodoro_sessions", + "work_duration_minutes", + "ALTER TABLE pomodoro_sessions ADD COLUMN work_duration_minutes INTEGER DEFAULT 25", + ), + ( + "pomodoro_sessions", + "break_duration_minutes", + "ALTER TABLE pomodoro_sessions ADD COLUMN break_duration_minutes INTEGER DEFAULT 5", + ), + ( + "pomodoro_sessions", + "total_rounds", + "ALTER TABLE pomodoro_sessions ADD COLUMN total_rounds INTEGER DEFAULT 4", + ), + ( + "pomodoro_sessions", + "current_round", + "ALTER TABLE pomodoro_sessions ADD COLUMN current_round INTEGER DEFAULT 1", + ), + ( + "pomodoro_sessions", + "current_phase", + "ALTER TABLE pomodoro_sessions ADD COLUMN current_phase TEXT DEFAULT 'work'", + ), + ( + "pomodoro_sessions", + "phase_start_time", + "ALTER TABLE pomodoro_sessions ADD COLUMN phase_start_time TEXT", + ), + ( + "pomodoro_sessions", + "completed_rounds", + "ALTER TABLE pomodoro_sessions ADD COLUMN completed_rounds INTEGER DEFAULT 0", + ), + ] + + for table, column, sql in columns_to_add: + try: + cursor.execute(sql) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # Add index for current_phase for efficient querying + try: + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_phase + ON pomodoro_sessions(current_phase) + """ + ) + except Exception: + # Index creation failures are usually safe to ignore + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN easily) + + To rollback, you would need to: + 1. Create new pomodoro_sessions table without the new columns + 2. Copy data + 3. Drop old table + 4. Rename new table + """ + pass diff --git a/backend/migrations/versions/__init__.py b/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..54f45bb --- /dev/null +++ b/backend/migrations/versions/__init__.py @@ -0,0 +1,11 @@ +""" +Migration versions directory + +Each migration file should be named: XXXX_description.py +Where XXXX is a 4-digit version number (e.g., 0001, 0002, etc.) + +Example: + 0001_initial_schema.py + 0002_add_three_layer_architecture.py + 0003_add_pomodoro_feature.py +""" diff --git a/backend/models/requests.py b/backend/models/requests.py index 78f666c..ab88245 100644 --- a/backend/models/requests.py +++ b/backend/models/requests.py @@ -4,7 +4,7 @@ """ from datetime import datetime -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import Field @@ -620,6 +620,17 @@ class ReadImageFileRequest(BaseModel): file_path: str +class CleanupBrokenActionsRequest(BaseModel): + """Request parameters for cleaning up actions with missing images. + + @property strategy - Cleanup strategy: delete_actions, remove_references, or dry_run. + @property actionIds - Optional list of specific action IDs to process. + """ + + strategy: Literal["delete_actions", "remove_references", "dry_run"] + action_ids: Optional[List[str]] = None + + # ============================================================================ # Three-Layer Architecture Request Models (Activities → Events → Actions) # ============================================================================ diff --git a/backend/models/responses.py b/backend/models/responses.py index ca232c2..5d8006e 100644 --- a/backend/models/responses.py +++ b/backend/models/responses.py @@ -3,7 +3,7 @@ Provides strongly typed response models for better type safety and auto-generation """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional from models.base import BaseModel, OperationResponse, TimedOperationResponse @@ -197,6 +197,39 @@ class ImageOptimizationStatsResponse(OperationResponse): config: Optional[Dict[str, Any]] = None +class ImagePersistenceHealthData(BaseModel): + """Data model for image persistence health check""" + + total_actions: int + actions_with_screenshots: int + actions_all_images_ok: int + actions_partial_missing: int + actions_all_missing: int + total_image_references: int + images_found: int + images_missing: int + missing_rate_percent: float + memory_cache_current_size: int + memory_cache_max_size: int + memory_ttl_seconds: int + actions_with_issues: List[Dict[str, Any]] + + +class ImagePersistenceHealthResponse(OperationResponse): + """Response containing image persistence health check results""" + + data: Optional[ImagePersistenceHealthData] = None + + +class CleanupBrokenActionsResponse(OperationResponse): + """Response after cleaning up broken action images""" + + actions_processed: int = 0 + actions_deleted: int = 0 + references_removed: int = 0 + images_removed: int = 0 + + class UpdateImageOptimizationConfigResponse(OperationResponse): """Response after updating image optimization configuration""" @@ -343,3 +376,53 @@ class CompleteInitialSetupResponse(TimedOperationResponse): pass +# Pomodoro Feature Response Models +class PomodoroSessionData(BaseModel): + """Pomodoro session data with rounds support""" + + session_id: str + user_intent: str + start_time: str + elapsed_minutes: int + planned_duration_minutes: int + associated_todo_id: Optional[str] = None + associated_todo_title: Optional[str] = None + # Rounds configuration + work_duration_minutes: int = 25 + break_duration_minutes: int = 5 + total_rounds: int = 4 + current_round: int = 1 + current_phase: Literal["work", "break", "completed"] = "work" + phase_start_time: Optional[str] = None + completed_rounds: int = 0 + # Calculated fields for frontend + remaining_phase_seconds: Optional[int] = None + + +class StartPomodoroResponse(TimedOperationResponse): + """Response after starting a Pomodoro session""" + + data: Optional[PomodoroSessionData] = None + + +class EndPomodoroData(BaseModel): + """End Pomodoro session result data""" + + session_id: str + processing_job_id: Optional[str] = None + raw_records_count: int = 0 + message: str = "" + + +class EndPomodoroResponse(TimedOperationResponse): + """Response after ending a Pomodoro session""" + + data: Optional[EndPomodoroData] = None + + +class GetPomodoroStatusResponse(TimedOperationResponse): + """Response for getting current Pomodoro session status""" + + data: Optional[PomodoroSessionData] = None + + diff --git a/backend/perception/image_manager.py b/backend/perception/image_manager.py index b4e9495..b68e4ac 100644 --- a/backend/perception/image_manager.py +++ b/backend/perception/image_manager.py @@ -69,6 +69,15 @@ def __init__( # Image metadata: hash -> (timestamp, is_persisted) self._image_metadata: dict[str, Tuple[datetime, bool]] = {} + # Persistence statistics tracking + self.persistence_stats = { + "total_persist_attempts": 0, + "successful_persists": 0, + "failed_persists": 0, + "cache_misses": 0, + "already_persisted": 0, + } + self._ensure_directories() logger.debug( @@ -79,6 +88,14 @@ def __init__( f"quality={thumbnail_quality}, base_dir={self.base_dir}" ) + # Validation: Warn if TTL seems too low for reliable persistence + if self.memory_ttl < 120: + logger.warning( + f"Memory TTL ({self.memory_ttl}s) is low and may cause image persistence failures. " + f"Recommended: ≥180s for reliable persistence. " + f"Increase 'image.memory_ttl_multiplier' in config.toml to fix." + ) + def _select_thumbnail_size(self, img: Image.Image) -> Tuple[int, int]: """Choose target size based on orientation and resolution""" width, height = img.size @@ -288,15 +305,19 @@ def persist_image(self, img_hash: str) -> bool: True if persisted successfully, False otherwise """ try: + self.persistence_stats["total_persist_attempts"] += 1 + # Check if already persisted metadata = self._image_metadata.get(img_hash) if metadata and metadata[1]: # is_persisted = True + self.persistence_stats["already_persisted"] += 1 logger.debug(f"Image already persisted: {img_hash[:8]}...") return True # Check if exists on disk already thumbnail_path = self.thumbnails_dir / f"{img_hash}.jpg" if thumbnail_path.exists(): + self.persistence_stats["already_persisted"] += 1 # Update metadata self._image_metadata[img_hash] = (datetime.now(), True) logger.debug(f"Image already on disk: {img_hash[:8]}...") @@ -305,6 +326,8 @@ def persist_image(self, img_hash: str) -> bool: # Get from memory cache img_data = self.get_from_cache(img_hash) if not img_data: + self.persistence_stats["failed_persists"] += 1 + self.persistence_stats["cache_misses"] += 1 logger.warning( f"Image not found in memory cache (likely evicted): {img_hash[:8]}... " f"Cannot persist to disk." @@ -317,11 +340,13 @@ def persist_image(self, img_hash: str) -> bool: # Update metadata self._image_metadata[img_hash] = (datetime.now(), True) + self.persistence_stats["successful_persists"] += 1 logger.debug(f"Persisted image to disk: {img_hash[:8]}...") return True except Exception as e: + self.persistence_stats["failed_persists"] += 1 logger.error(f"Failed to persist image {img_hash[:8]}: {e}") return False @@ -557,6 +582,14 @@ def get_stats(self) -> Dict[str, Any]: else: memory_only_count += 1 + # Calculate persistence success rate + total_attempts = self.persistence_stats["total_persist_attempts"] + success_rate = ( + self.persistence_stats["successful_persists"] / total_attempts + if total_attempts > 0 + else 1.0 + ) + return { "memory_cache_count": memory_count, "memory_cache_limit": self.memory_cache_size, @@ -572,6 +605,9 @@ def get_stats(self) -> Dict[str, Any]: "memory_ttl_seconds": self.memory_ttl, "memory_only_images": memory_only_count, "persisted_images_in_cache": persisted_count, + # Persistence stats + "persistence_success_rate": round(success_rate, 4), + "persistence_stats": self.persistence_stats, } except Exception as e: diff --git a/backend/perception/manager.py b/backend/perception/manager.py index d79a2bf..5f61ab2 100644 --- a/backend/perception/manager.py +++ b/backend/perception/manager.py @@ -6,6 +6,7 @@ """ import asyncio +import time from datetime import datetime from typing import Any, Callable, Dict, Optional @@ -89,6 +90,12 @@ def __init__( self.keyboard_enabled = True self.mouse_enabled = True + # Pomodoro mode state + self.pomodoro_session_id: Optional[str] = None + + # Event loop reference (set when start() is called) + self._event_loop: Optional[asyncio.AbstractEventLoop] = None + def _on_screen_lock(self) -> None: """Screen lock/system sleep callback""" if not self.is_running: @@ -148,7 +155,11 @@ def _on_keyboard_event(self, record: RawRecord) -> None: return try: - # Record all keyboard events for subsequent processing to preserve usage context + # Tag with Pomodoro session ID if active (for future use) + if self.pomodoro_session_id: + record.data['pomodoro_session_id'] = self.pomodoro_session_id + + # Always add to memory for real-time viewing and processing self.storage.add_record(record) self.event_buffer.add(record) @@ -170,6 +181,11 @@ def _on_mouse_event(self, record: RawRecord) -> None: try: # Only record important mouse events if self.mouse_capture.is_important_event(record.data): + # Tag with Pomodoro session ID if active (for future use) + if self.pomodoro_session_id: + record.data['pomodoro_session_id'] = self.pomodoro_session_id + + # Always add to memory for real-time viewing and processing self.storage.add_record(record) self.event_buffer.add(record) @@ -201,6 +217,11 @@ def _on_screenshot_event(self, record: RawRecord) -> None: try: if record: # Screenshot may be None (duplicate screenshots) + # Tag with Pomodoro session ID if active (for future use) + if self.pomodoro_session_id: + record.data['pomodoro_session_id'] = self.pomodoro_session_id + + # Always add to memory for real-time viewing and processing self.storage.add_record(record) self.event_buffer.add(record) @@ -226,6 +247,9 @@ async def start(self) -> None: self.is_running = True self.is_paused = False + # Store event loop reference for sync callbacks + self._event_loop = asyncio.get_running_loop() + # Load perception settings from core.settings import get_settings @@ -309,6 +333,9 @@ async def stop(self) -> None: self.is_running = False self.is_paused = False + # Clear event loop reference + self._event_loop = None + # Stop screen state monitor self.screen_state_monitor.stop() @@ -345,19 +372,29 @@ async def stop(self) -> None: async def _screenshot_loop(self) -> None: """Screenshot loop task""" try: - loop = asyncio.get_event_loop() + iteration = 0 + while self.is_running: - # Execute synchronous screenshot operation in thread pool to avoid blocking event loop - await loop.run_in_executor( - None, - self.screenshot_capture.capture_with_interval, - self.capture_interval, - ) - await asyncio.sleep(0.1) # Brief sleep to avoid excessive CPU usage + iteration += 1 + loop_start = time.time() + + # Directly call capture() without interval checking + # The loop itself controls the timing + try: + self.screenshot_capture.capture() + except Exception as e: + logger.error(f"Screenshot capture failed: {e}", exc_info=True) + + elapsed = time.time() - loop_start + + # Sleep for the interval, accounting for capture time + sleep_time = max(0.1, self.capture_interval - elapsed) + await asyncio.sleep(sleep_time) + except asyncio.CancelledError: logger.debug("Screenshot loop task cancelled") except Exception as e: - logger.error(f"Screenshot loop task failed: {e}") + logger.error(f"Screenshot loop task failed: {e}", exc_info=True) async def _cleanup_loop(self) -> None: """Cleanup loop task""" @@ -523,3 +560,40 @@ def update_perception_settings( logger.debug( f"Perception settings updated: keyboard={self.keyboard_enabled}, mouse={self.mouse_enabled}" ) + + def set_pomodoro_session(self, session_id: str) -> None: + """ + Set Pomodoro session ID for tagging captured records + + Args: + session_id: Pomodoro session identifier + """ + self.pomodoro_session_id = session_id + logger.debug(f"✓ Pomodoro session set: {session_id}") + + def clear_pomodoro_session(self) -> None: + """Clear Pomodoro session ID (exit Pomodoro mode)""" + session_id = self.pomodoro_session_id + self.pomodoro_session_id = None + logger.debug(f"✓ Pomodoro session cleared: {session_id}") + + async def _persist_raw_record(self, record: RawRecord) -> None: + """ + Persist raw record to database (Pomodoro mode) + + Args: + record: RawRecord to persist + """ + try: + import json + from core.db import get_db + + db = get_db() + await db.raw_records.save( + timestamp=record.timestamp.isoformat(), + record_type=record.type.value, # Convert enum to string + data=json.dumps(record.data), + pomodoro_session_id=record.data.get('pomodoro_session_id'), + ) + except Exception as e: + logger.error(f"Failed to persist raw record: {e}", exc_info=True) diff --git a/backend/perception/screenshot_capture.py b/backend/perception/screenshot_capture.py index 9a90aa9..0f527aa 100644 --- a/backend/perception/screenshot_capture.py +++ b/backend/perception/screenshot_capture.py @@ -323,7 +323,9 @@ def capture_with_interval(self, interval: float = 1.0): return current_time = time.time() - if current_time - self._last_screenshot_time >= interval: + time_since_last = current_time - self._last_screenshot_time + + if time_since_last >= interval: self.capture() self._last_screenshot_time = current_time diff --git a/src/components/activity/ActionCard.tsx b/src/components/activity/ActionCard.tsx index 564a821..09c93cb 100644 --- a/src/components/activity/ActionCard.tsx +++ b/src/components/activity/ActionCard.tsx @@ -90,7 +90,7 @@ export function ActionCard({ action, isExpanded = false, onToggleExpand }: Actio {/* Title - takes up remaining space and wraps */}
-
{action.title}
+
{action.title}
{/* Timestamp and Screenshots button - takes up actual space */} @@ -164,8 +164,14 @@ export function ActionCard({ action, isExpanded = false, onToggleExpand }: Actio
) : ( -
+
+ Image Lost + {import.meta.env.DEV && ( + + {screenshot.substring(0, 8)}... + + )}
)}
diff --git a/src/components/pomodoro/CircularProgress.tsx b/src/components/pomodoro/CircularProgress.tsx new file mode 100644 index 0000000..fe1086c --- /dev/null +++ b/src/components/pomodoro/CircularProgress.tsx @@ -0,0 +1,53 @@ +interface CircularProgressProps { + progress: number // 0-100 + size?: number + strokeWidth?: number + className?: string + children?: React.ReactNode + color?: string +} + +export function CircularProgress({ + progress, + size = 280, + strokeWidth = 12, + className = '', + children, + color = 'hsl(var(--primary))' +}: CircularProgressProps) { + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const offset = circumference - (progress / 100) * circumference + + return ( +
+ + {/* Background circle */} + + {/* Progress circle */} + + + {/* Center content */} +
{children}
+
+ ) +} diff --git a/src/components/pomodoro/CircularRoundProgress.tsx b/src/components/pomodoro/CircularRoundProgress.tsx new file mode 100644 index 0000000..a7c8577 --- /dev/null +++ b/src/components/pomodoro/CircularRoundProgress.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CheckCircle2, Clock, Coffee } from 'lucide-react' + +import { CircularProgress } from '@/components/pomodoro/CircularProgress' +import { usePomodoroStore } from '@/lib/stores/pomodoro' +import { cn } from '@/lib/utils' + +/** + * Dual-ring circular progress display for Pomodoro sessions + * - Outer ring: Overall session progress (completed rounds / total rounds) + * - Inner ring: Current phase progress (elapsed time / phase duration) + * - Center: Round info and phase icon + */ +export function CircularRoundProgress() { + const { t } = useTranslation() + const { session } = usePomodoroStore() + const [currentTime, setCurrentTime] = useState(Date.now()) + + // Update current time every second for real-time progress calculation + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(Date.now()) + }, 1000) + + return () => clearInterval(interval) + }, []) + + if (!session) { + return null + } + + const currentRound = session.currentRound || 1 + const totalRounds = session.totalRounds || 2 + const completedRounds = session.completedRounds || 0 + const currentPhase = session.currentPhase || 'work' + + const isWorkPhase = currentPhase === 'work' + const isBreakPhase = currentPhase === 'break' + const isCompleted = currentPhase === 'completed' + + // Overall session progress (0-100) + const overallProgress = (completedRounds / totalRounds) * 100 + + // Calculate current phase progress (0-100) + let phaseProgress = 0 + if (!isCompleted) { + const phaseDurationSeconds = isWorkPhase + ? (session.workDurationMinutes || 25) * 60 + : (session.breakDurationMinutes || 5) * 60 + + const phaseStartTime = session.phaseStartTime ? new Date(session.phaseStartTime).getTime() : null + + if (phaseStartTime) { + const elapsedSeconds = Math.floor((currentTime - phaseStartTime) / 1000) + phaseProgress = Math.min(100, (elapsedSeconds / phaseDurationSeconds) * 100) + } + } else { + phaseProgress = 100 + } + + // Phase-specific colors + const phaseColor = isWorkPhase + ? 'hsl(var(--primary))' + : isBreakPhase + ? 'hsl(var(--chart-2))' + : 'hsl(var(--muted-foreground))' + + const phaseIconColor = isWorkPhase ? 'text-primary' : isBreakPhase ? 'text-chart-2' : 'text-muted-foreground' + + const PhaseIcon = isWorkPhase ? Clock : isBreakPhase ? Coffee : CheckCircle2 + + // Hide outer ring if only one round + const showOuterRing = totalRounds > 1 + + return ( +
+
+ {/* Outer ring: Overall session progress */} + {showOuterRing && ( + +
+ + )} + + {/* Inner ring: Current phase progress */} +
+ + {/* Center content */} +
+ {/* Current round number */} +
+
{isCompleted ? '✓' : currentRound}
+ {!isCompleted &&
of {totalRounds}
} +
+ + {/* Phase icon */} +
+ +
+ + {/* Completed count */} + {!isCompleted && ( +
+ {completedRounds} {t('pomodoro.progress.roundsComplete')} +
+ )} + + {/* Completion message */} + {isCompleted && ( +
+
{t('pomodoro.progress.completed')}
+
+ {completedRounds}/{totalRounds} {t('pomodoro.progress.roundsComplete')} +
+
+ )} +
+
+
+
+
+ ) +} diff --git a/src/components/pomodoro/FlipDigit.tsx b/src/components/pomodoro/FlipDigit.tsx new file mode 100644 index 0000000..bf373bd --- /dev/null +++ b/src/components/pomodoro/FlipDigit.tsx @@ -0,0 +1,41 @@ +import { cn } from '@/lib/utils' + +interface FlipDigitProps { + digit: string + phase: 'work' | 'break' | 'completed' +} + +/** + * Reusable flip-card digit component for Pomodoro countdown + * Features phase-aware styling and maintains flip effect with top/bottom halves + */ +export function FlipDigit({ digit, phase }: FlipDigitProps) { + return ( +
+
+ {/* Top half */} +
+
{digit}
+
+ + {/* Bottom half */} +
+
+ {digit} +
+
+ + {/* Center divider line */} +
+
+
+ ) +} diff --git a/src/components/pomodoro/PomodoroConfig.tsx b/src/components/pomodoro/PomodoroConfig.tsx new file mode 100644 index 0000000..4d68fd7 --- /dev/null +++ b/src/components/pomodoro/PomodoroConfig.tsx @@ -0,0 +1,231 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { Settings, ChevronUp, ChevronDown } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { usePomodoroStore } from '@/lib/stores/pomodoro' +import { cn } from '@/lib/utils' + +interface PomodoroConfigProps { + onConfigChange?: (config: { workDurationMinutes: number; breakDurationMinutes: number; totalRounds: number }) => void +} + +export function PomodoroConfig({ onConfigChange }: PomodoroConfigProps) { + const { t } = useTranslation() + const { config, presets, setConfig, applyPreset, setPresets } = usePomodoroStore() + + // Load presets from backend on mount + useEffect(() => { + async function loadPresets() { + try { + const response = await fetch('/api/pomodoro/presets') + const data = await response.json() + if (data.success && data.data) { + setPresets(data.data) + } + } catch (error) { + console.error('Failed to load Pomodoro presets:', error) + } + } + loadPresets() + }, [setPresets]) + + const handlePresetSelect = (presetId: string) => { + applyPreset(presetId) + const preset = presets.find((p) => p.id === presetId) + if (preset && onConfigChange) { + onConfigChange({ + workDurationMinutes: preset.workDurationMinutes, + breakDurationMinutes: preset.breakDurationMinutes, + totalRounds: preset.totalRounds + }) + } + } + + const handleCustomChange = (field: keyof typeof config, value: number) => { + const newConfig = { ...config, [field]: value } + setConfig(newConfig) + if (onConfigChange) { + onConfigChange(newConfig) + } + } + + const adjustValue = (field: keyof typeof config, delta: number) => { + const currentValue = config[field] + let newValue = currentValue + delta + + // Set limits based on field + if (field === 'totalRounds') { + newValue = Math.max(1, Math.min(8, newValue)) + } else if (field === 'workDurationMinutes') { + newValue = Math.max(5, Math.min(120, newValue)) + } else if (field === 'breakDurationMinutes') { + newValue = Math.max(1, Math.min(60, newValue)) + } + + handleCustomChange(field, newValue) + } + + return ( + + + + + {t('pomodoro.config.title')} + + {t('pomodoro.config.description')} + + + + + {t('pomodoro.config.presets')} + {t('pomodoro.config.custom')} + + + {/* Presets Tab */} + +
+ {presets.map((preset) => ( + + ))} +
+
+ + {/* Custom Tab - Circular Controls */} + + {/* Circular Adjusters */} +
+ {/* Total Rounds */} +
+
+ {/* Up Arrow */} + + {/* Circle with number */} +
+ {config.totalRounds} +
+ {/* Down Arrow */} + +
+ {t('pomodoro.config.totalRounds')} +
+ + {/* Work Duration */} +
+
+ {/* Up Arrow */} + + {/* Circle with number */} +
+ {config.workDurationMinutes} +
+ {/* Down Arrow */} + +
+ {t('pomodoro.config.workDuration')} +
+ + {/* Break Duration */} +
+
+ {/* Up Arrow */} + + {/* Circle with number */} +
+ {config.breakDurationMinutes} +
+ {/* Down Arrow */} + +
+ {t('pomodoro.config.breakDuration')} +
+
+ + {/* Summary */} +
+
{t('pomodoro.config.summary')}
+
+ {t('pomodoro.config.totalTime')}:{' '} + {(config.workDurationMinutes + config.breakDurationMinutes) * config.totalRounds - + config.breakDurationMinutes}{' '} + {t('pomodoro.config.minutes')} +
+
+
+
+
+
+ ) +} diff --git a/src/components/pomodoro/PomodoroCountdown.tsx b/src/components/pomodoro/PomodoroCountdown.tsx new file mode 100644 index 0000000..d601100 --- /dev/null +++ b/src/components/pomodoro/PomodoroCountdown.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' + +import { FlipDigit } from '@/components/pomodoro/FlipDigit' +import { usePomodoroStore } from '@/lib/stores/pomodoro' +import { cn } from '@/lib/utils' + +export function PomodoroCountdown() { + const { session } = usePomodoroStore() + const [colonVisible, setColonVisible] = useState(true) + const [currentTime, setCurrentTime] = useState(Date.now()) + + // Update current time every second for real-time calculation + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(Date.now()) + }, 1000) + + return () => clearInterval(interval) + }, []) + + // Blinking colon effect + useEffect(() => { + const interval = setInterval(() => { + setColonVisible((prev) => !prev) + }, 500) + + return () => clearInterval(interval) + }, []) + + if (!session) { + return null + } + + const formatTime = (totalSeconds: number): { digits: string[]; hasHours: boolean } => { + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + if (hours > 0) { + return { + digits: [ + ...hours.toString().padStart(2, '0').split(''), + ...minutes.toString().padStart(2, '0').split(''), + ...seconds.toString().padStart(2, '0').split('') + ], + hasHours: true + } + } + return { + digits: [...minutes.toString().padStart(2, '0').split(''), ...seconds.toString().padStart(2, '0').split('')], + hasHours: false + } + } + + const currentPhase = (session.currentPhase || 'work') as 'work' | 'break' | 'completed' + const isWorkPhase = currentPhase === 'work' + const isCompleted = currentPhase === 'completed' + + // Calculate remaining seconds based on phase start time (works even when page is in background) + const phaseDurationSeconds = isWorkPhase + ? (session.workDurationMinutes || 25) * 60 + : (session.breakDurationMinutes || 5) * 60 + + let remainingSeconds = 0 + if (!isCompleted && session) { + // Use phaseStartTime for reliable calculation + const phaseStartTime = session.phaseStartTime ? new Date(session.phaseStartTime).getTime() : null + + if (phaseStartTime) { + // Calculate elapsed time since phase started + const elapsedSeconds = Math.floor((currentTime - phaseStartTime) / 1000) + + // Remaining time = phase duration - elapsed time + remainingSeconds = Math.max(0, phaseDurationSeconds - elapsedSeconds) + } else if (session.remainingPhaseSeconds != null) { + // Fallback: use server's calculated value if phaseStartTime not available + remainingSeconds = session.remainingPhaseSeconds + } else { + // Last resort: show full duration + remainingSeconds = phaseDurationSeconds + } + } + + const timeData = isCompleted ? { digits: ['0', '0', '0', '0'], hasHours: false } : formatTime(remainingSeconds) + + return ( +
+ {/* Main time display - Flip clock style */} +
+ {/* First pair of digits (minutes) */} + + + + {/* Colon separator */} +
+
+
+
+ + {/* Second pair of digits (seconds) */} + + +
+
+ ) +} diff --git a/src/components/pomodoro/PomodoroCountdown.tsx.bak b/src/components/pomodoro/PomodoroCountdown.tsx.bak new file mode 100644 index 0000000..daab912 --- /dev/null +++ b/src/components/pomodoro/PomodoroCountdown.tsx.bak @@ -0,0 +1,320 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Clock, Coffee } from 'lucide-react' + +import { usePomodoroStore } from '@/lib/stores/pomodoro' +import { cn } from '@/lib/utils' +import { Separator } from '@/components/ui/separator' + +export function PomodoroCountdown() { + const { t } = useTranslation() + const { session, localRemainingSeconds, setLocalRemainingSeconds } = usePomodoroStore() + const [colonVisible, setColonVisible] = useState(true) + + // Update local countdown every second + useEffect(() => { + if (!session || localRemainingSeconds === null || localRemainingSeconds <= 0) { + return + } + + const interval = setInterval(() => { + setLocalRemainingSeconds(Math.max(0, localRemainingSeconds - 1)) + }, 1000) + + return () => clearInterval(interval) + }, [localRemainingSeconds, session, setLocalRemainingSeconds]) + + // Blinking colon effect + useEffect(() => { + const interval = setInterval(() => { + setColonVisible((prev) => !prev) + }, 500) + + return () => clearInterval(interval) + }, []) + + // Sync with server data when session updates + useEffect(() => { + if (session?.remainingPhaseSeconds != null) { + setLocalRemainingSeconds(session.remainingPhaseSeconds) + } + }, [session?.remainingPhaseSeconds, setLocalRemainingSeconds]) + + if (!session) { + return null + } + + const formatTime = (totalSeconds: number): { digits: string[]; hasHours: boolean } => { + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + if (hours > 0) { + return { + digits: [ + ...hours.toString().padStart(2, '0').split(''), + ...minutes.toString().padStart(2, '0').split(''), + ...seconds.toString().padStart(2, '0').split('') + ], + hasHours: true + } + } + return { + digits: [...minutes.toString().padStart(2, '0').split(''), ...seconds.toString().padStart(2, '0').split('')], + hasHours: false + } + } + + const currentPhase = session.currentPhase || 'work' + const isWorkPhase = currentPhase === 'work' + const isBreakPhase = currentPhase === 'break' + const isCompleted = currentPhase === 'completed' + + const remainingSeconds = localRemainingSeconds ?? 0 + + // Calculate progress percentage + const phaseDurationSeconds = isWorkPhase + ? (session.workDurationMinutes || 25) * 60 + : (session.breakDurationMinutes || 5) * 60 + const progress = isCompleted ? 100 : ((phaseDurationSeconds - remainingSeconds) / phaseDurationSeconds) * 100 + + const progressColor = isWorkPhase + ? 'hsl(var(--primary))' + : isBreakPhase + ? 'hsl(var(--chart-2))' + : 'hsl(var(--muted-foreground))' + + const timeData = isCompleted ? { digits: ['0', '0', '0', '0'], hasHours: false } : formatTime(remainingSeconds) + + // Digital clock color - classic black/white based on theme + const digitColor = 'text-foreground' + + // Subtle shadow for better contrast instead of colored glow + const textShadow = '0 2px 4px rgba(0, 0, 0, 0.2)' + + const glowColor = isWorkPhase + ? 'rgba(59, 130, 246, 0.8)' // Blue glow + : isBreakPhase + ? 'rgba(16, 185, 129, 0.8)' // Green glow + : 'rgba(128, 128, 128, 0.5)' + + return ( +
+ {/* Digital Clock Display with integrated info */} +
+ {/* Background glow */} +
+ + {/* Digital display container */} +
+ {/* Top row: Phase indicator + Round info */} +
+
+ {isWorkPhase && ( + <> + + {t('pomodoro.phase.work')} + + )} + {isBreakPhase && ( + <> + + {t('pomodoro.phase.break')} + + )} + {isCompleted && ( + {t('pomodoro.phase.completed')} + )} +
+ {!isCompleted && ( +
+
+ {isWorkPhase && session.workDurationMinutes + ? `${session.workDurationMinutes}${t('pomodoro.config.minutes')}` + : session.breakDurationMinutes + ? `${session.breakDurationMinutes}${t('pomodoro.config.minutes')}` + : ''} +
+
+
+ {Math.round(progress)}% +
+
+ )} +
+ + + + {/* Main time display - Flip clock style */} +
+ {/* Time digits */} +
+ {/* First pair of digits (hours or minutes) */} +
+ {/* Flip card digit 1 */} +
+
+ {/* Top half */} +
+
+ {timeData.digits[0]} +
+
+ {/* Bottom half */} +
+
+ {timeData.digits[0]} +
+
+ {/* Center divider line */} +
+
+
+ + {/* Flip card digit 2 */} +
+
+
+
+ {timeData.digits[1]} +
+
+
+
+ {timeData.digits[1]} +
+
+
+
+
+
+ + {/* Colon separator */} +
+
+
+
+ + {/* Second pair of digits (minutes or seconds) */} +
+ {/* Flip card digit 3 */} +
+
+
+
+ {timeData.digits[2]} +
+
+
+
+ {timeData.digits[2]} +
+
+
+
+
+ + {/* Flip card digit 4 */} +
+
+
+
+ {timeData.digits[3]} +
+
+
+
+ {timeData.digits[3]} +
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/src/components/pomodoro/PomodoroProgress.tsx b/src/components/pomodoro/PomodoroProgress.tsx new file mode 100644 index 0000000..f4ac0c5 --- /dev/null +++ b/src/components/pomodoro/PomodoroProgress.tsx @@ -0,0 +1,9 @@ +import { CircularRoundProgress } from '@/components/pomodoro/CircularRoundProgress' + +/** + * Wrapper component for CircularRoundProgress + * Maintains compatibility with existing usage in PomodoroTimer + */ +export function PomodoroProgress() { + return +} diff --git a/src/components/pomodoro/PomodoroTimer.tsx b/src/components/pomodoro/PomodoroTimer.tsx new file mode 100644 index 0000000..e2d5953 --- /dev/null +++ b/src/components/pomodoro/PomodoroTimer.tsx @@ -0,0 +1,453 @@ +import { useState, useEffect, useCallback } from 'react' +import { Clock, Play, Square, ChevronDown, ChevronUp, Coffee, Settings } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { toast } from 'sonner' +import { startPomodoro, endPomodoro, getPomodoroStatus } from '@/lib/client/apiClient' +import { usePomodoroStore } from '@/lib/stores/pomodoro' +import { useInsightsStore } from '@/lib/stores/insights' +import { usePomodoroPhaseSwitched } from '@/hooks/useTauriEvents' +import { PomodoroCountdown } from './PomodoroCountdown' +import { PomodoroProgress } from './PomodoroProgress' +import { PresetButtons } from './PresetButtons' +import { SessionInfoCard } from './SessionInfoCard' +import { TodoAssociationSelector } from './TodoAssociationSelector' +import { cn } from '@/lib/utils' + +export function PomodoroTimer() { + const { t } = useTranslation() + const { status, session, error, config, setStatus, setSession, setError, reset, setConfig } = usePomodoroStore() + const { todos } = useInsightsStore() + + const [userIntent, setUserIntent] = useState('') + const [selectedTodoId, setSelectedTodoId] = useState(null) + + // Listen for phase switches (work → break or break → work) + usePomodoroPhaseSwitched((payload) => { + console.log('[Pomodoro] Phase switched:', payload) + + // Refresh session data to get updated phase info + getPomodoroStatus() + .then((result) => { + if (result.success && result.data) { + setSession(result.data) + + // Show toast notification + const phaseText = payload.new_phase === 'work' ? t('pomodoro.phase.work') : t('pomodoro.phase.break') + toast.info( + t('pomodoro.phaseSwitch.notification', { + phase: phaseText, + round: payload.current_round, + total: payload.total_rounds + }), + { duration: 3000 } + ) + } + }) + .catch((err) => { + console.error('[Pomodoro] Failed to refresh session after phase switch:', err) + }) + }) + + // Auto-fill userIntent when todo is selected + useEffect(() => { + if (selectedTodoId) { + const selectedTodo = todos.find((todo) => todo.id === selectedTodoId) + if (selectedTodo) { + setUserIntent(selectedTodo.title) + } + } else { + // Clear userIntent when todo is deselected + setUserIntent('') + } + }, [selectedTodoId, todos]) + + // Check for active session on mount + useEffect(() => { + const checkStatus = async () => { + try { + const result = await getPomodoroStatus() + if (result.success && result.data) { + setStatus('active') + setSession(result.data) + } + } catch (err) { + console.error('[Pomodoro] Failed to check status:', err) + } + } + + checkStatus() + }, [setStatus, setSession]) + + // Poll for status updates when Pomodoro is active + useEffect(() => { + if (status !== 'active') { + return + } + + // Immediately poll on mount/activation + const pollStatus = async () => { + try { + console.log('[PomodoroTimer] Polling status...') + const result = await getPomodoroStatus() + console.log('[PomodoroTimer] Poll result:', { + success: result.success, + hasData: !!result.data, + remainingPhaseSeconds: result.data?.remainingPhaseSeconds, + sessionId: result.data?.sessionId + }) + if (result.success && result.data) { + setSession(result.data) + } else { + // Session ended on backend + console.log('[PomodoroTimer] Session ended, resetting') + reset() + } + } catch (err) { + console.error('[Pomodoro] Failed to poll status:', err) + } + } + + pollStatus() + + // Poll every 3 seconds to sync with backend + const pollInterval = setInterval(pollStatus, 3000) + + // Re-sync when page becomes visible (fixes issue when switching tabs/pages) + const handleVisibilityChange = () => { + if (!document.hidden) { + console.log('[Pomodoro] Page visible, triggering immediate poll') + pollStatus() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + clearInterval(pollInterval) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [status, setSession, reset]) + + const handleStart = useCallback(async () => { + if (!userIntent.trim()) { + toast.error(t('pomodoro.error.noIntent')) + return + } + + setStatus('active') + setError(null) + + try { + const totalDuration = + (config.workDurationMinutes + config.breakDurationMinutes) * config.totalRounds - config.breakDurationMinutes + + const result = await startPomodoro({ + userIntent: userIntent.trim(), + durationMinutes: totalDuration, + workDurationMinutes: config.workDurationMinutes, + breakDurationMinutes: config.breakDurationMinutes, + totalRounds: config.totalRounds, + associatedTodoId: selectedTodoId || undefined + }) + + if (result.success && result.data) { + setSession(result.data) + toast.success(t('pomodoro.started')) + } else { + throw new Error(result.error || 'Failed to start Pomodoro') + } + } catch (err: any) { + console.error('[Pomodoro] Failed to start:', err) + setError(err.message || String(err)) + toast.error(t('pomodoro.error.startFailed', { error: err.message || String(err) })) + setStatus('idle') + } + }, [userIntent, config, selectedTodoId, setStatus, setSession, setError, t]) + + const handleEnd = useCallback(async () => { + if (!session) return + + setStatus('ending') + setError(null) + + try { + const result = await endPomodoro({ + status: 'completed' + }) + + if (result.success && result.data) { + const { rawRecordsCount, message } = result.data + + if (message) { + toast.info(message) + } else { + // Show success message and immediately reset to idle + const recordCount = rawRecordsCount ?? 0 + toast.success(t('pomodoro.ended', { count: recordCount })) + + // If there are records, show background processing info + if (recordCount > 0) { + toast.info(t('pomodoro.processing.background')) + } + } + + // Immediately reset to idle state (don't wait for processing) + reset() + } else { + throw new Error(result.error || 'Failed to end Pomodoro') + } + } catch (err: any) { + console.error('[Pomodoro] Failed to end:', err) + setError(err.message || String(err)) + toast.error(t('pomodoro.error.endFailed', { error: err.message || String(err) })) + setStatus('active') // Revert to active + } + }, [session, setStatus, setError, reset, t]) + + const adjustValue = useCallback( + (field: keyof typeof config, delta: number) => { + const currentValue = config[field] + let newValue = currentValue + delta + + // Set limits based on field + if (field === 'totalRounds') { + newValue = Math.max(1, Math.min(8, newValue)) + } else if (field === 'workDurationMinutes') { + newValue = Math.max(5, Math.min(120, newValue)) + } else if (field === 'breakDurationMinutes') { + newValue = Math.max(1, Math.min(60, newValue)) + } + + setConfig({ ...config, [field]: newValue }) + }, + [config, setConfig] + ) + + return ( + + + {status === 'idle' && ( +
+ {/* TODO Association + Manual Input */} + + + {/* TODO Association */} + + + {/* Main Input - Only show when no todo is selected */} + {!selectedTodoId && ( +
+ + setUserIntent(e.target.value)} + maxLength={200} + className="text-base" + /> +
+ )} +
+
+ + {/* Configuration Section - Left: Custom Controls, Right: Presets */} +
+ {/* Left: Quick Setup Presets */} +
+

+ + {t('pomodoro.presets.quickSetup')} +

+ +
+ {/* Right: Circular Config Controls */} + + +

+ + {t('pomodoro.config.custom')} +

+
+ +
+ {/* Total Rounds */} +
+
+ {/* Up Arrow */} + + {/* Circle with number */} +
+ {config.totalRounds} +
+ {/* Down Arrow */} + +
+ {t('pomodoro.config.totalRounds')} +
+ + {/* Work Duration */} +
+
+ {/* Up Arrow */} + + {/* Circle with number */} +
+ {config.workDurationMinutes} + {/* Optional watermark icon */} +
+ +
+
+ {/* Down Arrow */} + +
+
+ + + {t('pomodoro.config.workDuration')} + +
+
+ + {/* Break Duration */} +
+
+ {/* Up Arrow */} + + {/* Circle with number */} +
+ {config.breakDurationMinutes} + {/* Optional watermark icon */} +
+ +
+
+ {/* Down Arrow */} + +
+
+ + + {t('pomodoro.config.breakDuration')} + +
+
+
+
+
+
+ + {/* Start Button */} + +
+ )} + + {status === 'active' && session && ( +
+ {/* Session Info Card */} + + + {/* Countdown + Progress - Side by side */} +
+ {/* Countdown - Takes equal space */} +
+ +
+ + {/* Progress - Takes equal space */} +
+ +
+
+ + {/* End Button */} + +
+ )} + + {error && ( +
+ {t('pomodoro.error.title')}: {error} +
+ )} +
+
+ ) +} diff --git a/src/components/pomodoro/PresetButtons.tsx b/src/components/pomodoro/PresetButtons.tsx new file mode 100644 index 0000000..945893d --- /dev/null +++ b/src/components/pomodoro/PresetButtons.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react' + +import { Button } from '@/components/ui/button' +import { usePomodoroStore } from '@/lib/stores/pomodoro' +import { cn } from '@/lib/utils' + +interface PresetButtonsProps { + layout?: 'horizontal' | 'vertical' +} + +/** + * Preset buttons for quick Pomodoro configuration + * Displays 3 preset options with auto-detection of matching config + */ +export function PresetButtons({ layout = 'horizontal' }: PresetButtonsProps) { + const { config, presets, selectedPresetId, applyPreset, setSelectedPresetId, setPresets } = usePomodoroStore() + + // Initialize default presets if empty + useEffect(() => { + if (presets.length === 0) { + setPresets([ + { + id: 'classic', + name: '25 - 5', + description: 'Classic Pomodoro', + workDurationMinutes: 25, + breakDurationMinutes: 5, + totalRounds: 2, + icon: '🍅' + }, + { + id: 'extended', + name: '50 - 10', + description: 'Extended Focus', + workDurationMinutes: 50, + breakDurationMinutes: 10, + totalRounds: 2, + icon: '⏰' + }, + { + id: 'deep', + name: '90 - 20', + description: 'Deep Work', + workDurationMinutes: 90, + breakDurationMinutes: 20, + totalRounds: 2, + icon: '🚀' + } + ]) + } + }, [presets.length, setPresets]) + + // Auto-detect if current config matches a preset + useEffect(() => { + if (!selectedPresetId) { + const matchingPreset = presets.find( + (p) => + p.workDurationMinutes === config.workDurationMinutes && + p.breakDurationMinutes === config.breakDurationMinutes && + p.totalRounds === config.totalRounds + ) + if (matchingPreset) { + setSelectedPresetId(matchingPreset.id) + } + } + }, [config, presets, selectedPresetId, setSelectedPresetId]) + + const handlePresetClick = (presetId: string) => { + setSelectedPresetId(presetId) + applyPreset(presetId) + } + + if (presets.length === 0) { + return null + } + + return ( +
+ {presets.map((preset) => { + const isSelected = selectedPresetId === preset.id + + return ( + + ) + })} +
+ ) +} diff --git a/src/components/pomodoro/SessionInfoCard.tsx b/src/components/pomodoro/SessionInfoCard.tsx new file mode 100644 index 0000000..444cf11 --- /dev/null +++ b/src/components/pomodoro/SessionInfoCard.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from 'react-i18next' +import { CheckSquare, Clock, Coffee } from 'lucide-react' + +import { Card, CardContent } from '@/components/ui/card' +import { usePomodoroStore } from '@/lib/stores/pomodoro' +import { cn } from '@/lib/utils' + +/** + * Session information card with phase-colored gradient background + * Prominently displays current task and phase status + */ +export function SessionInfoCard() { + const { t } = useTranslation() + const { session } = usePomodoroStore() + + if (!session) { + return null + } + + const currentPhase = session.currentPhase || 'work' + const isWorkPhase = currentPhase === 'work' + const isBreakPhase = currentPhase === 'break' + + // Session title: prioritize associated TODO, fallback to user intent + const sessionTitle = session.associatedTodoTitle || session.userIntent || t('pomodoro.intent.current') + + // Phase-specific styling + const phaseConfig = { + work: { + gradient: 'from-primary/5 to-primary/10', + border: 'border-primary/40', + iconBg: 'bg-primary/10', + iconColor: 'text-primary', + badgeBg: 'bg-primary/10', + icon: Clock, + label: t('pomodoro.phase.work') + }, + break: { + gradient: 'from-chart-2/5 to-chart-2/10', + border: 'border-chart-2/40', + iconBg: 'bg-chart-2/10', + iconColor: 'text-chart-2', + badgeBg: 'bg-chart-2/10', + icon: Coffee, + label: t('pomodoro.phase.break') + }, + completed: { + gradient: 'from-muted/5 to-muted/10', + border: 'border-muted/40', + iconBg: 'bg-muted/10', + iconColor: 'text-muted-foreground', + badgeBg: 'bg-muted/10', + icon: CheckSquare, + label: t('pomodoro.phase.completed') + } + } + + const config = isWorkPhase ? phaseConfig.work : isBreakPhase ? phaseConfig.break : phaseConfig.completed + const PhaseIcon = config.icon + + return ( + + +
+ {/* Left: Icon + Task name */} +
+
+ +
+ +
+
+ {t('pomodoro.intent.current')} +
+
+ {sessionTitle} +
+
+
+ + {/* Right: Phase badge */} +
+ + {config.label} +
+
+
+
+ ) +} diff --git a/src/components/pomodoro/TodoAssociationSelector.tsx b/src/components/pomodoro/TodoAssociationSelector.tsx new file mode 100644 index 0000000..42df921 --- /dev/null +++ b/src/components/pomodoro/TodoAssociationSelector.tsx @@ -0,0 +1,133 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { CheckSquare, X } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useInsightsStore } from '@/lib/stores/insights' + +interface TodoAssociationSelectorProps { + selectedTodoId: string | null + onTodoSelect: (todoId: string | null) => void +} + +export function TodoAssociationSelector({ selectedTodoId, onTodoSelect }: TodoAssociationSelectorProps) { + const { t } = useTranslation() + const { getPendingTodos, todos, refreshTodos, loadingTodos } = useInsightsStore() + const [pendingTodos, setPendingTodos] = useState([]) + + // Load todos on mount if not already loaded + useEffect(() => { + if (todos.length === 0 && !loadingTodos) { + refreshTodos(false) + } + }, []) + + useEffect(() => { + setPendingTodos(getPendingTodos()) + }, [todos, getPendingTodos]) + + const selectedTodo = pendingTodos.find((todo) => todo.id === selectedTodoId) + + return ( +
+ + + {loadingTodos ? ( +

{t('insights.loading')}

+ ) : pendingTodos.length === 0 ? ( +

{t('pomodoro.todoAssociation.noTodos')}

+ ) : ( +
+ + + {selectedTodoId && ( + + )} +
+ )} + + {selectedTodo && ( +
+
+ +
+

{selectedTodo.title}

+ {selectedTodo.description && ( +

{selectedTodo.description}

+ )} +
+
+
+ )} +
+ ) +} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 7aae9cf..43fd899 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -73,7 +73,7 @@ const SelectContent = React.forwardRef< {children} @@ -95,13 +95,14 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName const SelectItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +>(({ className, children, style, ...props }, ref) => ( @@ -109,7 +110,9 @@ const SelectItem = React.forwardRef< - {children} + + {children} + )) SelectItem.displayName = SelectPrimitive.Item.displayName diff --git a/src/hooks/useTauriEvents.ts b/src/hooks/useTauriEvents.ts index b9fc30a..32a1e27 100644 --- a/src/hooks/useTauriEvents.ts +++ b/src/hooks/useTauriEvents.ts @@ -304,3 +304,57 @@ export interface TodoDeletedPayload { export function useTodoDeleted(onDeleted: (payload: TodoDeletedPayload) => void) { useTauriEvent('todo-deleted', onDeleted) } + +/** + * Pomodoro processing progress hook (fires during batch processing) + */ +export interface PomodoroProcessingProgressPayload { + session_id: string + job_id: string + processed: number +} + +export function usePomodoroProcessingProgress(onProgress: (payload: PomodoroProcessingProgressPayload) => void) { + useTauriEvent('pomodoro-processing-progress', onProgress) +} + +/** + * Pomodoro processing complete hook (fires after batch processing finishes) + */ +export interface PomodoroProcessingCompletePayload { + session_id: string + job_id: string + total_processed: number +} + +export function usePomodoroProcessingComplete(onComplete: (payload: PomodoroProcessingCompletePayload) => void) { + useTauriEvent('pomodoro-processing-complete', onComplete) +} + +/** + * Pomodoro processing failed hook (fires if batch processing fails) + */ +export interface PomodoroProcessingFailedPayload { + session_id: string + job_id: string + error: string +} + +export function usePomodoroProcessingFailed(onFailed: (payload: PomodoroProcessingFailedPayload) => void) { + useTauriEvent('pomodoro-processing-failed', onFailed) +} + +/** + * Pomodoro phase switched hook (fires when switching between work/break phases) + */ +export interface PomodoroPhaseSwitchedPayload { + session_id: string + new_phase: string + current_round: number + total_rounds: number + completed_rounds: number +} + +export function usePomodoroPhaseSwitched(onSwitch: (payload: PomodoroPhaseSwitchedPayload) => void) { + useTauriEvent('pomodoro-phase-switched', onSwitch) +} diff --git a/src/lib/client/_apiTypes.d.ts b/src/lib/client/_apiTypes.d.ts index 9b92bab..d191ef9 100644 --- a/src/lib/client/_apiTypes.d.ts +++ b/src/lib/client/_apiTypes.d.ts @@ -61,12 +61,35 @@ export type Success6 = boolean export type Message4 = string export type Error5 = string export type Newactivityids = string[] -export type Agent = string -export type Plandescription = string +export type Activityid4 = string +export type Dimension = string +export type Rating = number +export type Note = (string | null) export type Success7 = boolean export type Message5 = string export type Error6 = string -export type Data2 = ({ +export type Id1 = string +export type Activityid5 = string +export type Dimension1 = string +export type Rating1 = number +export type Note1 = (string | null) +export type Createdat1 = string +export type Updatedat = string +export type Timestamp2 = string +export type Activityid6 = string +export type Success8 = boolean +export type Message6 = string +export type Error7 = string +export type Data2 = (ActivityRatingData[] | null) +export type Timestamp3 = string +export type Activityid7 = string +export type Dimension2 = string +export type Agent = string +export type Plandescription = string +export type Success9 = boolean +export type Message7 = string +export type Error8 = string +export type Data3 = ({ [k: string]: unknown } | { [k: string]: unknown @@ -100,10 +123,10 @@ export type Offset2 = (number | null) export type Conversationid3 = string export type Conversationids = (string[] | null) export type Conversationid4 = string -export type Success8 = boolean -export type Message6 = string -export type Error7 = string -export type Data3 = ({ +export type Success10 = boolean +export type Message8 = string +export type Error9 = string +export type Data4 = ({ [k: string]: unknown } | null) export type Enabled = (boolean | null) @@ -124,21 +147,21 @@ export type Starttime1 = (string | null) export type Endtime1 = (string | null) export type Eventid = string export type Eventid1 = string -export type Success9 = boolean -export type Message7 = string -export type Error8 = string -export type Id1 = string +export type Success11 = boolean +export type Message9 = string +export type Error10 = string +export type Id2 = string export type Title3 = string export type Description2 = string export type Keywords = string[] -export type Timestamp2 = string +export type Timestamp4 = string export type Screenshots = string[] -export type Createdat1 = string +export type Createdat2 = string export type Actions = ActionResponse[] export type Eventid2 = string export type Limit7 = number export type Offset4 = number -export type Id2 = string +export type Id3 = string export type Includecompleted = boolean export type Todoid = string export type Scheduleddate2 = string @@ -149,26 +172,26 @@ export type Recurrencerule = ({ } | null) export type Todoid1 = string export type Date = string -export type Success10 = boolean -export type Message8 = string -export type Error9 = string -export type Id3 = string +export type Success12 = boolean +export type Message10 = string +export type Error11 = string +export type Id4 = string export type Date1 = string export type Content1 = string export type Sourceactivityids = string[] -export type Createdat2 = string -export type Timestamp3 = string +export type Createdat3 = string +export type Timestamp5 = string export type Limit8 = number -export type Success11 = boolean -export type Message9 = string -export type Error10 = string +export type Success13 = boolean +export type Message11 = string +export type Error12 = string export type Diaries = DiaryData[] export type Count1 = number -export type Timestamp4 = string -export type Success12 = boolean -export type Message10 = string -export type Error11 = string -export type Timestamp5 = string +export type Timestamp6 = string +export type Success14 = boolean +export type Message12 = string +export type Error13 = string +export type Timestamp7 = string export type Intervalseconds = (number | null) export type Monitorindex = number export type Monitorname = string @@ -197,19 +220,81 @@ export type Required = boolean export type Systemsettingspath = string export type Platform = string export type Needsrestart = boolean -export type Success13 = boolean -export type Message11 = string -export type Error12 = string -export type Success14 = boolean -export type Message12 = string -export type Error13 = string -export type Granted = (boolean | null) -export type Delayseconds = number export type Success15 = boolean export type Message13 = string export type Error14 = string +export type Success16 = boolean +export type Message14 = string +export type Error15 = string +export type Granted = (boolean | null) +export type Delayseconds = number +export type Success17 = boolean +export type Message15 = string +export type Error16 = string export type Delayseconds1 = (number | null) -export type Timestamp6 = string +export type Timestamp8 = string +export type Userintent = string +export type Durationminutes = number +export type Associatedtodoid = (string | null) +export type Workdurationminutes = number +export type Breakdurationminutes = number +export type Totalrounds = number +export type Success18 = boolean +export type Message16 = string +export type Error17 = string +export type Sessionid = string +export type Userintent1 = string +export type Starttime3 = string +export type Elapsedminutes = number +export type Planneddurationminutes = number +export type Associatedtodoid1 = (string | null) +export type Associatedtodotitle = (string | null) +export type Workdurationminutes1 = number +export type Breakdurationminutes1 = number +export type Totalrounds1 = number +export type Currentround = number +export type Currentphase = ("work" | "break" | "completed") +export type Phasestarttime = (string | null) +export type Completedrounds = number +export type Remainingphaseseconds = (number | null) +export type Timestamp9 = string +export type Status1 = string +export type Success19 = boolean +export type Message17 = string +export type Error18 = string +export type Sessionid1 = string +export type Processingjobid = (string | null) +export type Rawrecordscount = number +export type Message18 = string +export type Timestamp10 = string +export type Success20 = boolean +export type Message19 = string +export type Error19 = string +export type Timestamp11 = string +export type Success21 = boolean +export type Message20 = string +export type Error20 = string +export type Id5 = string +export type Name1 = string +export type Description4 = string +export type Workdurationminutes2 = number +export type Breakdurationminutes2 = number +export type Totalrounds2 = number +export type Icon = string +export type Data5 = PomodoroPreset[] +export type Timestamp12 = string +export type Date2 = string +export type Success22 = boolean +export type Message21 = string +export type Error21 = string +export type Date3 = string +export type Completedcount = number +export type Totalfocusminutes = number +export type Averagedurationminutes = number +export type Sessions = { +[k: string]: unknown +}[] +export type Timestamp13 = string export type Days = number export type Startdate1 = string export type Enddate1 = string @@ -217,41 +302,40 @@ export type Startdate2 = string export type Enddate2 = string export type Startdate3 = string export type Enddate3 = string -export type Success16 = boolean -export type Message14 = string -export type Error15 = string +export type Success23 = boolean +export type Message22 = string +export type Error22 = string export type Stats = ({ [k: string]: unknown } | null) export type Hashes = string[] -export type Success17 = boolean -export type Message15 = string -export type Error16 = string +export type Success24 = boolean +export type Message23 = string +export type Error23 = string export type Foundcount = number export type Requestedcount = number export type Maxagehours = number -export type Success18 = boolean -export type Message16 = string -export type Error17 = string +export type Success25 = boolean +export type Message24 = string +export type Error24 = string export type Cleanedcount = number -export type Success19 = boolean -export type Message17 = string -export type Error18 = string +export type Success26 = boolean +export type Message25 = string +export type Error25 = string export type Clearedcount = number -export type Success20 = boolean -export type Message18 = string -export type Error19 = string +export type Success27 = boolean +export type Message26 = string +export type Error26 = string export type Enabled2 = boolean export type Strategy = string export type Phashthreshold = number export type Mininterval = number -export type Maximages = number export type Enablecontentanalysis = boolean export type Enabletextdetection = boolean -export type Timestamp7 = string -export type Success21 = boolean -export type Message19 = string -export type Error20 = string +export type Timestamp14 = string +export type Success28 = boolean +export type Message27 = string +export type Error27 = string export type Stats1 = ({ [k: string]: unknown } | null) @@ -262,19 +346,46 @@ export type Enabled3 = (boolean | null) export type Strategy1 = (string | null) export type Phashthreshold1 = (number | null) export type Mininterval1 = (number | null) -export type Maximages1 = (number | null) +export type Maximages = (number | null) export type Enablecontentanalysis1 = (boolean | null) export type Enabletextdetection1 = (boolean | null) -export type Success22 = boolean -export type Message20 = string -export type Error21 = string -export type Timestamp8 = string +export type Success29 = boolean +export type Message28 = string +export type Error28 = string +export type Timestamp15 = string export type Filepath = string -export type Success23 = boolean -export type Message21 = string -export type Error22 = string +export type Success30 = boolean +export type Message29 = string +export type Error29 = string export type Dataurl = string -export type Name1 = string +export type Success31 = boolean +export type Message30 = string +export type Error30 = string +export type Totalactions = number +export type Actionswithscreenshots = number +export type Actionsallimagesok = number +export type Actionspartialmissing = number +export type Actionsallmissing = number +export type Totalimagereferences = number +export type Imagesfound = number +export type Imagesmissing = number +export type Missingratepercent = number +export type Memorycachecurrentsize = number +export type Memorycachemaxsize = number +export type Memoryttlseconds = number +export type Actionswithissues = { +[k: string]: unknown +}[] +export type Strategy2 = ("delete_actions" | "remove_references" | "dry_run") +export type Actionids = (string[] | null) +export type Success32 = boolean +export type Message31 = string +export type Error31 = string +export type Actionsprocessed = number +export type Actionsdeleted = number +export type Referencesremoved = number +export type Imagesremoved = number +export type Name2 = string /** * Provider identifier */ @@ -285,17 +396,17 @@ export type Inputtokenprice = number export type Outputtokenprice = number export type Currency = string export type Apikey = string -export type Success24 = boolean -export type Message22 = string -export type Error23 = string -export type Data4 = ({ +export type Success33 = boolean +export type Message32 = string +export type Error32 = string +export type Data6 = ({ [k: string]: unknown } | { [k: string]: unknown }[] | null) -export type Timestamp9 = (string | null) +export type Timestamp16 = (string | null) export type Modelid2 = string -export type Name2 = (string | null) +export type Name3 = (string | null) export type Apiurl1 = (string | null) export type Model1 = (string | null) export type Inputtokenprice1 = (number | null) @@ -309,10 +420,10 @@ export type Apikey1 = (string | null) export type Modelid3 = string export type Modelid4 = string export type Modelid5 = string -export type Success25 = boolean -export type Message23 = string -export type Error24 = string -export type Timestamp10 = string +export type Success34 = boolean +export type Message33 = string +export type Error33 = string +export type Timestamp17 = string export type Modelid6 = string export type Model2 = string export type Prompttokens = number @@ -320,68 +431,68 @@ export type Completiontokens = number export type Totaltokens = number export type Cost = number export type Requesttype = string -export type Dimension = string +export type Dimension3 = string export type Days1 = number export type Startdate4 = (string | null) export type Enddate4 = (string | null) export type Modelconfigid = (string | null) -export type Success26 = boolean -export type Message24 = string -export type Error25 = string -export type Timestamp11 = string -export type Dimension1 = (string | null) +export type Success35 = boolean +export type Message34 = string +export type Error34 = string +export type Timestamp18 = string +export type Dimension4 = (string | null) export type Days2 = (number | null) -export type Success27 = boolean -export type Message25 = string -export type Timestamp12 = string -export type Success28 = boolean +export type Success36 = boolean +export type Message35 = string +export type Timestamp19 = string +export type Success37 = boolean export type Path = string -export type Timestamp13 = string -export type Success29 = boolean -export type Message26 = string -export type Error26 = string +export type Timestamp20 = string +export type Success38 = boolean +export type Message36 = string +export type Error35 = string export type Language = string -export type Timestamp14 = string +export type Timestamp21 = string export type Databasepath = (string | null) export type Screenshotsavepath = (string | null) export type Language1 = (string | null) -export type Success30 = boolean -export type Message27 = string -export type Timestamp15 = string -export type Success31 = boolean -export type Message28 = string -export type Error27 = string +export type Success39 = boolean +export type Message37 = string +export type Timestamp22 = string +export type Success40 = boolean +export type Message38 = string +export type Error36 = string export type Compressionlevel = number export type Enableregioncropping = boolean export type Cropthreshold = number -export type Timestamp16 = string +export type Timestamp23 = string export type Compressionlevel1 = (string | null) export type Enableregioncropping1 = (boolean | null) export type Cropthreshold1 = (number | null) -export type Success32 = boolean -export type Message29 = string -export type Error28 = string -export type Timestamp17 = string -export type Success33 = boolean -export type Message30 = string -export type Error29 = string +export type Success41 = boolean +export type Message39 = string +export type Error37 = string +export type Timestamp24 = string +export type Success42 = boolean +export type Message40 = string +export type Error38 = string export type Totalprocessed = number export type Totalsavedbytes = number export type Averagecompressionratio = number -export type Timestamp18 = string -export type Success34 = boolean -export type Message31 = string -export type Error30 = string +export type Timestamp25 = string +export type Success43 = boolean +export type Message41 = string +export type Error39 = string export type Hasmodels = boolean export type Hasactivemodel = boolean export type Hascompletedsetup = boolean export type Needssetup = boolean export type Modelcount = number -export type Timestamp19 = string -export type Success35 = boolean -export type Message32 = string -export type Error31 = string -export type Timestamp20 = string +export type Timestamp26 = string +export type Success44 = boolean +export type Message42 = string +export type Error40 = string +export type Timestamp27 = string export type Show = string export type Hide = string export type Dashboard = string @@ -391,15 +502,15 @@ export type Agents = string export type Settings1 = string export type About = string export type Quit = string -export type Success36 = boolean -export type Message33 = string -export type Error32 = string +export type Success45 = boolean +export type Message43 = string +export type Error41 = string export type Visible = boolean -export type Success37 = boolean -export type Message34 = string -export type Error33 = string +export type Success46 = boolean +export type Message44 = string +export type Error42 = string export type Visible1 = boolean -export type Name3 = string +export type Name4 = string export type RootModelStr = string /** @@ -442,6 +553,18 @@ split_activity_handler: { input: SplitActivityRequest output: SplitActivityResponse } +save_activity_rating: { +input: SaveActivityRatingRequest +output: SaveActivityRatingResponse +} +get_activity_ratings: { +input: GetActivityRatingsRequest +output: GetActivityRatingsResponse +} +delete_activity_rating: { +input: DeleteActivityRatingRequest +output: TimedOperationResponse +} create_task: { input: CreateTaskRequest output: AgentResponse @@ -682,6 +805,26 @@ restart_app: { input: RestartAppRequest output: RestartAppResponse } +start_pomodoro: { +input: StartPomodoroRequest +output: StartPomodoroResponse +} +end_pomodoro: { +input: EndPomodoroRequest +output: EndPomodoroResponse +} +get_pomodoro_status: { +input: void | undefined +output: GetPomodoroStatusResponse +} +get_pomodoro_presets: { +input: void | undefined +output: GetPomodoroPresetsResponse +} +get_pomodoro_stats: { +input: GetPomodoroStatsRequest +output: GetPomodoroStatsResponse +} get_processing_stats: { input: void | undefined output: TimedOperationResponse @@ -754,6 +897,14 @@ read_image_file: { input: ReadImageFileRequest output: ReadImageFileResponse } +check_image_persistence_health: { +input: void | undefined +output: ImagePersistenceHealthResponse +} +cleanup_broken_action_images: { +input: CleanupBrokenActionsRequest +output: CleanupBrokenActionsResponse +} create_model: { input: CreateModelRequest output: ModelOperationResponse @@ -1069,27 +1220,81 @@ error?: Error5 newActivityIds?: Newactivityids } /** - * Request parameters for creating a new agent task. - * - * @property agent - The agent type to use. - * @property planDescription - The task description/plan. + * Request to save or update an activity rating */ -export interface CreateTaskRequest { -agent: Agent -planDescription: Plandescription +export interface SaveActivityRatingRequest { +activityId: Activityid4 +dimension: Dimension +rating: Rating +note?: Note } /** - * Standard agent handler response. + * Response after saving a rating */ -export interface AgentResponse { +export interface SaveActivityRatingResponse { success: Success7 message?: Message5 error?: Error6 -data?: Data2 +data?: (ActivityRatingData | null) +timestamp?: Timestamp2 } /** - * Request parameters for executing a task. - * + * Individual rating record + */ +export interface ActivityRatingData { +id: Id1 +activityId: Activityid5 +dimension: Dimension1 +rating: Rating1 +note?: Note1 +createdAt: Createdat1 +updatedAt: Updatedat +} +/** + * Request to get ratings for an activity + */ +export interface GetActivityRatingsRequest { +activityId: Activityid6 +} +/** + * Response with list of ratings + */ +export interface GetActivityRatingsResponse { +success: Success8 +message?: Message6 +error?: Error7 +data?: Data2 +timestamp?: Timestamp3 +} +/** + * Request to delete a specific rating + */ +export interface DeleteActivityRatingRequest { +activityId: Activityid7 +dimension: Dimension2 +} +/** + * Request parameters for creating a new agent task. + * + * @property agent - The agent type to use. + * @property planDescription - The task description/plan. + */ +export interface CreateTaskRequest { +agent: Agent +planDescription: Plandescription +} +/** + * Standard agent handler response. + */ +export interface AgentResponse { +success: Success9 +message?: Message7 +error?: Error8 +data?: Data3 +} +/** + * Request parameters for executing a task. + * * @property taskId - The task ID to execute. */ export interface ExecuteTaskRequest { @@ -1246,10 +1451,10 @@ conversationId: Conversationid4 * Response model for friendly chat handlers. */ export interface FriendlyChatResponse { -success: Success8 -message?: Message6 -error?: Error7 -data?: Data3 +success: Success10 +message?: Message8 +error?: Error9 +data?: Data4 } /** * Request parameters for updating friendly chat settings. @@ -1327,22 +1532,22 @@ eventId: Eventid1 * Response containing actions for a specific event */ export interface GetActionsByEventResponse { -success: Success9 -message?: Message7 -error?: Error8 +success: Success11 +message?: Message9 +error?: Error10 actions: Actions } /** * Action response data for three-layer architecture */ export interface ActionResponse { -id: Id1 +id: Id2 title: Title3 description: Description2 keywords: Keywords -timestamp: Timestamp2 +timestamp: Timestamp4 screenshots: Screenshots -createdAt: Createdat1 +createdAt: Createdat2 } /** * Request parameters for deleting an event. @@ -1368,7 +1573,7 @@ offset?: Offset4 * @property id - The item ID to delete. */ export interface DeleteItemRequest { -id: Id2 +id: Id3 } /** * Request parameters for getting todo list. @@ -1414,21 +1619,21 @@ date: Date * Response after generating a diary */ export interface GenerateDiaryResponse { -success: Success10 -message?: Message8 -error?: Error9 +success: Success12 +message?: Message10 +error?: Error11 data?: (DiaryData | null) -timestamp?: Timestamp3 +timestamp?: Timestamp5 } /** * Diary data */ export interface DiaryData { -id: Id3 +id: Id4 date: Date1 content: Content1 sourceActivityIds: Sourceactivityids -createdAt: Createdat2 +createdAt: Createdat3 } /** * Request parameters for getting diary list. @@ -1442,11 +1647,11 @@ limit?: Limit8 * Response containing diary list */ export interface GetDiaryListResponse { -success: Success11 -message?: Message9 -error?: Error10 +success: Success13 +message?: Message11 +error?: Error12 data?: (DiaryListData | null) -timestamp?: Timestamp4 +timestamp?: Timestamp6 } /** * Diary list data @@ -1459,10 +1664,10 @@ count: Count1 * Response after deleting a diary */ export interface DeleteDiaryResponse { -success: Success12 -message?: Message10 -error?: Error11 -timestamp?: Timestamp5 +success: Success14 +message?: Message12 +error?: Error13 +timestamp?: Timestamp7 } /** * Request to start monitors auto refresh. @@ -1540,17 +1745,17 @@ permissionType: PermissionType * Response for opening system settings */ export interface OpenSystemSettingsResponse { -success: Success13 -message?: Message11 -error?: Error12 +success: Success15 +message?: Message13 +error?: Error14 } /** * Response for requesting accessibility permission */ export interface AccessibilityPermissionResponse { -success: Success14 -message?: Message12 -error?: Error13 +success: Success16 +message?: Message14 +error?: Error15 granted?: Granted } /** @@ -1563,11 +1768,135 @@ delaySeconds?: Delayseconds * Response for restarting the application */ export interface RestartAppResponse { -success: Success15 -message?: Message13 -error?: Error14 +success: Success17 +message?: Message15 +error?: Error16 delaySeconds?: Delayseconds1 -timestamp?: Timestamp6 +timestamp?: Timestamp8 +} +/** + * Start Pomodoro request with rounds configuration + */ +export interface StartPomodoroRequest { +userIntent: Userintent +durationMinutes?: Durationminutes +associatedTodoId?: Associatedtodoid +workDurationMinutes?: Workdurationminutes +breakDurationMinutes?: Breakdurationminutes +totalRounds?: Totalrounds +} +/** + * Response after starting a Pomodoro session + */ +export interface StartPomodoroResponse { +success: Success18 +message?: Message16 +error?: Error17 +data?: (PomodoroSessionData | null) +timestamp?: Timestamp9 +} +/** + * Pomodoro session data with rounds support + */ +export interface PomodoroSessionData { +sessionId: Sessionid +userIntent: Userintent1 +startTime: Starttime3 +elapsedMinutes: Elapsedminutes +plannedDurationMinutes: Planneddurationminutes +associatedTodoId?: Associatedtodoid1 +associatedTodoTitle?: Associatedtodotitle +workDurationMinutes?: Workdurationminutes1 +breakDurationMinutes?: Breakdurationminutes1 +totalRounds?: Totalrounds1 +currentRound?: Currentround +currentPhase?: Currentphase +phaseStartTime?: Phasestarttime +completedRounds?: Completedrounds +remainingPhaseSeconds?: Remainingphaseseconds +} +/** + * End Pomodoro request + */ +export interface EndPomodoroRequest { +status?: Status1 +} +/** + * Response after ending a Pomodoro session + */ +export interface EndPomodoroResponse { +success: Success19 +message?: Message17 +error?: Error18 +data?: (EndPomodoroData | null) +timestamp?: Timestamp10 +} +/** + * End Pomodoro session result data + */ +export interface EndPomodoroData { +sessionId: Sessionid1 +processingJobId?: Processingjobid +rawRecordsCount?: Rawrecordscount +message?: Message18 +} +/** + * Response for getting current Pomodoro session status + */ +export interface GetPomodoroStatusResponse { +success: Success20 +message?: Message19 +error?: Error19 +data?: (PomodoroSessionData | null) +timestamp?: Timestamp11 +} +/** + * Response with list of Pomodoro presets + */ +export interface GetPomodoroPresetsResponse { +success: Success21 +message?: Message20 +error?: Error20 +data?: Data5 +timestamp?: Timestamp12 +} +/** + * Pomodoro configuration preset + */ +export interface PomodoroPreset { +id: Id5 +name: Name1 +description: Description4 +workDurationMinutes: Workdurationminutes2 +breakDurationMinutes: Breakdurationminutes2 +totalRounds: Totalrounds2 +icon?: Icon +} +/** + * Request to get Pomodoro statistics for a specific date + */ +export interface GetPomodoroStatsRequest { +date: Date2 +} +/** + * Response with Pomodoro statistics + */ +export interface GetPomodoroStatsResponse { +success: Success22 +message?: Message21 +error?: Error21 +data?: (PomodoroStatsData | null) +timestamp?: Timestamp13 +} +/** + * Pomodoro statistics for a specific date + */ +export interface PomodoroStatsData { +date: Date3 +completedCount: Completedcount +totalFocusMinutes: Totalfocusminutes +averageDurationMinutes: Averagedurationminutes +sessions: Sessions } /** * Request parameters for cleaning up old data. @@ -1611,9 +1940,9 @@ endDate: Enddate3 * Response containing image cache statistics */ export interface ImageStatsResponse { -success: Success16 -message?: Message14 -error?: Error15 +success: Success23 +message?: Message22 +error?: Error22 stats?: Stats } /** @@ -1628,9 +1957,9 @@ hashes: Hashes * Response containing cached images in base64 format */ export interface CachedImagesResponse { -success: Success17 -message?: Message15 -error?: Error16 +success: Success24 +message?: Message23 +error?: Error23 images: Images1 foundCount: Foundcount requestedCount: Requestedcount @@ -1650,29 +1979,29 @@ maxAgeHours?: Maxagehours * Response after cleaning up old images */ export interface CleanupImagesResponse { -success: Success18 -message?: Message16 -error?: Error17 +success: Success25 +message?: Message24 +error?: Error24 cleanedCount?: Cleanedcount } /** * Response after clearing memory cache */ export interface ClearMemoryCacheResponse { -success: Success19 -message?: Message17 -error?: Error18 +success: Success26 +message?: Message25 +error?: Error25 clearedCount?: Clearedcount } /** * Response for get_image_optimization_config handler */ export interface GetImageOptimizationConfigResponse { -success: Success20 -message?: Message18 -error?: Error19 +success: Success27 +message?: Message26 +error?: Error26 data?: (ImageOptimizationConfigData | null) -timestamp?: Timestamp7 +timestamp?: Timestamp14 } /** * Image optimization configuration data @@ -1682,7 +2011,6 @@ enabled?: Enabled2 strategy?: Strategy phashThreshold?: Phashthreshold minInterval?: Mininterval -maxImages?: Maximages enableContentAnalysis?: Enablecontentanalysis enableTextDetection?: Enabletextdetection } @@ -1690,9 +2018,9 @@ enableTextDetection?: Enabletextdetection * Response containing image optimization statistics */ export interface ImageOptimizationStatsResponse { -success: Success21 -message?: Message19 -error?: Error20 +success: Success28 +message?: Message27 +error?: Error27 stats?: Stats1 config?: Config } @@ -1712,7 +2040,7 @@ enabled?: Enabled3 strategy?: Strategy1 phashThreshold?: Phashthreshold1 minInterval?: Mininterval1 -maxImages?: Maximages1 +maxImages?: Maximages enableContentAnalysis?: Enablecontentanalysis1 enableTextDetection?: Enabletextdetection1 } @@ -1720,11 +2048,11 @@ enableTextDetection?: Enabletextdetection1 * Response for update_image_optimization_config handler */ export interface UpdateImageOptimizationConfigResponseV2 { -success: Success22 -message?: Message20 -error?: Error21 +success: Success29 +message?: Message28 +error?: Error28 data?: (ImageOptimizationConfigData | null) -timestamp?: Timestamp8 +timestamp?: Timestamp15 } /** * Request parameters for reading an image file. @@ -1738,11 +2066,60 @@ filePath: Filepath * Response containing image file data as base64 */ export interface ReadImageFileResponse { -success: Success23 -message?: Message21 -error?: Error22 +success: Success30 +message?: Message29 +error?: Error29 dataUrl?: Dataurl } +/** + * Response containing image persistence health check results + */ +export interface ImagePersistenceHealthResponse { +success: Success31 +message?: Message30 +error?: Error30 +data?: (ImagePersistenceHealthData | null) +} +/** + * Data model for image persistence health check + */ +export interface ImagePersistenceHealthData { +totalActions: Totalactions +actionsWithScreenshots: Actionswithscreenshots +actionsAllImagesOk: Actionsallimagesok +actionsPartialMissing: Actionspartialmissing +actionsAllMissing: Actionsallmissing +totalImageReferences: Totalimagereferences +imagesFound: Imagesfound +imagesMissing: Imagesmissing +missingRatePercent: Missingratepercent +memoryCacheCurrentSize: Memorycachecurrentsize +memoryCacheMaxSize: Memorycachemaxsize +memoryTtlSeconds: Memoryttlseconds +actionsWithIssues: Actionswithissues +} +/** + * Request parameters for cleaning up actions with missing images. + * + * @property strategy - Cleanup strategy: delete_actions, remove_references, or dry_run. + * @property actionIds - Optional list of specific action IDs to process. + */ +export interface CleanupBrokenActionsRequest { +strategy: Strategy2 +actionIds?: Actionids +} +/** + * Response after cleaning up broken action images + */ +export interface CleanupBrokenActionsResponse { +success: Success32 +message?: Message31 +error?: Error31 +actionsProcessed?: Actionsprocessed +actionsDeleted?: Actionsdeleted +referencesRemoved?: Referencesremoved +imagesRemoved?: Imagesremoved +} /** * Request parameters for creating a new model configuration. * @@ -1757,7 +2134,7 @@ dataUrl?: Dataurl * Note: Provider is automatically set to 'openai' for OpenAI-compatible APIs. */ export interface CreateModelRequest { -name: Name1 +name: Name2 provider?: Provider apiUrl: Apiurl model: Model @@ -1770,11 +2147,11 @@ apiKey: Apikey * Generic model management response with optional payload and timestamp. */ export interface ModelOperationResponse { -success: Success24 -message?: Message22 -error?: Error23 -data?: Data4 -timestamp?: Timestamp9 +success: Success33 +message?: Message32 +error?: Error32 +data?: Data6 +timestamp?: Timestamp16 } /** * Request parameters for updating a model configuration. @@ -1792,7 +2169,7 @@ timestamp?: Timestamp9 */ export interface UpdateModelRequest { modelId: Modelid2 -name?: Name2 +name?: Name3 apiUrl?: Apiurl1 model?: Model1 inputTokenPrice?: Inputtokenprice1 @@ -1829,11 +2206,11 @@ modelId: Modelid5 * Standard dashboard response with optional data payload. */ export interface DashboardResponse { -success: Success25 -message?: Message23 -error?: Error24 +success: Success34 +message?: Message33 +error?: Error33 data?: unknown -timestamp?: Timestamp10 +timestamp?: Timestamp17 } /** * Request parameters for retrieving LLM statistics of a specific model. @@ -1871,7 +2248,7 @@ requestType: Requesttype * @property modelConfigId - Optional model configuration ID filter. */ export interface GetLLMUsageTrendRequest { -dimension?: Dimension +dimension?: Dimension3 days?: Days1 startDate?: Startdate4 endDate?: Enddate4 @@ -1881,30 +2258,30 @@ modelConfigId?: Modelconfigid * Dashboard trend response with dimension metadata. */ export interface LLMUsageTrendResponse { -success: Success26 -message?: Message24 -error?: Error25 +success: Success35 +message?: Message34 +error?: Error34 data?: unknown -timestamp?: Timestamp11 -dimension?: Dimension1 +timestamp?: Timestamp18 +dimension?: Dimension4 days?: Days2 } /** * Common system operation response */ export interface SystemResponse { -success: Success27 -message?: Message25 +success: Success36 +message?: Message35 data?: unknown -timestamp: Timestamp12 +timestamp: Timestamp19 } /** * Database path response */ export interface DatabasePathResponse { -success: Success28 +success: Success37 data: DatabasePathData -timestamp: Timestamp13 +timestamp: Timestamp20 } /** * Database path data @@ -1916,11 +2293,11 @@ path: Path * Response for get_settings_info handler */ export interface GetSettingsInfoResponse { -success: Success29 -message?: Message26 -error?: Error26 +success: Success38 +message?: Message36 +error?: Error35 data?: (SettingsInfoData | null) -timestamp?: Timestamp14 +timestamp?: Timestamp21 } /** * Settings info data structure @@ -1963,19 +2340,19 @@ language?: Language1 * Update settings response */ export interface UpdateSettingsResponse { -success: Success30 -message: Message27 -timestamp: Timestamp15 +success: Success39 +message: Message37 +timestamp: Timestamp22 } /** * Response for get_image_compression_config handler */ export interface GetImageCompressionConfigResponse { -success: Success31 -message?: Message28 -error?: Error27 +success: Success40 +message?: Message38 +error?: Error36 data?: (ImageCompressionConfigData | null) -timestamp?: Timestamp16 +timestamp?: Timestamp23 } /** * Image compression configuration data @@ -2001,21 +2378,21 @@ cropThreshold?: Cropthreshold1 * Response for update_image_compression_config handler */ export interface UpdateImageCompressionConfigResponseV2 { -success: Success32 -message?: Message29 -error?: Error28 +success: Success41 +message?: Message39 +error?: Error37 data?: (ImageCompressionConfigData | null) -timestamp?: Timestamp17 +timestamp?: Timestamp24 } /** * Response for get_image_compression_stats handler */ export interface GetImageCompressionStatsResponse { -success: Success33 -message?: Message30 -error?: Error29 +success: Success42 +message?: Message40 +error?: Error38 data?: (ImageCompressionStatsData | null) -timestamp?: Timestamp18 +timestamp?: Timestamp25 } /** * Image compression statistics data @@ -2029,11 +2406,11 @@ averageCompressionRatio?: Averagecompressionratio * Response for check_initial_setup handler */ export interface CheckInitialSetupResponse { -success: Success34 -message?: Message31 -error?: Error30 +success: Success43 +message?: Message41 +error?: Error39 data?: (InitialSetupData | null) -timestamp?: Timestamp19 +timestamp?: Timestamp26 } /** * Initial setup check data @@ -2049,11 +2426,11 @@ modelCount: Modelcount * Response for complete_initial_setup handler */ export interface CompleteInitialSetupResponse { -success: Success35 -message?: Message32 -error?: Error31 +success: Success44 +message?: Message42 +error?: Error40 data?: unknown -timestamp?: Timestamp20 +timestamp?: Timestamp27 } /** * Request to update tray menu labels with i18n translations. @@ -2073,9 +2450,9 @@ quit: Quit * Response from tray update operation. */ export interface TrayUpdateResponse { -success: Success36 -message?: Message33 -error?: Error32 +success: Success45 +message?: Message43 +error?: Error41 } /** * Request to change tray icon visibility. @@ -2087,9 +2464,9 @@ visible: Visible * Response from tray visibility operation. */ export interface TrayVisibilityResponse { -success: Success37 -message?: Message34 -error?: Error33 +success: Success46 +message?: Message44 +error?: Error42 visible: Visible1 } /** @@ -2098,5 +2475,5 @@ visible: Visible1 * @property name - The name of the person. */ export interface Person { -name: Name3 +name: Name4 } diff --git a/src/lib/client/apiClient.ts b/src/lib/client/apiClient.ts index 512c53e..d041ffa 100644 --- a/src/lib/client/apiClient.ts +++ b/src/lib/client/apiClient.ts @@ -146,6 +146,46 @@ export async function splitActivityHandler( return await pyInvoke("split_activity_handler", body, options); } +/** + * Save or update an activity rating + * + * Supports multi-dimensional ratings: + * - focus_level: How focused were you? (1-5) + * - productivity: How productive was this session? (1-5) + * - importance: How important was this activity? (1-5) + * - satisfaction: How satisfied are you with the outcome? (1-5) + */ +export async function saveActivityRating( + body: Commands["save_activity_rating"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("save_activity_rating", body, options); +} + +/** + * Get all ratings for an activity + * + * Returns ratings for all dimensions that have been rated. + */ +export async function getActivityRatings( + body: Commands["get_activity_ratings"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("get_activity_ratings", body, options); +} + +/** + * Delete a specific activity rating + * + * Removes the rating for a specific dimension. + */ +export async function deleteActivityRating( + body: Commands["delete_activity_rating"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("delete_activity_rating", body, options); +} + /** * Create new agent task */ @@ -928,6 +968,90 @@ export async function restartApp( return await pyInvoke("restart_app", body, options); } +/** + * Start a new Pomodoro session + * + * Args: + * body: Request containing user_intent and duration_minutes + * + * Returns: + * StartPomodoroResponse with session data + * + * Raises: + * ValueError: If a Pomodoro session is already active or previous session is still processing + */ +export async function startPomodoro( + body: Commands["start_pomodoro"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("start_pomodoro", body, options); +} + +/** + * End current Pomodoro session + * + * Args: + * body: Request containing status (completed/abandoned/interrupted) + * + * Returns: + * EndPomodoroResponse with processing job info + * + * Raises: + * ValueError: If no active Pomodoro session + */ +export async function endPomodoro( + body: Commands["end_pomodoro"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("end_pomodoro", body, options); +} + +/** + * Get current Pomodoro session status + * + * Returns: + * GetPomodoroStatusResponse with current session info or None if no active session + */ +export async function getPomodoroStatus( + body: Commands["get_pomodoro_status"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("get_pomodoro_status", body, options); +} + +/** + * Get available Pomodoro configuration presets + * + * Returns a list of predefined configurations including: + * - Classic Pomodoro (25/5) + * - Deep Work (50/10) + * - Quick Sprint (15/3) + * - Ultra Focus (90/15) + * - Balanced Flow (40/8) + */ +export async function getPomodoroPresets( + body: Commands["get_pomodoro_presets"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("get_pomodoro_presets", body, options); +} + +/** + * Get Pomodoro statistics for a specific date + * + * Returns: + * - Number of completed sessions + * - Total focus time (minutes) + * - Average session duration (minutes) + * - List of all sessions for that day + */ +export async function getPomodoroStats( + body: Commands["get_pomodoro_stats"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("get_pomodoro_stats", body, options); +} + /** * Get processing module statistics. * @@ -1185,6 +1309,43 @@ export async function readImageFile( return await pyInvoke("read_image_file", body, options); } +/** + * Check health of image persistence system + * + * Analyzes all actions with screenshots to determine how many have missing + * image files on disk. Provides statistics for diagnostics. + * + * Returns: + * Health check results with statistics + */ +export async function checkImagePersistenceHealth( + body: Commands["check_image_persistence_health"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("check_image_persistence_health", body, options); +} + +/** + * Clean up actions with missing image references + * + * Supports three strategies: + * - delete_actions: Soft-delete actions with all images missing + * - remove_references: Clear image references, keep action metadata + * - dry_run: Report what would be cleaned without making changes + * + * Args: + * body: Cleanup request with strategy and optional action IDs + * + * Returns: + * Cleanup results with statistics + */ +export async function cleanupBrokenActionImages( + body: Commands["cleanup_broken_action_images"]["input"], + options?: InvokeOptions +): Promise { + return await pyInvoke("cleanup_broken_action_images", body, options); +} + /** * Create new model configuration * diff --git a/src/lib/config/menu.ts b/src/lib/config/menu.ts index fa45221..a124df3 100644 --- a/src/lib/config/menu.ts +++ b/src/lib/config/menu.ts @@ -1,4 +1,14 @@ -import { LucideIcon, Clock, BookOpen, CheckSquare, NotebookPen, BarChart, Settings, MessageSquare } from 'lucide-react' +import { + LucideIcon, + Clock, + Timer, + BookOpen, + CheckSquare, + NotebookPen, + BarChart, + Settings, + MessageSquare +} from 'lucide-react' export interface MenuItem { id: string @@ -12,6 +22,13 @@ export interface MenuItem { } export const MENU_ITEMS: MenuItem[] = [ + { + id: 'pomodoro', + labelKey: 'menu.pomodoro', + icon: Timer, + path: '/pomodoro', + position: 'main' + }, { id: 'activity', labelKey: 'menu.activity', diff --git a/src/lib/stores/pomodoro.ts b/src/lib/stores/pomodoro.ts new file mode 100644 index 0000000..c6d0cc3 --- /dev/null +++ b/src/lib/stores/pomodoro.ts @@ -0,0 +1,116 @@ +import { create } from 'zustand' + +/** + * Pomodoro Session State with Rounds Support + * + * Manages Pomodoro timer state with work/break phases and rounds + */ + +export interface PomodoroSession { + sessionId: string + userIntent: string + startTime: string + elapsedMinutes: number + plannedDurationMinutes: number + associatedTodoId?: string | null + associatedTodoTitle?: string | null + // Rounds configuration + workDurationMinutes?: number + breakDurationMinutes?: number + totalRounds?: number + currentRound?: number + currentPhase?: string // 'work' | 'break' | 'completed' + phaseStartTime?: string | null + completedRounds?: number + remainingPhaseSeconds?: number | null +} + +export interface PomodoroConfig { + workDurationMinutes: number + breakDurationMinutes: number + totalRounds: number +} + +export interface PomodoroPreset { + id: string + name: string + description: string + workDurationMinutes: number + breakDurationMinutes: number + totalRounds: number + icon: string +} + +export type PomodoroStatus = 'idle' | 'active' | 'ending' | 'processing' +export type PomodoroPhase = 'work' | 'break' | 'completed' + +interface PomodoroState { + // Session state + status: PomodoroStatus + session: PomodoroSession | null + error: string | null + + // Configuration state + config: PomodoroConfig + presets: PomodoroPreset[] + selectedPresetId: string | null + + // Actions + setStatus: (status: PomodoroStatus) => void + setSession: (session: PomodoroSession | null) => void + setError: (error: string | null) => void + setConfig: (config: PomodoroConfig) => void + setPresets: (presets: PomodoroPreset[]) => void + setSelectedPresetId: (id: string | null) => void + applyPreset: (presetId: string) => void + reset: () => void +} + +const DEFAULT_CONFIG: PomodoroConfig = { + workDurationMinutes: 25, + breakDurationMinutes: 5, + totalRounds: 2 +} + +export const usePomodoroStore = create((set, get) => ({ + // Initial state + status: 'idle', + session: null, + error: null, + config: DEFAULT_CONFIG, + presets: [], + selectedPresetId: null, + + // Actions + setStatus: (status) => set({ status }), + + setSession: (session) => set({ session }), + + setError: (error) => set({ error }), + + setConfig: (config) => set({ config, selectedPresetId: null }), + + setPresets: (presets) => set({ presets }), + + setSelectedPresetId: (selectedPresetId) => set({ selectedPresetId }), + + applyPreset: (presetId) => { + const preset = get().presets.find((p) => p.id === presetId) + if (preset) { + set({ + config: { + workDurationMinutes: preset.workDurationMinutes, + breakDurationMinutes: preset.breakDurationMinutes, + totalRounds: preset.totalRounds + } + }) + } + }, + + reset: () => + set({ + status: 'idle', + session: null, + error: null + }) +})) diff --git a/src/lib/stores/ui.ts b/src/lib/stores/ui.ts index 7613a20..24e193b 100644 --- a/src/lib/stores/ui.ts +++ b/src/lib/stores/ui.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' export type MenuItemId = | 'activity' + | 'pomodoro' | 'recent-events' | 'ai-summary' | 'ai-summary-knowledge' diff --git a/src/locales/en.ts b/src/locales/en.ts index cfad778..8bda349 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -166,6 +166,7 @@ export const en = { menu: { dashboard: 'Dashboard', activity: 'Activity', + pomodoro: 'Pomodoro', recentEvents: 'Recent Events', aiSummary: 'AI Summary', aiSummaryKnowledge: 'Knowledge', @@ -933,6 +934,87 @@ export const en = { description: 'AI-powered desktop activity monitoring and task recommendation system', allRightsReserved: 'All rights reserved' }, + pomodoro: { + title: 'Pomodoro Timer', + description: 'Focus Mode: Start a Pomodoro session to capture and analyze your focused work.', + intent: { + label: 'Or, enter your plan manually', + placeholder: 'e.g., Write project documentation', + hint: 'Describe your intention for this Pomodoro session', + current: 'Current task' + }, + duration: { + label: 'Duration (minutes)', + hint: 'Recommended: 25 minutes' + }, + config: { + title: 'Pomodoro Configuration', + description: 'Customize your focus session settings', + presets: 'Presets', + custom: 'Custom', + workDuration: 'Work Duration', + breakDuration: 'Break Duration', + totalRounds: 'Total Rounds', + rounds: 'rounds', + minutes: 'minutes', + summary: 'Summary', + totalTime: 'Total Time' + }, + presets: { + quickSetup: 'Quick Setup', + orCustomize: 'or customize' + }, + phase: { + work: 'Work', + break: 'Break', + completed: 'Completed' + }, + countdown: { + remaining: 'Remaining', + round: 'Round', + workDuration: 'Work duration', + breakDuration: 'Break duration', + allRoundsComplete: 'All rounds completed!' + }, + progress: { + roundIndicator: 'Round {{current}}/{{total}}', + roundsComplete: 'rounds completed', + completed: 'All Rounds Completed' + }, + phaseSwitch: { + notification: 'Switched to {{phase}} - Round {{round}}/{{total}}' + }, + todoAssociation: { + linkTodo: 'Link TODO', + unlinkTodo: 'Unlink TODO', + selectTodo: 'Select a TODO (optional)', + noTodoSelected: 'No TODO selected', + optional: 'Optional', + linkedTodo: 'Linked TODO', + noTodos: 'No pending TODOs available' + }, + start: 'Start Pomodoro', + end: 'End Session', + status: { + active: 'Session in progress', + ending: 'Ending session...', + processing: 'Analyzing session data...' + }, + started: 'Pomodoro session started', + ended: 'Session ended. Captured {{count}} records', + processing: { + background: 'Analyzing activity data in background...', + progress: 'Processed {{count}} records', + complete: 'Analysis complete! Processed {{count}} records.', + failed: 'Analysis failed: {{error}}' + }, + error: { + title: 'Error', + noIntent: 'Please describe what you plan to work on', + startFailed: 'Failed to start Pomodoro: {{error}}', + endFailed: 'Failed to end Pomodoro: {{error}}' + } + }, debug: { welcomeFlowReset: '🔄 Welcome flow reset', setupAlreadyActive: 'ℹ️ Setup is already active', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 166c747..19f8a54 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -165,6 +165,7 @@ export const zhCN = { menu: { dashboard: '仪表盘', activity: '活动记录', + pomodoro: '番茄钟', recentEvents: '最近事件', aiSummary: 'AI 总结', aiSummaryKnowledge: '知识整理', @@ -923,6 +924,87 @@ export const zhCN = { description: 'AI 驱动的桌面活动监控与任务推荐系统', allRightsReserved: '版权所有' }, + pomodoro: { + title: '番茄钟', + description: '专注模式:开启番茄钟会话以捕获并分析您的专注工作。', + intent: { + label: '或者,手动输入计划', + placeholder: '例如:编写项目文档', + hint: '描述你在这个番茄钟期间的工作意图', + current: '当前任务' + }, + duration: { + label: '时长(分钟)', + hint: '推荐:25 分钟' + }, + config: { + title: '番茄钟配置', + description: '自定义你的专注会话设置', + presets: '预设方案', + custom: '自定义', + workDuration: '工作时长', + breakDuration: '休息时长', + totalRounds: '总轮次', + rounds: '轮', + minutes: '分钟', + summary: '摘要', + totalTime: '总时长' + }, + presets: { + quickSetup: '快速设置', + orCustomize: '或自定义' + }, + phase: { + work: '工作', + break: '休息', + completed: '已完成' + }, + countdown: { + remaining: '剩余时间', + round: '轮次', + workDuration: '工作时长', + breakDuration: '休息时长', + allRoundsComplete: '所有轮次已完成!' + }, + progress: { + roundIndicator: '第 {{current}}/{{total}} 轮', + roundsComplete: '轮已完成', + completed: '所有轮次已完成' + }, + phaseSwitch: { + notification: '切换到{{phase}} - 第 {{round}}/{{total}} 轮' + }, + todoAssociation: { + linkTodo: '关联待办', + unlinkTodo: '取消关联', + selectTodo: '选择待办(可选)', + noTodoSelected: '未选择待办', + optional: '可选', + linkedTodo: '已关联待办', + noTodos: '暂无待办事项' + }, + start: '开始番茄钟', + end: '结束会话', + status: { + active: '会话进行中', + ending: '正在结束会话...', + processing: '正在分析会话数据...' + }, + started: '番茄钟会话已开始', + ended: '会话已结束,捕获了 {{count}} 条记录', + processing: { + background: '正在后台分析活动数据...', + progress: '已处理 {{count}} 条记录', + complete: '分析完成!已处理 {{count}} 条记录。', + failed: '分析失败:{{error}}' + }, + error: { + title: '错误', + noIntent: '请描述你计划做什么', + startFailed: '启动番茄钟失败:{{error}}', + endFailed: '结束番茄钟失败:{{error}}' + } + }, debug: { welcomeFlowReset: '🔄 欢迎流程已重置', setupAlreadyActive: 'ℹ️ 设置已处于激活状态', diff --git a/src/routes/Index.tsx b/src/routes/Index.tsx index 9965aeb..b2b4c35 100644 --- a/src/routes/Index.tsx +++ b/src/routes/Index.tsx @@ -6,6 +6,7 @@ import { LoadingPage } from '@/components/shared/LoadingPage' // Lazy-load page components const ActivityView = lazy(() => import('@/views/Activity')) +const PomodoroView = lazy(() => import('@/views/Pomodoro')) const AIKnowledgeView = lazy(() => import('@/views/AIKnowledge')) const AITodosView = lazy(() => import('@/views/AITodos')) const AIDiaryView = lazy(() => import('@/views/AIDiary')) @@ -36,6 +37,14 @@ export const router = createBrowserRouter([ index: true, element: }, + { + path: 'pomodoro', + element: ( + }> + + + ) + }, { path: 'activity', element: ( @@ -44,14 +53,6 @@ export const router = createBrowserRouter([ ) }, - // { - // path: 'events', - // element: ( - // }> - // - // - // ) - // }, { path: 'insights', element: diff --git a/src/types/auto-imports.d.ts b/src/types/auto-imports.d.ts index 761c151..9db8100 100644 --- a/src/types/auto-imports.d.ts +++ b/src/types/auto-imports.d.ts @@ -17,6 +17,7 @@ declare global { const Chat: typeof import('../views/Chat')['default'] const Dashboard: typeof import('../views/Dashboard')['default'] const MainLayout: typeof import('../layouts/MainLayout')['MainLayout'] + const Pomodoro: typeof import('../views/Pomodoro')['default'] const RecentEvents: typeof import('../views/RecentEvents')['default'] const Settings: typeof import('../views/Settings')['default'] const createRef: typeof import('react')['createRef'] diff --git a/src/views/Activity.tsx b/src/views/Activity.tsx index 9a60934..d0fee0d 100644 --- a/src/views/Activity.tsx +++ b/src/views/Activity.tsx @@ -316,6 +316,7 @@ export default function ActivityView() { {/* Main Content: Timeline */}
+ {/* Timeline Content */} {isLoading ? (
diff --git a/src/views/Pomodoro.tsx b/src/views/Pomodoro.tsx new file mode 100644 index 0000000..d163c63 --- /dev/null +++ b/src/views/Pomodoro.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from 'react-i18next' + +import { PageLayout } from '@/components/layout/PageLayout' +import { PageHeader } from '@/components/layout/PageHeader' +import { PomodoroTimer } from '@/components/pomodoro/PomodoroTimer' + +export default function Pomodoro() { + const { t } = useTranslation() + + return ( + + + +
+
+ +
+
+
+ ) +}