From 62b18118e046e5f2ed25528d0fb4f4bb04fc5112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 01:28:24 +0200 Subject: [PATCH 1/9] refactor: update project version to 0.3.0 and enhance multi-project support --- CHANGELOG.md | 13 ++ README.es.md | 76 ++++----- README.md | 82 +++++----- docs/ARCHITECTURE.md | 124 ++++++-------- docs/CLI-GUIDE.md | 54 ++++--- docs/DEVELOPMENT.md | 85 ++++------ docs/es/ARCHITECTURE.es.md | 122 ++++++-------- docs/es/CLI-GUIDE.es.md | 54 ++++--- docs/es/DEVELOPMENT.es.md | 85 ++++------ pyproject.toml | 2 +- setup.py | 2 +- tests/test_cli.py | 82 +++++----- tests/test_io.py | 131 +++++++-------- tests/test_storage.py | 122 +++++++------- time_balance/__init__.py | 12 +- time_balance/cli.py | 288 +++++++++++++++++++++------------ time_balance/constants.py | 35 +++- time_balance/i18n.py | 24 ++- time_balance/io.py | 34 ++-- time_balance/storage.py | 322 +++++++++++++++++++++++-------------- 20 files changed, 940 insertions(+), 809 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af8f88..0982ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## [0.3.0] - 2026-04-24 +### Added +- **Global Installation**: The app now stores data in standard system paths (XDG compliant), making it truly global. +- **SQLite Backend**: Replaced JSON storage with SQLite for improved performance and data integrity. +- **Multi-project Support**: New project management menu allows creating, switching, and editing multiple work contexts. +- **Migration Tool**: New `--migrate ` command to import legacy history files into the new system. +- **Active Project Context**: The application now remembers the last used project globally. + +### Changed +- Refactored `storage.py` to use `DatabaseManager`. +- Updated `cli.py` to support project management submenus. +- Decoupled `io.py` from storage logic for better testability. + ## [0.2.0] - 2026-04-20 ### Added - **Globalization (i18n)**: Fully translated UI with automatic language detection (English and Spanish supported). diff --git a/README.es.md b/README.es.md index 41ce5d3..e1acc2e 100644 --- a/README.es.md +++ b/README.es.md @@ -1,6 +1,6 @@ # time-balance 🕒 -> Una herramienta de terminal ligera y profesional para registrar tus jornadas laborales y controlar tu saldo acumulado de horas. +> Una herramienta de terminal profesional y global para gestionar múltiples proyectos, registrar jornadas laborales y controlar tu saldo de horas. [Read in English 🇺🇸](README.md) @@ -9,85 +9,75 @@ ## Descripción -`time-balance` es una aplicación de consola diseñada para personas que necesitan llevar un control riguroso de su tiempo trabajado. Calcula automáticamente la diferencia diaria respecto a una jornada base (7h 45m por defecto) y mantiene un saldo acumulado para que siempre sepas si "debes" horas o tienes saldo a favor. +`time-balance` es ahora una aplicación de consola **global**. Ya no depende de dónde la ejecutes; tus proyectos y registros se almacenan en una base de datos SQLite centralizada en tu sistema. Calcula automáticamente la diferencia diaria respecto a una jornada base y mantiene un saldo acumulado por cada proyecto de forma independiente. --- -## Instalación rápida +## Instalación Global + +Para instalar la aplicación de forma que esté disponible en cualquier terminal: ```bash # Clonar y entrar al directorio git clone cd time-balance -# Instalar la aplicación -python3 -m pip install . +# Instalar de forma global en tu usuario +pip install . ``` --- ## Uso de la aplicación -### 1. Menú Interactivo (Recomendado) -Simplemente ejecuta el comando para abrir el centro de control: +### 1. Centro de Control (Menú Interactivo) +Ejecuta el comando desde cualquier carpeta para abrir el gestor: ```bash time-balance ``` -La interfaz detecta automáticamente el idioma de tu sistema (soporta inglés y español). Puedes registrar nuevas jornadas, ver tu historial reciente o importar/exportar tus datos. +### 2. Gestión de Proyectos +Dentro del menú, la **opción 3** te permite crear nuevos proyectos (ej: "Cliente A", "Proyecto Personal") y cambiar entre ellos. La aplicación recordará cuál fue el último proyecto que usaste la próxima vez que la abras. + +### 3. Migración de datos antiguos (v0.2.x) +Si tienes archivos `historial_hours.json` de versiones anteriores, puedes importarlos fácilmente a un nuevo proyecto en la base de datos global: + +```bash +time-balance --migrate ./ruta/al/historial_hours.json +``` -### 2. Comandos Rápidos (Modo Directo) +### 4. Consultas Rápidas Consulta tu estado sin entrar al menú: ```bash -# Ver solo tu saldo acumulado actual +# Ver el saldo del proyecto activo time-balance --status -# Listar los últimos 10 días registrados +# Listar los últimos 10 registros del proyecto activo time-balance --list 10 -# Forzar un idioma específico +# Forzar un idioma (es/en) time-balance --lang es - -# Consultar la versión instalada -time-balance --version ``` --- -## Características Principales - -- ✅ **Registro ágil**: Introduce horas y minutos de forma sencilla. -- ✅ **Seguridad ante todo**: Escritura atómica de datos y backups automáticos en operaciones críticas. -- ✅ **Multi-idioma**: Cambia sin problemas entre inglés y español. -- ✅ **Importación flexible**: Combina historiales de diferentes dispositivos (merge) o restaura copias completas. -- ✅ **Sin dependencias**: 100% Python estándar. Funciona en Windows, macOS y Linux. -- ✅ **Privacidad absoluta**: Todo se guarda localmente en un archivo JSON legible. - ---- - -## Configuración Avanzada - -### Ubicación del historial -Por defecto, la aplicación crea `historial_hours.json` en la carpeta actual. Si prefieres centralizarlo, define la variable de entorno: - -```bash -export HISTORIAL_PATH="~/.config/mi_historial.json" -``` +## Características de la Versión 0.3.0 -### Jornada Base -La aplicación usa por defecto **7h 45m**. Puedes modificarla a través del menú interactivo en la configuración del proyecto. +- ✅ **Almacenamiento SQLite**: Persistencia robusta y profesional en rutas estándar (XDG). +- ✅ **Multi-proyecto**: Gestiona diferentes contextos de trabajo desde una única instalación. +- ✅ **Instalación Global**: Accede a tus datos desde cualquier ubicación en la terminal. +- ✅ **Migración Automática**: Comando dedicado para traer tus datos de la era JSON. +- ✅ **Privacidad**: Todo sigue siendo 100% local en tu equipo. --- -## Próximos Pasos (Roadmap) 🚀 - -Estamos trabajando para llevar `time-balance` al siguiente nivel: - -- 🗄️ **Migración a SQLite**: Evolucionar el almacenamiento interno a una base de datos robusta para mejorar la integridad y velocidad, manteniendo JSON como estándar de importación/exportación. -- 📂 **Gestión Multiproyecto**: Permitir el cambio rápido entre diferentes contextos de trabajo desde una instalación centralizada. -- ☁️ **Sincronización Inteligente**: Ubicación configurable para facilitar el uso en carpetas compartidas (Dropbox, Drive) y backups automáticos. +## Ubicación de los datos +La base de datos se guarda automáticamente en: +- **macOS**: `~/Library/Application Support/time-balance/time_balance.db` +- **Linux**: `~/.local/share/time-balance/time_balance.db` +- **Windows**: `%APPDATA%/time-balance/time_balance.db` --- diff --git a/README.md b/README.md index d847a3a..70ea76c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # time-balance 🕒 -> A lightweight, professional command-line tool to track your working hours and manage your accumulated balance. +> A professional, global terminal tool to manage multiple projects, track workdays, and manage your hour balance. [Leer en español 🇪🇸](README.es.md) @@ -9,86 +9,88 @@ ## Description -`time-balance` is a CLI application designed for people who need rigorous control over their worked time. It automatically calculates the daily difference against a base workday (7h 45m by default) and maintains an accumulated balance so you always know if you "owe" hours or have a surplus. +`time-balance` is now a **global** console application. It no longer depends on where you run it; your projects and records are stored in a centralized SQLite database on your machine. It automatically calculates the daily difference against a base workday and maintains an accumulated balance for each project independently. --- -## Quick Installation +## Global Installation + +To install the application so it's available in any terminal: ```bash # Clone and enter directory git clone cd time-balance -# Install the application -python3 -m pip install . +# Install globally for your user +pip install . ``` --- ## Usage -### 1. Interactive Menu (Recommended) -Simply run the command to open the control center: +### 1. Control Center (Interactive Menu) +Simply run the command from any folder to open the manager: ```bash time-balance ``` -The interface automatically detects your system language (supports English and Spanish). You can register new days, view recent history, or import/export your data. +### 2. Multi-Project Management +Inside the menu, **option 3** allows you to create new projects (e.g., "Client A", "Personal") and switch between them. The app remembers your last active project globally. + +### 3. Migrating Legacy Data (v0.2.x) +If you have `historial_hours.json` files from previous versions, you can easily import them into a new project in the global database: + +```bash +time-balance --migrate ./path/to/historial_hours.json +``` -### 2. Quick Commands (Direct Mode) -Consult your status without entering the menu: +### 4. Quick Commands +Check your status without entering the menu: ```bash -# Show current accumulated balance only +# Show balance for the active project time-balance --status -# List last 10 recorded days +# List last 10 records for the active project time-balance --list 10 -# Force a specific language +# Force a specific language (en/es) time-balance --lang en - -# Check installed version -time-balance --version ``` --- -## Key Features +## Version 0.3.0 Features -- ✅ **Agile Registration**: Input hours and minutes easily. -- ✅ **Safety First**: Atomic data writing and automatic backups for critical operations. -- ✅ **Multi-language**: Seamlessly switch between English and Spanish. -- ✅ **Flexible Import**: Merge histories from different devices or restore full copies. -- ✅ **Zero Dependencies**: 100% Standard Python. Works on Windows, macOS, and Linux. -- ✅ **Privacy-Focused**: All data is stored locally in a readable JSON file. +- ✅ **SQLite Backend**: Robust and professional persistence in standard paths (XDG). +- ✅ **Multi-project**: Manage different work contexts from a single installation. +- ✅ **Global Installation**: Access your data from any terminal location. +- ✅ **Migration Tool**: Dedicated command to bring your data from the JSON era. +- ✅ **Privacy-Focused**: All data remains 100% local on your machine. --- -## Advanced Configuration - -### History Location -By default, the app creates `historial_hours.json` in the current folder. To centralize it, define the environment variable: - -```bash -export HISTORIAL_PATH="~/.config/time-balance/my_history.json" -``` - -### Base Workday -The app defaults to **7h 45m**. If your workday is different, you can modify the constants in `time_balance/constants.py` and reinstall, or configure it via the interactive menu. +## Data Location +The database is automatically saved in: +- **macOS**: `~/Library/Application Support/time-balance/time_balance.db` +- **Linux**: `~/.local/share/time-balance/time_balance.db` +- **Windows**: `%APPDATA%/time-balance/time_balance.db` --- -## Future Steps (Roadmap) 🚀 +## Development and Contributions -We are working to take `time-balance` to the next level: +If you want to contribute or understand how it works internally: +- [**ARCHITECTURE.md**](docs/ARCHITECTURE.md): System design and modules. +- [**DEVELOPMENT.md**](docs/DEVELOPMENT.md): Technical guide for developers. +- [**CONTRIBUTING.md**](docs/CONTRIBUTING.md): How to submit improvements and translations. + +## License -- 🗄️ **SQLite Migration**: Evolving internal storage to a robust database for better integrity and speed, keeping JSON as the standard for import/export. -- 📂 **Multi-project Management**: Switch between different work contexts from a single centralized installation. -- ☁️ **Smart Sync**: Simplified data location settings for cloud folders (Dropbox, iCloud, Drive) and automatic backups. -- 🎨 **Rich UI**: Enhanced terminal experience using modern libraries for clearer tables, colors, and better usability. +This project is Open Source under the [GPL-3.0](LICENSE) license. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 96c0f94..de178ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Overview -`time-balance` is a terminal application to track working hours and manage accumulated balances. It is designed under principles of modularity, data integrity, and ease of use. +`time-balance` is a terminal application to track working hours and manage accumulated balances. In version 0.3.0, the application evolved into a **global** and **multi-project** tool, using **SQLite** as the persistence engine and following XDG standards for data storage. ## Project Structure @@ -11,17 +11,17 @@ time-balance/ ├── time_balance/ # Main package │ ├── __init__.py # Facade (re-exports public API) │ ├── __main__.py # Entry point for module execution -│ ├── constants.py # Centralized configuration and constants +│ ├── constants.py # Global path configuration and constants │ ├── core.py # Business logic (formatting, calculations) -│ ├── storage.py # Persistence and atomic storage -│ ├── io.py # Validation, import, and export -│ ├── cli.py # User interface and arguments +│ ├── storage.py # Persistence (SQLite) and DatabaseManager +│ ├── io.py # External file validation and reading (JSON) +│ ├── cli.py # User interface (Menu and Submenus) │ └── i18n.py # Internationalization system ├── tests/ # Modular test suite │ ├── test_core.py -│ ├── test_storage.py -│ ├── test_io.py -│ └── test_cli.py +│ ├── test_storage.py # Validates SQLite transactions +│ ├── test_io.py # Validates JSON schema reading +│ └── test_cli.py # Validates menus and interaction ├── docs/ # Documentation ├── README.md # User guide └── CHANGELOG.md # Change history @@ -30,81 +30,59 @@ time-balance/ ## Modules and Components ### 1. **`core.py` (Business Logic)** -Contains the mathematical "brain" of the system, independent of I/O. -- `format_time()`: Converts minutes to readable format +/- Xh Ym. -- `calculate_total_balance()`: Sums differences from a list of records. +Contains mathematical and formatting functions independent of persistence. +- `format_time()`: Converts minutes to readable format `+/- Xh Ym`. +- `calculate_total_balance()`: Used mainly during imports to validate balances. -### 2. **`storage.py` (Persistence Layer)** -Manages the history file lifecycle and physical integrity. -- `load_data()`: Loads JSON from the structured history file. -- `save_data()`: **Atomic** (crash-safe) writing using temporary files and replacement. -- `_create_backup()`: Generates versioned backups before critical operations. +### 2. **`storage.py` (SQLite Persistence Layer)** +Implements the `DatabaseManager` class which centralizes all SQL operations. +- **Project Management**: CRUD for multiple work contexts. +- **Records**: Efficient storage of workdays by project ID. +- **Global Settings**: `settings` table to remember the active project and language. +- **Standard Location**: Uses `pathlib` to comply with XDG (Application Support on macOS, .local/share on Linux). ### 3. **`cli.py` (Presentation Layer)** -Handles final user interaction. -- Supports **command arguments** (`--status`, `--list`, `--version`, `--lang`) for quick queries. -- Provides an **interactive menu** for daily management. -- Allows dynamic project configuration (name and base workday). +Handles user interaction. +- **Main Menu**: Logging and viewing operations for the active project. +- **Project Management**: Submenu to create, switch, and edit projects. +- **Migration Command**: `--migrate` flag to import data from legacy JSON files. ### 4. **`io.py` (Data Exchange)** -Logic to import and export histories between different systems. -- Rigorous JSON schema validation. -- Support for history merging (`merge`) or total replacement (`overwrite`). +Logic to validate and read external JSON files. +- `read_history_file()`: Validates schemas from previous versions (v0.2.x). +- `export_history()`: Generates a JSON dump of the active project. ### 5. **`i18n.py` (Internationalization)** -Simple translation system supporting multiple languages (English and Spanish included). -- Automatically detects system language. -- Provides the `translate()` utility for the CLI. - -## Data Schema (JSON) - -Each project is saved with its own configuration context to allow future multi-project support. - -```json -{ - "metadata": { - "project_name": "My Project", - "hours_base": 7, - "minutes_base": 45, - "version": "1.0", - "language": "auto" - }, - "records": { - "2026-04-19": { - "hours": 8, - "minutes": 0, - "difference": 15 - } - } -} -``` +Simple translation engine supporting English and Spanish. + +## Database Schema (SQLite) + +### `projects` table +| Column | Type | Description | +| :--- | :--- | :--- | +| id | INTEGER PK | Unique identifier | +| name | TEXT UNIQUE | Project name | +| base_hours | INTEGER | Base workday (hours) | +| base_minutes | INTEGER | Base workday (minutes) | + +### `records` table +| Column | Type | Description | +| :--- | :--- | :--- | +| id | INTEGER PK | Unique identifier | +| project_id | INTEGER FK | Reference to project | +| date | TEXT | Date (YYYY-MM-DD) | +| hours | INTEGER | Hours worked | +| minutes | INTEGER | Minutes worked | +| difference | INTEGER | Balance in minutes | ## Reliability and Safety -- **Integrity**: All writes are atomic. If the program closes unexpectedly, data is not corrupted. -- **Privacy**: 100% local storage in plain text (JSON). +- **Transactions**: All critical database operations are protected by SQLite transactions. +- **Atomic Export**: JSON dumps still use atomic writing for safety. +- **Privacy**: All data is stored locally on the user's machine. ## Testing -The test suite is divided to match the package architecture: -- `test_core`: Validates calculation algorithms. -- `test_storage`: Validates persistence and backups. -- `test_io`: Validates import/export logic. -- `test_cli`: Validates UI and command arguments. - -## Future Extensibility (Technical Evolution) - -The system is designed to evolve into a professional time management solution: - -1. **Relational Storage (SQLite)**: - - Migrate internal persistence from JSON to SQLite. - - JSON format will remain exclusively for import/export flows (portability). - -2. **Centralized Multi-project Support**: - - Implement a global project registry to switch contexts without changing directories. - -3. **Cloud Sync**: - - Allow database location in cloud storage services for automatic synchronization. - -4. **Modern UI (Rich UI)**: - - Integrate libraries like `rich` to improve the presentation of tables and colors. +The test suite has been adapted to use temporary databases: +- `test_storage`: Verifies the schema and `DatabaseManager` behavior. +- `test_cli`: Uses patching (mocking) to simulate user input over database logic. diff --git a/docs/CLI-GUIDE.md b/docs/CLI-GUIDE.md index 17ecc21..854efd5 100644 --- a/docs/CLI-GUIDE.md +++ b/docs/CLI-GUIDE.md @@ -1,18 +1,21 @@ # Usage Guide: CLI Interface -`time-balance` offers a dual interface: an interactive menu for daily use and direct commands for quick queries. +`time-balance` offers a dual interface: an interactive menu for daily use and direct commands for quick queries. In version 0.3.0, the application is **global** and supports **multiple projects**. ## Direct Commands (Fast Mode) -You can query information without entering the interactive menu using flags: +You can query information or perform actions without entering the interactive menu using flags: ```bash -# View current accumulated balance +# View current accumulated balance of the active project time-balance --status -# List last 10 records +# List last 10 records of the active project time-balance --list 10 +# Migrate a legacy JSON history file to a new global project +time-balance --migrate ./path/to/history.json + # Force language (en/es) time-balance --lang en @@ -30,11 +33,11 @@ time-balance ### Main Menu -The interface automatically detects your system language, but you can change it in the project configuration. +The interface automatically detects your system language. It shows the status of the **active project**. ``` ================================================== - PROJECT: PROJECT NAME + PROJECT: MY WORK PROJECT TOTAL ACCUMULATED BALANCE: +2h 15m (Daily base: 7h 45m) ================================================== @@ -42,7 +45,7 @@ The interface automatically detects your system language, but you can change it Options: 1. Register workday (or correct day) 2. View recent records -3. Configure project (name/hours) +3. Manage projects (switch/create/edit) 4. Export history to file 5. Import history from file 6. Exit @@ -53,32 +56,31 @@ Choose option: _ ## Detailed Options ### 1. Register Workday -Allows you to record worked hours. If the day already exists, it will ask for confirmation to overwrite. It automatically calculates the difference against the base workday configured for **that project**. +Record worked hours for a specific date (defaults to today). It calculates the difference against the base workday of the **current active project**. ### 2. View Recent Records -Displays a table with the 5 most recent records, including hours worked and the impact on the balance (positive or negative). +Displays the last 5 records (or as many as specified) for the active project. -### 3. Configure Project -Allows you to customize the project name and the base workday (hours/minutes) for the current file. This is saved in the JSON metadata. +### 3. Manage Projects +Opens a submenu to: +- **Switch project**: Change which project is currently active globally. +- **Create new project**: Initialize a new work context with its own base workday. +- **Edit project**: Change the name or base workday of the current project. ### 4. Export History -Creates a backup copy in structured JSON format at the path of your choice. +Exports the active project's data to a structured JSON file. ### 5. Import History -- **Merge Mode**: Combines external data with the current one. Imported data wins in case of a date conflict. -- **Overwrite Mode**: Replaces the entire file (metadata and records) with the new one. It creates an automatic backup before proceeding. - -## Environment Variables Configuration - -You can centralize your history by defining the path in your shell configuration file (`.bashrc` or `.zshrc`): - -```bash -export HISTORIAL_PATH="~/.config/time-balance/main_history.json" -``` +Imports data from a JSON file into the **current active project**. +- **Merge Mode**: Adds new records and updates existing ones. +- **Overwrite Mode**: Clears all current records before importing. --- -## Usage Tips -- Press **ENTER** in the date field to use today quickly. -- Use `--status` in your automation scripts to see your balance when starting the terminal. -- Export your data regularly if you are not using a synchronized folder. +## Data Persistence +Data is stored in a centralized SQLite database. You no longer need to worry about `historial_hours.json` files in your project folders unless you want to export or migrate them. + +### Default Paths +- **macOS**: `~/Library/Application Support/time-balance/` +- **Linux**: `~/.local/share/time-balance/` +- **Windows**: `%APPDATA%/time-balance/` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1f50d53..9572e29 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,71 +1,52 @@ # Developer Guide -This guide provides technical details for developers who want to integrate with `time-balance` or contribute to its development. +This guide provides technical details for developers who want to contribute to version 0.3.x of `time-balance`. ## Project Philosophy -- **Standard Library Only**: No external dependencies allowed for the core functionality. -- **Clean Code**: Descriptive naming, modularity, and high test coverage. -- **Safety**: Atomic writes and defensive data validation. +- **Standard Library Only**: The core and persistence must rely solely on native Python libraries (like `sqlite3`). +- **Clean Code**: Descriptive naming (minimum 3 characters), modularity, and high test coverage. +- **Transactionality**: All database writes must be consistent and secure. -## Module Structure +## Layer Structure -The project is organized into logical layers: +### 1. Persistence Logic (`storage.py`) +Manages the global SQLite database. +- `DatabaseManager`: Central class for project and record CRUD. +- `db`: Global instance used by the rest of the application. -### 1. Business Logic (`core.py`) -Pure functions for time calculations. -- `format_time(minutes)`: Returns strings like `"-1h 30m"`. -- `calculate_total_balance(records)`: Sums differences from the data dictionary. - -### 2. Persistence (`storage.py`) -Handles disk I/O and atomic writing. -- `load_data(path)`: Returns a structured dict with `metadata` and `records`. -- `save_data(data, path)`: Atomic write via temporary files. +### 2. Presentation Layer (`cli.py`) +Controls menu flow and user interaction. +- `manage_projects()`: Logic for the multi-project management submenu. +- `register_day()`: Interacts with `db` to save workdays in the active project. ### 3. Data Exchange (`io.py`) -Logic for moving data between systems. -- `export_history(dest)`: Exports the full structured JSON. -- `import_history(src, mode)`: Validates and merges/overwrites data. - -### 4. User Interface (`cli.py`) -The presentation layer using `argparse` and a loop-based menu. - -### 5. Internationalization (`i18n.py`) -Simple translation engine. Uses `translate(key, lang)` to fetch strings. +Functions for reading legacy JSON and exporting dumps. +- `read_history_file(path)`: Reads a JSON and validates it against the old schema. ## Running Tests -We use the standard `unittest` framework: +It is essential to keep tests updated. A temporary database is used to avoid messing with the developer's real data. ```bash # Run all tests python3 -m unittest discover -v tests ``` -## Adding a New Language - -1. Open `time_balance/i18n.py`. -2. Add a new entry to the `STRINGS` dictionary with the ISO 639-1 code (e.g., `"fr"` for French). -3. Translate all keys from the `"en"` template. -4. Update the `--lang` choices list in `cli.py` to include the new language code. - -## Data Schema Reference - -```json -{ - "metadata": { - "project_name": "string", - "hours_base": "int", - "minutes_base": "int", - "version": "string", - "language": "string (en|es|auto)" - }, - "records": { - "YYYY-MM-DD": { - "hours": "int", - "minutes": "int", - "difference": "int (worked_minutes - base_minutes)" - } - } -} -``` +## Database Management + +The database is initialized automatically on the first run. The schema is defined in the `_initialize_database()` method of `DatabaseManager`. + +### Data Paths (XDG) +- macOS: `~/Library/Application Support/time-balance/` +- Linux: `~/.local/share/time-balance/` + +## How to add a CLI command + +1. Modify the `main()` function in `cli.py`. +2. Add the new flag in `argparse`. +3. Implement the corresponding logic, using `db` if it requires data access or `translate()` for output. + +## Record Schema Reference +Workdays are saved as difference minutes (`worked_minutes - base_minutes`). +- Example: If the base is 7h 45m (465 min) and 8h (480 min) are worked, the saved difference is `+15`. diff --git a/docs/es/ARCHITECTURE.es.md b/docs/es/ARCHITECTURE.es.md index c584944..a55e605 100644 --- a/docs/es/ARCHITECTURE.es.md +++ b/docs/es/ARCHITECTURE.es.md @@ -2,7 +2,7 @@ ## Visión General -`time-balance` es una aplicación de terminal para registrar jornadas laborales y gestionar el saldo horario acumulado. Está diseñada bajo principios de modularidad, integridad de datos y facilidad de uso. +`time-balance` es una aplicación de terminal para registrar jornadas laborales y gestionar el saldo horario acumulado. En su versión 0.3.0, la aplicación ha evolucionado a una herramienta **global** y **multiproyecto**, utilizando **SQLite** como motor de persistencia siguiendo los estándares XDG para el almacenamiento de datos. ## Estructura del Proyecto @@ -11,17 +11,17 @@ time-balance/ ├── time_balance/ # Paquete principal │ ├── __init__.py # Fachada (reexporta la API pública) │ ├── __main__.py # Punto de entrada para ejecución como módulo -│ ├── constants.py # Configuración y constantes centralizadas +│ ├── constants.py # Configuración de rutas globales y constantes │ ├── core.py # Lógica de negocio (formateo, cálculos) -│ ├── storage.py # Persistencia y almacenamiento atómico -│ ├── io.py # Validación, importación y exportación -│ ├── cli.py # Interfaz de usuario y argumentos +│ ├── storage.py # Persistencia (SQLite) y DatabaseManager +│ ├── io.py # Validación y lectura de archivos externos (JSON) +│ ├── cli.py # Interfaz de usuario (Menú y Submenús) │ └── i18n.py # Sistema de internacionalización ├── tests/ # Suite de tests modular │ ├── test_core.py -│ ├── test_storage.py -│ ├── test_io.py -│ └── test_cli.py +│ ├── test_storage.py # Valida transacciones SQLite +│ ├── test_io.py # Valida lectura de esquemas JSON +│ └── test_cli.py # Valida menús e interacción ├── docs/ # Documentación ├── README.md # Guía de usuario (Inglés) └── CHANGELOG.md # Historial de cambios @@ -30,79 +30,59 @@ time-balance/ ## Módulos y Componentes ### 1. **`core.py` (Lógica de Negocio)** -Contiene el "cerebro" matemático del sistema, independiente de la I/O. -- `format_time()`: Convierte minutos a formato legible +/- Xh Ym. -- `calculate_total_balance()`: Suma las diferencias de una lista de registros. +Contiene las funciones matemáticas y de formateo independientes de la persistencia. +- `format_time()`: Convierte minutos a formato legible `+/- Xh Ym`. +- `calculate_total_balance()`: Utilizado principalmente en importaciones para validar saldos. -### 2. **`storage.py` (Capa de Persistencia)** -Gestiona el ciclo de vida del archivo de datos e integridad física. -- `load_data()`: Carga el JSON estructurado del historial. -- `save_data()`: Escritura **atómica** (crash-safe) mediante archivos temporales. -- `_create_backup()`: Genera respaldos versionados antes de operaciones críticas. +### 2. **`storage.py` (Capa de Persistencia SQLite)** +Implementa la clase `DatabaseManager` que centraliza todas las operaciones SQL. +- **Gestión de Proyectos**: CRUD para múltiples contextos de trabajo. +- **Registros**: Almacenamiento eficiente de jornadas por ID de proyecto. +- **Configuración Global**: Tabla `settings` para recordar el proyecto activo e idioma. +- **Ubicación Estándar**: Uso de `pathlib` para cumplir con XDG (Application Support en macOS, .local/share en Linux). ### 3. **`cli.py` (Capa de Presentación)** -Maneja la interacción con el usuario final. -- Soporta **argumentos de comando** (`--status`, `--list`, `--version`, `--lang`) para consultas rápidas. -- Proporciona un **menú interactivo** bilingüe para la gestión diaria. -- Permite la configuración dinámica del proyecto (nombre y jornada base). +Maneja la interacción con el usuario. +- **Menú Principal**: Operaciones de registro y visualización del proyecto activo. +- **Gestión de Proyectos**: Submenú para crear, cambiar y editar proyectos. +- **Comando de Migración**: Flag `--migrate` para importar datos desde JSON antiguos. ### 4. **`io.py` (Intercambio de Datos)** -Lógica para importar y exportar historiales entre diferentes sistemas. -- Validación rigurosa de esquemas JSON. -- Soporte para mezcla de historiales (`merge`) o reemplazo total (`overwrite`). +Lógica para validar y leer archivos JSON externos. +- `read_history_file()`: Valida esquemas de versiones anteriores (v0.2.x). +- `export_history()`: Genera un volcado JSON del proyecto activo. ### 5. **`i18n.py` (Internacionalización)** -Motor de traducción simple que permite que la aplicación hable varios idiomas (actualmente inglés y español). Detecta automáticamente el idioma del sistema. - -## Esquema de Datos (JSON) - -Cada proyecto se guarda con su propio contexto de configuración. Las claves se almacenan en inglés para consistencia técnica. - -```json -{ - "metadata": { - "project_name": "Mi Proyecto", - "hours_base": 7, - "minutes_base": 45, - "version": "1.0", - "language": "auto" - }, - "records": { - "2026-04-19": { - "hours": 8, - "minutes": 0, - "difference": 15 - } - } -} -``` +Motor de traducción simple que soporta inglés y español. + +## Esquema de Base de Datos (SQLite) + +### Tabla `projects` +| Columna | Tipo | Descripción | +| :--- | :--- | :--- | +| id | INTEGER PK | Identificador único | +| name | TEXT UNIQUE | Nombre del proyecto | +| base_hours | INTEGER | Jornada base (horas) | +| base_minutes | INTEGER | Jornada base (minutos) | + +### Tabla `records` +| Columna | Tipo | Descripción | +| :--- | :--- | :--- | +| id | INTEGER PK | Identificador único | +| project_id | INTEGER FK | Referencia al proyecto | +| date | TEXT | Fecha (YYYY-MM-DD) | +| hours | INTEGER | Horas trabajadas | +| minutes | INTEGER | Minutos trabajados | +| difference | INTEGER | Balance en minutos | ## Confiabilidad y Seguridad -- **Integridad**: Todas las escrituras son atómicas. Si el programa se cierra inesperadamente, los datos no se corrompen. -- **Privacidad**: Almacenamiento 100% local en texto plano (JSON). +- **Transacciones**: Todas las operaciones críticas de base de datos están protegidas por transacciones SQLite. +- **Atomicidad en Exportación**: Los volcados JSON siguen utilizando escrituras atómicas. +- **Privacidad**: Todos los datos se almacenan localmente en el equipo del usuario. ## Testing -La suite de tests está dividida para coincidir con la arquitectura del paquete: -- `test_core`: Valida algoritmos de cálculo. -- `test_storage`: Valida persistencia y backups. -- `test_io`: Valida la lógica de importación/exportación. -- `test_cli`: Valida la interfaz de usuario y los argumentos de comando. - -## Extensibilidad Futura (Evolución Técnica) - -El sistema está diseñado para evolucionar hacia una solución de gestión de tiempo profesional: - -1. **Almacenamiento Relacional (SQLite)**: - - Migrar la persistencia interna de JSON a SQLite. - - El formato JSON se mantendrá exclusivamente para intercambio. - -2. **Soporte Multiproyecto Centralizado**: - - Implementar un registro global de proyectos para cambiar de contexto rápidamente. - -3. **Sincronización en la Nube**: - - Permitir la ubicación de la base de datos en servicios como Dropbox o Drive para sincronización automática. - -4. **Interfaz Enriquecida (Rich UI)**: - - Integrar librerías como `rich` para mejorar la presentación visual. +La suite de tests ha sido adaptada para utilizar bases de datos temporales: +- `test_storage`: Verifica el esquema y el comportamiento de `DatabaseManager`. +- `test_cli`: Utiliza parches (mocking) para simular la entrada del usuario sobre la lógica de base de datos. diff --git a/docs/es/CLI-GUIDE.es.md b/docs/es/CLI-GUIDE.es.md index 921970d..c5bd900 100644 --- a/docs/es/CLI-GUIDE.es.md +++ b/docs/es/CLI-GUIDE.es.md @@ -1,18 +1,21 @@ # Guía de Uso: Interfaz CLI -`time-balance` ofrece una interfaz dual: un menú interactivo para el uso diario y comandos directos para consultas rápidas. +`time-balance` ofrece una interfaz dual: un menú interactivo para el uso diario y comandos directos para consultas rápidas. En la versión 0.3.0, la aplicación es **global** y soporta **múltiples proyectos**. ## Comandos Directos (Modo Rápido) -Puedes consultar información sin entrar al menú interactivo usando flags: +Puedes consultar información o realizar acciones sin entrar al menú interactivo usando flags: ```bash -# Ver el saldo acumulado actual +# Ver el saldo acumulado del proyecto activo time-balance --status -# Listar los últimos 10 registros +# Listar los últimos 10 registros del proyecto activo time-balance --list 10 +# Migrar un archivo JSON de legado a un nuevo proyecto global +time-balance --migrate ./ruta/al/historial.json + # Forzar idioma (en/es) time-balance --lang en @@ -30,11 +33,11 @@ time-balance ### Menú Principal -La interfaz detecta automáticamente el idioma de tu sistema, pero puedes cambiarlo en la configuración. +La interfaz detecta automáticamente el idioma de tu sistema. Muestra el estado del **proyecto activo**. ``` ================================================== - PROYECTO: NOMBRE DEL PROYECTO + PROYECTO: MI PROYECTO DE TRABAJO SALDO TOTAL ACUMULADO: +2h 15m (Base diaria: 7h 45m) ================================================== @@ -42,7 +45,7 @@ La interfaz detecta automáticamente el idioma de tu sistema, pero puedes cambia Opciones: 1. Registrar jornada (o corregir día) 2. Ver últimos registros -3. Configurar proyecto (nombre/jornada) +3. Gestionar proyectos (cambiar/crear/editar) 4. Exportar historial a archivo 5. Importar historial desde archivo 6. Salir @@ -53,32 +56,31 @@ Elige opción: _ ## Opciones Detalladas ### 1. Registrar Jornada -Permite anotar las horas trabajadas. Si el día ya existe, pedirá confirmación para sobrescribir. Calcula automáticamente la diferencia respecto a la jornada base configurada para **ese proyecto**. +Permite anotar las horas trabajadas para una fecha (por defecto hoy). Calcula la diferencia respecto a la jornada base del **proyecto activo actual**. ### 2. Ver Últimos Registros -Muestra una tabla con los 5 registros más recientes, incluyendo las horas trabajadas y el impacto en el saldo (positivo o negativo). +Muestra los últimos 5 registros (o los que especifiques) para el proyecto activo. -### 3. Configurar Proyecto -Permite personalizar el nombre del proyecto y la jornada base (horas/minutos) para el archivo actual. Esto se guarda en los metadatos del JSON. +### 3. Gestionar Proyectos +Abre un submenú para: +- **Cambiar de proyecto**: Cambiar cuál es el proyecto activo a nivel global. +- **Crear nuevo proyecto**: Inicializar un nuevo contexto de trabajo con su propia jornada base. +- **Editar proyecto**: Cambiar el nombre o la jornada base del proyecto actual. ### 4. Exportar Historial -Crea una copia de seguridad en formato JSON estructurado en la ruta que elijas. +Exporta los datos del proyecto activo a un archivo JSON estructurado. ### 5. Importar Historial -- **Modo Merge**: Combina datos externos con los actuales. Los datos importados ganan en caso de conflicto de fecha. -- **Modo Overwrite**: Reemplaza todo el archivo (metadatos y registros) por el nuevo. Crea un backup automático antes de proceder. - -## Configuración Mediante Variables de Entorno - -Puedes centralizar tu historial definiendo la ruta en tu archivo de configuración de shell (`.bashrc` o `.zshrc`): - -```bash -export HISTORIAL_PATH="~/.config/time-balance/main_history.json" -``` +Importa datos desde un archivo JSON hacia el **proyecto activo actual**. +- **Modo Merge**: Añade nuevos registros y actualiza los existentes. +- **Modo Overwrite**: Borra todos los registros actuales antes de importar. --- -## Consejos de Uso -- Presiona **ENTER** en el campo de fecha para usar hoy rápidamente. -- Usa `--status` en tus scripts de automatización para ver tu saldo al iniciar la terminal. -- Exporta tus datos regularmente si no usas una carpeta sincronizada. +## Persistencia de Datos +Los datos se guardan en una base de datos SQLite centralizada. Ya no necesitas preocuparte por archivos `historial_hours.json` en las carpetas de tus proyectos, a menos que quieras exportarlos o migrarlos. + +### Rutas por Defecto +- **macOS**: `~/Library/Application Support/time-balance/` +- **Linux**: `~/.local/share/time-balance/` +- **Windows**: `%APPDATA%/time-balance/` diff --git a/docs/es/DEVELOPMENT.es.md b/docs/es/DEVELOPMENT.es.md index 2afe812..101f4f5 100644 --- a/docs/es/DEVELOPMENT.es.md +++ b/docs/es/DEVELOPMENT.es.md @@ -1,71 +1,52 @@ # Guía para Desarrolladores -Esta guía proporciona detalles técnicos para desarrolladores que deseen integrar `time-balance` en sus flujos o contribuir a su desarrollo. +Esta guía proporciona detalles técnicos para desarrolladores que deseen contribuir a la versión 0.3.x de `time-balance`. ## Filosofía del Proyecto -- **Solo Biblioteca Estándar**: No se permiten dependencias externas para la funcionalidad principal. -- **Código Limpio (Clean Code)**: Naming descriptivo, modularidad y alta cobertura de tests. -- **Seguridad**: Escrituras atómicas y validación defensiva de datos. +- **Solo Biblioteca Estándar**: El core y la persistencia deben depender únicamente de librerías nativas de Python (como `sqlite3`). +- **Clean Code**: Naming descriptivo (mínimo 3 caracteres), modularidad y alta cobertura de tests. +- **Transaccionalidad**: Todas las escrituras en la base de datos deben ser consistentes y seguras. -## Estructura de Módulos +## Estructura de Capas -El proyecto está organizado en capas lógicas: +### 1. Lógica de Persistencia (`storage.py`) +Gestiona la base de datos SQLite global. +- `DatabaseManager`: Clase central para CRUD de proyectos y registros. +- `db`: Instancia global utilizada por el resto de la aplicación. -### 1. Lógica de Negocio (`core.py`) -Funciones puras para cálculos de tiempo. -- `format_time(minutes)`: Devuelve strings como `"-1h 30m"`. -- `calculate_total_balance(records)`: Suma diferencias del diccionario de datos. - -### 2. Persistencia (`storage.py`) -Gestiona E/S de disco y escritura atómica. -- `load_data(path)`: Devuelve un diccionario estructurado con `metadata` y `records`. -- `save_data(data, path)`: Escritura atómica mediante archivos temporales. +### 2. Capa de Presentación (`cli.py`) +Controla el flujo de menús e interacción con el usuario. +- `manage_projects()`: Lógica para el submenú de gestión multiproyecto. +- `register_day()`: Interactúa con `db` para guardar jornadas en el proyecto activo. ### 3. Intercambio de Datos (`io.py`) -Lógica para mover datos entre sistemas. -- `export_history(dest)`: Exporta el JSON estructurado completo. -- `import_history(src, mode)`: Valida y mezcla/sobreescribe datos. - -### 4. Interfaz de Usuario (`cli.py`) -Capa de presentación que utiliza `argparse` y un menú interactivo. - -### 5. Internacionalización (`i18n.py`) -Motor de traducción simple. Utiliza `translate(key, lang)` para obtener cadenas. +Funciones para leer JSON de legado y exportar volcados. +- `read_history_file(path)`: Lee un JSON y valida que cumpla con el esquema antiguo. ## Ejecución de Tests -Utilizamos el framework estándar `unittest`: +Es fundamental mantener los tests actualizados. Se utiliza una base de datos temporal para evitar ensuciar los datos reales del desarrollador. ```bash # Ejecutar todos los tests python3 -m unittest discover -v tests ``` -## Añadir un Nuevo Idioma - -1. Abre `time_balance/i18n.py`. -2. Añade una nueva entrada al diccionario `STRINGS` con el código ISO 639-1 (ej. `"fr"` para francés). -3. Traduce todas las claves basándote en la plantilla `"en"`. -4. Actualiza la lista de opciones de `--lang` en `cli.py` para incluir el nuevo código de idioma. - -## Referencia del Esquema de Datos - -```json -{ - "metadata": { - "project_name": "string", - "hours_base": "int", - "minutes_base": "int", - "version": "string", - "language": "string (en|es|auto)" - }, - "records": { - "YYYY-MM-DD": { - "hours": "int", - "minutes": "int", - "difference": "int (worked_minutes - base_minutes)" - } - } -} -``` +## Gestión de la Base de Datos + +La base de datos se inicializa automáticamente en la primera ejecución. El esquema se define en el método `_initialize_database()` de `DatabaseManager`. + +### Rutas de Datos (XDG) +- macOS: `~/Library/Application Support/time-balance/` +- Linux: `~/.local/share/time-balance/` + +## Cómo añadir un comando CLI + +1. Modifica la función `main()` en `cli.py`. +2. Añade el nuevo flag en `argparse`. +3. Implementa la lógica correspondiente, utilizando `db` si requiere acceso a datos o `translate()` para la salida. + +## Referencia del Esquema de Registros +Las jornadas se guardan como minutos de diferencia (`worked_minutes - base_minutes`). +- Ejemplo: Si la base es 7h 45m (465 min) y se trabajan 8h (480 min), la diferencia guardada es `+15`. diff --git a/pyproject.toml b/pyproject.toml index 09f8474..2db076a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "time-balance" -version = "0.2.0" +version = "0.3.0" description = "Control sencillo de jornadas y saldo horario" readme = "README.md" authors = [ { name = "autogenerated" } ] diff --git a/setup.py b/setup.py index 051cf35..6e63f4d 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='time-balance', - version='0.2.0', + version='0.3.0', description='Control sencillo de jornadas y saldo horario', packages=find_packages(exclude=("tests",)), # Entry point para el comando interactivo `time-balance` diff --git a/tests/test_cli.py b/tests/test_cli.py index 42c12f5..3b391bb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,76 +2,82 @@ import tempfile import os import io -import json +import pathlib from unittest import mock import time_balance as ch class TestCLI(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.TemporaryDirectory() - self._orig_cwd = os.getcwd() - os.chdir(self.tmpdir.name) - self._orig_archivo = ch.constants.DATA_FILE - self.data_file = os.path.join(self.tmpdir.name, 'history.json') - ch.constants.DATA_FILE = self.data_file + self.db_path = pathlib.Path(self.tmpdir.name) / "test_cli.db" + + # We need to re-initialize a DatabaseManager for tests + from time_balance.storage import DatabaseManager + self.db = DatabaseManager(self.db_path) + + # Patch the global db instance in all modules where it's used + self.patchers = [ + mock.patch('time_balance.cli.db', self.db), + mock.patch('time_balance.storage.db', self.db) + ] + for patcher in self.patchers: + patcher.start() def tearDown(self): - os.chdir(self._orig_cwd) - ch.constants.DATA_FILE = self._orig_archivo + for patcher in self.patchers: + patcher.stop() self.tmpdir.cleanup() def test_register_day_overwrite_cancel(self): - datos = { - "metadata": {"project_name": "Test", "hours_base": 7, "minutes_base": 45, "version": "1.0", "language": "en"}, - "records": {"2026-01-01": {"hours": 7, "minutes": 0, "difference": -45}} - } + """Verify that cancelling an overwrite does not change the record.""" + # Initial record + self.db.upsert_record(1, "2026-01-01", 7, 0, -45) + # inputs: date, confirmation 'n' (cancel) with mock.patch('builtins.input', side_effect=["2026-01-01", "n"]): - ch.register_day(datos, lang="en") - self.assertEqual(datos["records"]["2026-01-01"]["hours"], 7) + ch.register_day(lang="en") + + record = self.db.get_record_by_date(1, "2026-01-01") + self.assertEqual(record["hours"], 7) def test_register_day_overwrite_confirm(self): - datos = { - "metadata": {"project_name": "Test", "hours_base": 7, "minutes_base": 45, "version": "1.0", "language": "en"}, - "records": {"2026-01-01": {"hours": 7, "minutes": 0, "difference": -45}} - } + """Verify that confirming an overwrite updates the record in DB.""" + # Initial record + self.db.upsert_record(1, "2026-01-01", 7, 0, -45) + # inputs: date, confirmation 'y', hours '8', minutes '0' with mock.patch('builtins.input', side_effect=["2026-01-01", "y", "8", "0"]): - ch.register_day(datos, lang="en") - self.assertEqual(datos["records"]["2026-01-01"]["hours"], 8) - self.assertEqual(ch.load_data()["records"]["2026-01-01"]["hours"], 8) + ch.register_day(lang="en") + + record = self.db.get_record_by_date(1, "2026-01-01") + self.assertEqual(record["hours"], 8) + self.assertEqual(record["difference"], 15) # 8h vs 7h 45m def test_view_history_output(self): - datos = { - "metadata": {"project_name": "Test", "hours_base": 7, "minutes_base": 45, "version": "1.0", "language": "en"}, - "records": { - "2026-01-01": {"hours": 8, "minutes": 0, "difference": 15}, - "2026-01-02": {"hours": 7, "minutes": 0, "difference": -45}, - "2026-01-03": {"hours": 9, "minutes": 0, "difference": 75}, - "2026-01-04": {"hours": 6, "minutes": 30, "difference": -75}, - "2026-01-05": {"hours": 7, "minutes": 45, "difference": 0} - } - } + """Verify that view_history prints the correct number of records.""" + for i in range(1, 6): + self.db.upsert_record(1, f"2026-01-0{i}", 8, 0, 15) + buf = io.StringIO() with mock.patch('sys.stdout', buf): - ch.view_history(datos, lang="en") + ch.view_history(limit=5, lang="en") out = buf.getvalue() + self.assertIn("--- Last 5 records ---", out) - self.assertIn("2026-01-05", out) count_dates = sum(1 for line in out.splitlines() if line.startswith("2026-")) self.assertEqual(count_dates, 5) def test_cli_args_status(self): - datos = { - "metadata": {"project_name": "ProyA", "hours_base": 7, "minutes_base": 45, "version": "1.0", "language": "en"}, - "records": {"2026-01-01": {"hours": 8, "minutes": 0, "difference": 15}} - } - ch.save_data(datos) + """Verify the --status CLI argument output.""" + self.db.update_project(1, "ProyA", 7, 45) + self.db.upsert_record(1, "2026-01-01", 8, 0, 15) + buf = io.StringIO() with mock.patch('sys.stdout', buf): with mock.patch('sys.argv', ['time-balance', '--status', '--lang', 'en']): ch.main() out = buf.getvalue() + self.assertIn("Project: ProyA", out) self.assertIn("Accumulated balance: 0h 15m", out) diff --git a/tests/test_io.py b/tests/test_io.py index 4f9c394..25f4b0a 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -2,104 +2,97 @@ import tempfile import os import json -import glob - import time_balance as ch class TestImportExport(unittest.TestCase): def setUp(self): - # directory per test self.tmpdir = tempfile.TemporaryDirectory() - self.orig_archivo = getattr(ch.constants, 'DATA_FILE', None) - # Clear env var to avoid CI flakes - self._orig_historial_path = os.environ.get('HISTORIAL_PATH') - os.environ.pop('HISTORIAL_PATH', None) - self.dest_file = os.path.join(self.tmpdir.name, 'history.json') - ch.constants.DATA_FILE = self.dest_file def tearDown(self): - if self.orig_archivo is not None: - ch.constants.DATA_FILE = self.orig_archivo - if self._orig_historial_path is not None: - os.environ['HISTORIAL_PATH'] = self._orig_historial_path - else: - os.environ.pop('HISTORIAL_PATH', None) self.tmpdir.cleanup() def test_export_history_creates_file(self): + """Verify that export_history correctly writes the provided dictionary to a JSON file.""" datos = { - "metadata": {"project_name": "P1", "hours_base": 7, "minutes_base": 45, "version": "1.0", "language": "auto"}, - "records": {"2026-04-01": {"hours": 8, "minutes": 0, "difference": 15}} + "metadata": { + "project_name": "P1", + "hours_base": 7, + "minutes_base": 45, + "version": "1.0", + "language": "auto" + }, + "records": { + "2026-04-01": {"hours": 8, "minutes": 0, "difference": 15} + } } - ch.save_data(datos) - export_path = os.path.join(self.tmpdir.name, 'export.json') - written = ch.export_history(export_path) + written = ch.export_history(datos, export_path) + self.assertTrue(os.path.exists(written)) with open(written, 'r', encoding='utf-8') as f: contenido = json.load(f) self.assertEqual(contenido, datos) - def test_import_merge_prefers_source(self): - destino = { - "metadata": {"project_name": "Dest", "hours_base": 7, "minutes_base": 45, "version": "1.0", "language": "auto"}, - "records": { - "2026-04-01": {"hours": 7, "minutes": 0, "difference": -45}, - "2026-04-02": {"hours": 6, "minutes": 30, "difference": -75} - } - } - ch.save_data(destino) - - # Source: Correct v2 format - fuente_v2 = { - "metadata": {"project_name": "Source", "hours_base": 7, "minutes_base": 45, "version": "1.0"}, + def test_read_history_file_validates(self): + """Verify that read_history_file loads and validates a standard JSON history file.""" + datos = { + "metadata": { + "project_name": "Source", + "hours_base": 7, + "minutes_base": 45, + "version": "1.0", + "language": "auto" + }, "records": { - "2026-04-01": {"hours": 9, "minutes": 0, "difference": 75}, - "2026-04-03": {"hours": 8, "minutes": 15, "difference": 30} + "2026-04-01": {"hours": 9, "minutes": 0, "difference": 75} } } src_path = os.path.join(self.tmpdir.name, 'source.json') with open(src_path, 'w', encoding='utf-8') as f: - json.dump(fuente_v2, f) - - res = ch.import_history(src_path, mode=ch.MODE_MERGE) - - self.assertIn('2026-04-03', res["records"]) - self.assertEqual(res["records"]['2026-04-01']['hours'], 9) - loaded = ch.load_data() - self.assertEqual(loaded["records"]['2026-04-01']['hours'], 9) - self.assertIn('2026-04-02', loaded["records"]) - - def test_import_overwrite_creates_backup(self): - destino = { - "metadata": {"project_name": "Dest", "hours_base": 7, "minutes_base": 45, "version": "1.0", "language": "auto"}, - "records": {"2026-04-01": {"hours": 7, "minutes": 0, "difference": -45}} - } - ch.save_data(destino) - - fuente = { - "metadata": {"project_name": "New", "hours_base": 8, "minutes_base": 0, "version": "1.0", "language": "auto"}, - "records": {"2026-05-01": {"hours": 8, "minutes": 0, "difference": 15}} - } - src_path = os.path.join(self.tmpdir.name, 'source2.json') - with open(src_path, 'w', encoding='utf-8') as f: - json.dump(fuente, f) - - ch.import_history(src_path, mode=ch.MODE_OVERWRITE) - - bak_pattern = self.dest_file + '.bak.*' - bak_files = glob.glob(bak_pattern) - self.assertTrue(len(bak_files) >= 1) + json.dump(datos, f) - loaded = ch.load_data() - self.assertEqual(loaded, fuente) + res = ch.read_history_file(src_path) + self.assertEqual(res, datos) - def test_import_invalid_json_raises(self): + def test_read_invalid_json_raises(self): + """Verify that reading a corrupted JSON file raises a ValueError.""" src_path = os.path.join(self.tmpdir.name, 'bad.json') with open(src_path, 'w', encoding='utf-8') as f: f.write('{ invalid json') with self.assertRaises(ValueError): - ch.import_history(src_path, mode=ch.MODE_OVERWRITE) + ch.read_history_file(src_path) + + def test_validate_history_schema(self): + """Verify strict schema validation for history structure.""" + # Missing required keys + with self.assertRaisesRegex(ValueError, "missing required structured keys"): + ch.io.validate_history({"records": {}}) + + # Invalid hours_base type (string instead of int) + bad_meta = { + "metadata": { + "project_name": "P", + "hours_base": "8", + "minutes_base": 0, + "version": "1.0" + }, + "records": {} + } + with self.assertRaisesRegex(ValueError, "must be an integer"): + ch.io.validate_history(bad_meta) + + # Invalid minutes range + bad_minutes = { + "metadata": { + "project_name": "P", + "hours_base": 8, + "minutes_base": 60, + "version": "1.0" + }, + "records": {} + } + with self.assertRaisesRegex(ValueError, "between 0 and 59"): + ch.io.validate_history(bad_minutes) if __name__ == '__main__': unittest.main() diff --git a/tests/test_storage.py b/tests/test_storage.py index b6088ae..03cac23 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,76 +1,80 @@ import unittest import tempfile +import pathlib import os -import json -import time_balance as ch +from time_balance.storage import DatabaseManager +from time_balance import constants class TestStorage(unittest.TestCase): def setUp(self): - self.tmpdir = tempfile.TemporaryDirectory() - self._orig_cwd = os.getcwd() - os.chdir(self.tmpdir.name) - # Patch constants for tests - self._orig_archivo = ch.constants.DATA_FILE - self.data_file = os.path.join(self.tmpdir.name, 'history.json') - ch.constants.DATA_FILE = self.data_file - - # Clear env var - self._orig_env = os.environ.get('HISTORIAL_PATH') - if 'HISTORIAL_PATH' in os.environ: - del os.environ['HISTORIAL_PATH'] + self.tmp_dir = tempfile.TemporaryDirectory() + self.db_path = pathlib.Path(self.tmp_dir.name) / "test.db" + self.db = DatabaseManager(self.db_path) def tearDown(self): - os.chdir(self._orig_cwd) - ch.constants.DATA_FILE = self._orig_archivo - if self._orig_env: - os.environ['HISTORIAL_PATH'] = self._orig_env - self.tmpdir.cleanup() - - def test_load_data_no_file(self): - fake = os.path.join(self.tmpdir.name, "non_existent.json") - ch.constants.DATA_FILE = fake - res = ch.load_data() - self.assertIn("metadata", res) - self.assertEqual(res["records"], {}) + self.tmp_dir.cleanup() - def test_load_data_corrupted_json(self): - with open(self.data_file, "w", encoding="utf-8") as f: - f.write("{ invalid json") - res = ch.load_data() - self.assertIn("metadata", res) - self.assertEqual(res["records"], {}) + def test_initialization(self): + """Should create 'General' project and initial settings by default.""" + projects = self.db.get_projects() + # Initial seeding creates 'General' + self.assertEqual(len(projects), 1) + self.assertEqual(projects[0]['name'], "General") + + # Should set active project id to 1 by default + self.assertEqual(self.db.get_active_project_id(), 1) + + # Should set default language + self.assertEqual(self.db.get_setting("language"), "auto") - def test_save_and_load_roundtrip(self): + def test_create_and_get_projects(self): + """Verify project creation and retrieval.""" + new_id = self.db.create_project("Side Hustle", 2, 30) + self.assertGreater(new_id, 1) + + projects = self.db.get_projects() + self.assertEqual(len(projects), 2) + + project = self.db.get_project_by_id(new_id) + self.assertEqual(project['name'], "Side Hustle") + self.assertEqual(project['base_hours'], 2) + self.assertEqual(project['base_minutes'], 30) - data = { - "metadata": { - "project_name": "Test Project", - "hours_base": 8, - "minutes_base": 0, - "version": "1.0", - "language": "auto" - }, - "records": { - "2026-01-01": {"hours": 8, "minutes": 0, "difference": 0} - } - } - ch.save_data(data) - loaded = ch.load_data() - self.assertEqual(loaded, data) + def test_upsert_and_get_records(self): + """Verify record creation, update and retrieval sorting.""" + project_id = 1 + self.db.upsert_record(project_id, "2026-04-20", 8, 0, 15) + self.db.upsert_record(project_id, "2026-04-21", 7, 45, 0) + + records = self.db.get_records(project_id) + self.assertEqual(len(records), 2) + # Records should be sorted by date DESC by default + self.assertEqual(records[0]['date'], "2026-04-21") + + # Test update (UPSERT) + self.db.upsert_record(project_id, "2026-04-21", 9, 0, 75) + records = self.db.get_records(project_id) + self.assertEqual(len(records), 2) + self.assertEqual(records[0]['hours'], 9) + self.assertEqual(records[0]['difference'], 75) - def test_resolve_file_path_priorities(self): - # 1. Argument - arg_path = os.path.join(self.tmpdir.name, "arg.json") - self.assertEqual(ch._resolve_file_path(arg_path), os.path.abspath(arg_path)) + def test_total_balance(self): + """Verify calculation of total balance for a project.""" + project_id = 1 + self.db.upsert_record(project_id, "2026-04-20", 8, 0, 15) + self.db.upsert_record(project_id, "2026-04-21", 9, 0, 75) + self.db.upsert_record(project_id, "2026-04-22", 7, 0, -45) - # 2. Env Var - env_path = os.path.join(self.tmpdir.name, "env.json") - os.environ['HISTORIAL_PATH'] = env_path - self.assertEqual(ch._resolve_file_path(), os.path.abspath(env_path)) - del os.environ['HISTORIAL_PATH'] + balance = self.db.get_total_balance(project_id) + self.assertEqual(balance, 15 + 75 - 45) + + def test_settings_persistence(self): + """Verify global settings can be saved and retrieved.""" + self.db.set_setting("language", "es") + self.assertEqual(self.db.get_setting("language"), "es") - # 3. Default - self.assertEqual(ch._resolve_file_path(), os.path.abspath(self.data_file)) + self.db.set_setting("last_run", "2026-04-24") + self.assertEqual(self.db.get_setting("last_run"), "2026-04-24") if __name__ == "__main__": unittest.main() diff --git a/time_balance/__init__.py b/time_balance/__init__.py index 453f9f1..72b0173 100644 --- a/time_balance/__init__.py +++ b/time_balance/__init__.py @@ -2,7 +2,6 @@ VERSION, BASE_HOURS, BASE_MINUTES, - DATA_FILE, ENV_HISTORIAL, MODE_MERGE, MODE_OVERWRITE, @@ -13,14 +12,13 @@ calculate_total_balance ) from .storage import ( + db, load_data, - save_data, - _resolve_file_path, - _create_backup + save_data ) from .io import ( export_history, - import_history + read_history_file ) from .cli import ( request_date, @@ -35,17 +33,17 @@ 'VERSION', 'BASE_HOURS', 'BASE_MINUTES', - 'DATA_FILE', 'ENV_HISTORIAL', 'MODE_MERGE', 'MODE_OVERWRITE', 'VALID_IMPORT_MODES', 'format_time', 'calculate_total_balance', + 'db', 'load_data', 'save_data', 'export_history', - 'import_history', + 'read_history_file', 'request_date', 'register_day', 'view_history', diff --git a/time_balance/cli.py b/time_balance/cli.py index 44c9699..59f845c 100644 --- a/time_balance/cli.py +++ b/time_balance/cli.py @@ -3,11 +3,19 @@ from datetime import date, datetime from . import constants from . import core -from . import storage +from .storage import db from . import io from .i18n import translate, get_system_language +def get_current_lang(): + """Determines the active language based on settings or system.""" + lang = db.get_setting("language", "auto") + if lang == "auto": + return get_system_language() + return lang + + def request_date(lang="en"): """Requests date from user or uses today's as default.""" today = date.today().strftime("%Y-%m-%d") @@ -25,43 +33,16 @@ def request_date(lang="en"): return today -def configure_project(data, lang="en"): - """Configures project metadata.""" - metadata = data["metadata"] - print(translate("config_header", lang=lang)) - - new_name = input(translate("project_name_prompt", lang=lang, current=metadata['project_name'])).strip() - if new_name: - metadata["project_name"] = new_name - - try: - hours_str = input(translate("base_hours_prompt", lang=lang, current=metadata['hours_base'])).strip() - if hours_str: - metadata["hours_base"] = int(hours_str) - - minutes_str = input(translate("base_minutes_prompt", lang=lang, current=metadata['minutes_base'])).strip() - if minutes_str: - metadata["minutes_base"] = int(minutes_str) - except ValueError: - print(translate("error_integers", lang=lang)) - - new_lang = input(translate("language_prompt", lang=lang, current=metadata.get('language', 'auto'))).strip().lower() - if new_lang: - if new_lang in ("en", "es", "auto"): - metadata["language"] = new_lang - else: - print(translate("invalid_language", lang=lang)) - - -def register_day(data, file_path=None, lang="en"): - """Interactive flow to register working hours.""" +def register_day(lang="en"): + """Interactive flow to register working hours for the active project.""" + active_id = db.get_active_project_id() + project = db.get_project_by_id(active_id) work_date = request_date(lang=lang) - metadata = data["metadata"] - records = data["records"] - if work_date in records: + existing_record = db.get_record_by_date(active_id, work_date) + if existing_record: print(translate("duplicate_warning", lang=lang, date=work_date)) - print(translate("previous_record", lang=lang, hours=records[work_date]['hours'], minutes=records[work_date]['minutes'])) + print(translate("previous_record", lang=lang, hours=existing_record['hours'], minutes=existing_record['minutes'])) confirmation = input(translate("overwrite_confirm", lang=lang)).lower() if confirmation not in ('s', 'y'): print(translate("op_cancelled", lang=lang)) @@ -69,77 +50,152 @@ def register_day(data, file_path=None, lang="en"): print(translate("input_header", lang=lang, date=work_date)) try: - hours = int(input(translate("hours_worked", lang=lang)) or 0) - minutes = int(input(translate("minutes_worked", lang=lang)) or 0) + hours_input = input(translate("hours_worked", lang=lang)).strip() + hours = int(hours_input) if hours_input else 0 + + minutes_input = input(translate("minutes_worked", lang=lang)).strip() + minutes = int(minutes_input) if minutes_input else 0 except ValueError: print(translate("error_integers", lang=lang)) return - base_minutes = (metadata["hours_base"] * 60) + metadata["minutes_base"] + base_minutes = (project["base_hours"] * 60) + project["base_minutes"] worked_minutes = (hours * 60) + minutes difference = worked_minutes - base_minutes - records[work_date] = { - "hours": hours, - "minutes": minutes, - "difference": difference - } + db.upsert_record(active_id, work_date, hours, minutes, difference) - storage.save_data(data, file_path) print(translate("save_success", lang=lang, date=work_date)) print(translate("day_diff", lang=lang, diff=core.format_time(difference))) -def view_history(data, limit=5, lang="en"): - """Displays last records.""" - records = data["records"] +def view_history(limit=5, lang="en"): + """Displays last records for the active project.""" + active_id = db.get_active_project_id() + records = db.get_records(active_id, limit=limit if limit > 0 else None) if limit > 0: print(translate("recent_records_header", lang=lang, limit=limit)) else: print(translate("full_history_header", lang=lang)) - sorted_dates = sorted(records.keys(), reverse=True) - if limit > 0: - sorted_dates = sorted_dates[:limit] - - if not sorted_dates: + if not records: print(translate("no_records", lang=lang)) + return work_label = translate("work_label", lang=lang) bal_label = translate("balance_short_label", lang=lang) - for work_date in sorted_dates: - info = records[work_date] - time_fmt = core.format_time(info['difference']) - if info['difference'] > 0: + for record in records: + time_fmt = core.format_time(record['difference']) + if record['difference'] > 0: time_fmt = "+" + time_fmt + print(f"{record['date']} | {work_label}: {record['hours']}h {record['minutes']}m | {bal_label}: {time_fmt}") - print(f"{work_date} | {work_label}: {info['hours']}h {info['minutes']}m | {bal_label}: {time_fmt}") + +def manage_projects(lang="en"): + """Submenu for multi-project management.""" + while True: + os.system('cls' if os.name == 'nt' else 'clear') + print(f"\n--- {translate('projects_menu_header', lang=lang)} ---") + projects = db.get_projects() + active_id = db.get_active_project_id() + + for p in projects: + marker = " [*]" if p['id'] == active_id else "" + print(f"{p['id']}. {p['name']} ({p['base_hours']}h {p['base_minutes']}m){marker}") + + print(f"\n{translate('switch_project', lang=lang)}") + print(translate('create_project', lang=lang)) + print(translate('edit_project', lang=lang)) + print(translate('back', lang=lang)) + + choice = input(f"\n{translate('choose_option', lang=lang)}").strip() + + if choice == "1": + target_id = input(translate("enter_project_id", lang=lang)).strip() + if target_id.isdigit() and any(p['id'] == int(target_id) for p in projects): + db.set_active_project_id(int(target_id)) + else: + print(translate("invalid_option", lang=lang)) + input(translate("press_enter", lang=lang)) + elif choice == "2": + name = input(translate("project_name_prompt", lang=lang, current="New")).strip() + if name: + try: + h = int(input(translate("base_hours_prompt", lang=lang, current=constants.BASE_HOURS)) or constants.BASE_HOURS) + m = int(input(translate("base_minutes_prompt", lang=lang, current=constants.BASE_MINUTES)) or constants.BASE_MINUTES) + new_id = db.create_project(name, h, m) + db.set_active_project_id(new_id) + except ValueError: + print(translate("error_integers", lang=lang)) + input(translate("press_enter", lang=lang)) + elif choice == "3": + p = db.get_project_by_id(active_id) + name = input(translate("project_name_prompt", lang=lang, current=p['name'])).strip() or p['name'] + try: + h_input = input(translate("base_hours_prompt", lang=lang, current=p['base_hours'])).strip() + h = int(h_input) if h_input else p['base_hours'] + + m_input = input(translate("base_minutes_prompt", lang=lang, current=p['base_minutes'])).strip() + m = int(m_input) if m_input else p['base_minutes'] + + db.update_project(active_id, name, h, m) + + lang_opt = input(translate("language_prompt", lang=lang, current=db.get_setting("language", "auto"))).strip().lower() + if lang_opt in ("en", "es", "auto"): + db.set_setting("language", lang_opt) + lang = get_current_lang() + except ValueError: + print(translate("error_integers", lang=lang)) + input(translate("press_enter", lang=lang)) + elif choice == "4": + break + + +def migrate_from_json(file_path, lang="en"): + """Migrates records from a legacy JSON file to a new SQLite project.""" + try: + source_data = io.read_history_file(file_path) + records = source_data.get("records", {}) + metadata = source_data.get("metadata", {}) + + name = metadata.get('project_name', f"Migrated_{datetime.now().strftime('%Y%m%d')}") + try: + project_id = db.create_project( + name, + metadata.get('hours_base', constants.BASE_HOURS), + metadata.get('minutes_base', constants.BASE_MINUTES) + ) + except Exception: + # If name exists (e.g. General), use active project + project_id = db.get_active_project_id() + name = db.get_project_by_id(project_id)['name'] + + count = 0 + for date_str, info in records.items(): + db.upsert_record(project_id, date_str, info['hours'], info['minutes'], info['difference']) + count += 1 + + print(translate("migration_success", lang=lang, count=count, name=name)) + except Exception as e: + print(translate("migration_error", lang=lang, error=str(e))) def interactive_menu(): """Main interactive menu loop.""" - data = storage.load_data() - lang = data["metadata"].get("language", "auto") - if lang == "auto": - lang = get_system_language() - while True: - data = storage.load_data() - metadata = data["metadata"] - total_balance = core.calculate_total_balance(data["records"]) - - lang = metadata.get("language", "auto") - if lang == "auto": - lang = get_system_language() + lang = get_current_lang() + active_id = db.get_active_project_id() + project = db.get_project_by_id(active_id) + total_balance = db.get_total_balance(active_id) os.system('cls' if os.name == 'nt' else 'clear') print("\n" + "="*50) - print(f" {translate('project_label', lang=lang)}: {metadata['project_name'].upper()}") + print(f" {translate('project_label', lang=lang)}: {project['name'].upper()}") print(f" {translate('balance_label', lang=lang)}: {core.format_time(total_balance)}") - print(f" ({translate('base_day_label', lang=lang)}: {metadata['hours_base']}h {metadata['minutes_base']}m)") + print(f" ({translate('base_day_label', lang=lang)}: {project['base_hours']}h {project['base_minutes']}m)") print("="*50) print(f"\n{translate('option_1', lang=lang)}") @@ -149,36 +205,59 @@ def interactive_menu(): print(translate('option_5', lang=lang)) print(translate('option_6', lang=lang)) - option = input(f"\n{translate('choose_option', lang=lang)}") + option = input(f"\n{translate('choose_option', lang=lang)}").strip() if option == "1": - register_day(data, lang=lang) + register_day(lang=lang) input(translate('press_enter', lang=lang)) elif option == "2": - view_history(data, lang=lang) + view_history(lang=lang) input(translate('press_enter', lang=lang)) elif option == "3": - configure_project(data, lang=lang) - storage.save_data(data) - input(translate('press_enter', lang=lang)) + manage_projects(lang=lang) elif option == "4": - path = input(translate('export_dest_prompt', lang=lang)) - try: - dest = io.export_history(path) - print(translate('export_success', lang=lang, path=dest)) - except Exception as err: - print(translate('export_error', lang=lang, error=err)) - input(translate('press_enter', lang=lang)) + path = input(translate('export_dest_prompt', lang=lang)).strip() + if path: + try: + # Convert DB data back to JSON-like structure for export + project_data = db.get_project_by_id(active_id) + records = db.get_records(active_id) + data_to_export = { + "metadata": { + "project_name": project_data['name'], + "hours_base": project_data['base_hours'], + "minutes_base": project_data['base_minutes'], + "version": "1.0", + "language": db.get_setting("language", "auto") + }, + "records": {r['date']: {'hours': r['hours'], 'minutes': r['minutes'], 'difference': r['difference']} for r in records} + } + dest = io.export_history(data_to_export, path) + print(translate('export_success', lang=lang, path=dest)) + except Exception as err: + print(translate('export_error', lang=lang, error=err)) + input(translate('press_enter', lang=lang)) elif option == "5": - path = input(translate('import_src_prompt', lang=lang)) - mode_input = input(translate('import_mode_prompt', lang=lang, merge=constants.MODE_MERGE, overwrite=constants.MODE_OVERWRITE)).strip().lower() - mode = mode_input if mode_input else constants.MODE_MERGE - try: - result = io.import_history(path, mode=mode) - print(translate('import_success', lang=lang, count=len(result['records']))) - except Exception as err: - print(translate('import_error', lang=lang, error=err)) - input(translate('press_enter', lang=lang)) + path = input(translate('import_src_prompt', lang=lang)).strip() + if path: + mode_input = input(translate('import_mode_prompt', lang=lang, merge=constants.MODE_MERGE, overwrite=constants.MODE_OVERWRITE)).strip().lower() + mode = mode_input if mode_input else constants.MODE_MERGE + try: + source_data = io.read_history_file(path) + + if mode == constants.MODE_OVERWRITE: + # Clear existing records for this project if overwriting + with db._get_connection() as conn: + conn.execute("DELETE FROM records WHERE project_id = ?", (active_id,)) + + count = 0 + for date_str, info in source_data['records'].items(): + db.upsert_record(active_id, date_str, info['hours'], info['minutes'], info['difference']) + count += 1 + print(translate('import_success', lang=lang, count=count)) + except Exception as err: + print(translate('import_error', lang=lang, error=err)) + input(translate('press_enter', lang=lang)) elif option == "6": print(translate('exit_msg', lang=lang)) break @@ -186,7 +265,7 @@ def interactive_menu(): def main(): parser = argparse.ArgumentParser( - description="time-balance: A simple tool to track your hour balance." + description="time-balance: A professional tool to track your working hours balance globally." ) parser.add_argument( "-s", "--status", action="store_true", help="Show only the accumulated balance." @@ -200,6 +279,9 @@ def main(): parser.add_argument( "--lang", type=str, choices=["en", "es", "auto"], default="auto", help="Force interface language." ) + parser.add_argument( + "--migrate", type=str, metavar="JSON_FILE", help="Migrate records from a legacy JSON file." + ) args = parser.parse_args() @@ -207,21 +289,25 @@ def main(): print(f"time-balance v{constants.VERSION}") return - data = storage.load_data() lang = args.lang if lang == "auto": - lang = data["metadata"].get("language", "auto") - if lang == "auto": - lang = get_system_language() + lang = get_current_lang() + + if args.migrate: + migrate_from_json(args.migrate, lang=lang) + return + + active_id = db.get_active_project_id() + project = db.get_project_by_id(active_id) if args.status: - total_balance = core.calculate_total_balance(data["records"]) - print(translate("status_project", lang=lang, name=data['metadata']['project_name'])) + total_balance = db.get_total_balance(active_id) + print(translate("status_project", lang=lang, name=project['name'])) print(translate("status_balance", lang=lang, balance=core.format_time(total_balance))) return if args.list is not None: - view_history(data, limit=args.list, lang=lang) + view_history(limit=args.list, lang=lang) return interactive_menu() diff --git a/time_balance/constants.py b/time_balance/constants.py index 13b6f4e..debd6f4 100644 --- a/time_balance/constants.py +++ b/time_balance/constants.py @@ -1,11 +1,40 @@ -VERSION = "0.2.0" +import os +import pathlib + +VERSION = "0.3.0" + +# --- PATH CONFIGURATION --- +def get_data_dir() -> pathlib.Path: + """Get the standard directory for storing application data.""" + if os.name == 'nt': + # Windows + base_dir = os.getenv('APPDATA') + if not base_dir: + base_dir = pathlib.Path.home() / "AppData" / "Roaming" + else: + base_dir = pathlib.Path(base_dir) + elif os.uname().sysname == 'Darwin': + # macOS + base_dir = pathlib.Path.home() / "Library" / "Application Support" + else: + # Linux / Other + base_dir = os.getenv('XDG_DATA_HOME') + if not base_dir: + base_dir = pathlib.Path.home() / ".local" / "share" + else: + base_dir = pathlib.Path(base_dir) + + data_dir = pathlib.Path(base_dir) / "time-balance" + return data_dir + +DATA_DIR = get_data_dir() +DATABASE_PATH = DATA_DIR / "time_balance.db" # --- TIME CONFIGURATION --- BASE_HOURS = 7 BASE_MINUTES = 45 -# --- PERSISTENCE --- -DATA_FILE = "historial_hours.json" +# --- PERSISTENCE (LEGACY/ENV) --- ENV_HISTORIAL = "HISTORIAL_PATH" # --- IMPORT MODES --- diff --git a/time_balance/i18n.py b/time_balance/i18n.py index 6b535df..f3f44ec 100644 --- a/time_balance/i18n.py +++ b/time_balance/i18n.py @@ -10,10 +10,20 @@ "base_day_label": "Daily base", "option_1": "1. Register workday (or correct day)", "option_2": "2. View recent records", - "option_3": "3. Configure project (name/hours/lang)", + "option_3": "3. Manage projects (switch/create/edit)", "option_4": "4. Export history to file", "option_5": "5. Import history from file", "option_6": "6. Exit", + "option_7": "7. (Reserved)", + "projects_menu_header": "PROJECT MANAGEMENT", + "switch_project": "1. Switch project", + "create_project": "2. Create new project", + "edit_project": "3. Edit current project configuration", + "back": "4. Back to main menu", + "enter_project_id": "Enter project ID to switch: ", + "invalid_option": "❌ Invalid option.", + "migration_success": "\n✅ Successfully migrated {count} records to project '{name}'.", + "migration_error": "❌ Migration error: {error}", "choose_option": "Choose option: ", "press_enter": "\nPress ENTER to continue...", "exit_msg": "See you tomorrow!", @@ -58,10 +68,20 @@ "base_day_label": "Base diaria", "option_1": "1. Registrar jornada (o corregir día)", "option_2": "2. Ver últimos registros", - "option_3": "3. Configurar proyecto (nombre/jornada/idioma)", + "option_3": "3. Gestionar proyectos (cambiar/crear/editar)", "option_4": "4. Exportar historial a archivo", "option_5": "5. Importar historial desde archivo", "option_6": "6. Salir", + "option_7": "7. (Reservado)", + "projects_menu_header": "GESTIÓN DE PROYECTOS", + "switch_project": "1. Cambiar de proyecto", + "create_project": "2. Crear nuevo proyecto", + "edit_project": "3. Editar configuración del proyecto actual", + "back": "4. Volver al menú principal", + "enter_project_id": "Introduce el ID del proyecto para cambiar: ", + "invalid_option": "❌ Opción inválida.", + "migration_success": "\n✅ Migrados con éxito {count} registros al proyecto '{name}'.", + "migration_error": "❌ Error de migración: {error}", "choose_option": "Elige opción: ", "press_enter": "\nPresiona ENTER para continuar...", "exit_msg": "¡Hasta mañana!", diff --git a/time_balance/io.py b/time_balance/io.py index 4ed8aff..58580df 100644 --- a/time_balance/io.py +++ b/time_balance/io.py @@ -4,7 +4,6 @@ import shutil import errno from datetime import datetime -from . import storage from . import constants @@ -69,9 +68,9 @@ def _validate_record_entry(date_key, info): raise ValueError(f"'{key}' in {date_key} must be an integer") -def export_history(dest_path, file_path=None): - """Exports current history to external JSON with atomic safety.""" - data = storage.load_data(file_path) +def export_history(data, dest_path): + """Exports provided data dictionary to external JSON with atomic safety.""" + validate_history(data) dest = os.path.expanduser(dest_path) dest = os.path.abspath(dest) dest_dir = os.path.dirname(dest) or "." @@ -118,31 +117,22 @@ def export_history(dest_path, file_path=None): return dest -def import_history(source_path, mode=constants.MODE_MERGE, file_path=None): - """Imports history from external file.""" +def read_history_file(source_path): + """Reads and validates a history file from disk.""" source = os.path.expanduser(source_path) source = os.path.abspath(source) if not os.path.exists(source): - raise FileNotFoundError(f"Import file not found: {source}") + raise FileNotFoundError(f"File not found: {source}") try: with open(source, 'r', encoding='utf-8') as json_file: source_data = json.load(json_file) except json.JSONDecodeError as error: - raise ValueError(f"Invalid JSON in import file: {error}") + raise ValueError(f"Invalid JSON in file: {error}") validate_history(source_data) - - target_path = storage._resolve_file_path(file_path) - if mode == constants.MODE_OVERWRITE: - storage._create_backup(target_path) - storage.save_data(source_data, target_path) - return source_data - elif mode == constants.MODE_MERGE: - storage._create_backup(target_path) - current_data = storage.load_data(target_path) - current_data["records"].update(source_data["records"]) - storage.save_data(current_data, target_path) - return current_data - else: - raise ValueError(f"Unknown mode. Use {constants.VALID_IMPORT_MODES}.") + return source_data + +# import_history was previously handling backups and saving. +# Now cli.py will handle database interaction. +# We keep a simpler version or remove it if cli.py handles everything. diff --git a/time_balance/storage.py b/time_balance/storage.py index db12888..4cff81f 100644 --- a/time_balance/storage.py +++ b/time_balance/storage.py @@ -1,133 +1,209 @@ +import sqlite3 +import pathlib import os -import json -import tempfile -import shutil -import errno -from datetime import datetime +from typing import List, Dict, Optional, Any from . import constants - -def _resolve_file_path(file_path=None): - """Resolves the final history file path.""" - if file_path: - path = file_path - elif constants.ENV_HISTORIAL in os.environ and os.environ[constants.ENV_HISTORIAL].strip(): - path = os.environ[constants.ENV_HISTORIAL] - else: - path = constants.DATA_FILE - - path = os.path.expanduser(path) - return os.path.abspath(path) - - -def _empty_data_skeleton(): - """Returns a fresh empty history structure.""" +class DatabaseManager: + """Manages SQLite database operations for projects and time records.""" + + def __init__(self, db_path: pathlib.Path): + self.db_path = db_path + # Ensure the directory exists (XDG standard) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize_database() + + def _get_connection(self) -> sqlite3.Connection: + """Returns a standard sqlite3 connection.""" + return sqlite3.connect(self.db_path) + + def _initialize_database(self): + """Creates the necessary tables if they do not exist.""" + with self._get_connection() as connection: + cursor = connection.cursor() + + # Projects table: configuration for different work contexts + cursor.execute(""" + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + base_hours INTEGER NOT NULL, + base_minutes INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Records table: daily time registrations + cursor.execute(""" + CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + date TEXT NOT NULL, + hours INTEGER NOT NULL, + minutes INTEGER NOT NULL, + difference INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + UNIQUE(project_id, date) + ) + """) + + # Settings table: global application state (active project, language) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + """) + + connection.commit() + + # Seed initial data if empty + cursor.execute("SELECT COUNT(*) FROM projects") + if cursor.fetchone()[0] == 0: + self.create_project("General", constants.BASE_HOURS, constants.BASE_MINUTES) + # Set the first project as active by default + cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('active_project_id', '1')") + cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('language', 'auto')") + connection.commit() + + def create_project(self, name: str, base_hours: int, base_minutes: int) -> int: + """Creates a new project and returns its ID.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute( + "INSERT INTO projects (name, base_hours, base_minutes) VALUES (?, ?, ?)", + (name, base_hours, base_minutes) + ) + return cursor.lastrowid + + def update_project(self, project_id: int, name: str, base_hours: int, base_minutes: int): + """Updates project configuration.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute( + "UPDATE projects SET name = ?, base_hours = ?, base_minutes = ? WHERE id = ?", + (name, base_hours, base_minutes, project_id) + ) + + def get_projects(self) -> List[Dict[str, Any]]: + """Returns a list of all projects.""" + with self._get_connection() as connection: + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + cursor.execute("SELECT * FROM projects ORDER BY name") + return [dict(row) for row in cursor.fetchall()] + + def get_project_by_id(self, project_id: int) -> Optional[Dict[str, Any]]: + """Retrieves a single project by ID.""" + with self._get_connection() as connection: + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + cursor.execute("SELECT * FROM projects WHERE id = ?", (project_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + def get_active_project_id(self) -> int: + """Gets the ID of the project currently in use.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute("SELECT value FROM settings WHERE key = 'active_project_id'") + row = cursor.fetchone() + if row: + return int(row[0]) + return 1 + + def set_active_project_id(self, project_id: int): + """Updates the active project ID in settings.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('active_project_id', ?)", (str(project_id),)) + + def get_setting(self, key: str, default: str = None) -> Optional[str]: + """Retrieves a global setting.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute("SELECT value FROM settings WHERE key = ?", (key,)) + row = cursor.fetchone() + return row[0] if row else default + + def set_setting(self, key: str, value: str): + """Saves a global setting.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)) + + def upsert_record(self, project_id: int, record_date: str, hours: int, minutes: int, difference: int): + """Inserts or updates a daily time record.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO records (project_id, date, hours, minutes, difference) + VALUES (?, ?, ?, ?, ?) + """, (project_id, record_date, hours, minutes, difference)) + + def get_records(self, project_id: int, limit: Optional[int] = None) -> List[Dict[str, Any]]: + """Returns records for a specific project, sorted by date descending.""" + query = "SELECT * FROM records WHERE project_id = ? ORDER BY date DESC" + parameters = [project_id] + if limit: + query += " LIMIT ?" + parameters.append(limit) + + with self._get_connection() as connection: + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + cursor.execute(query, tuple(parameters)) + return [dict(row) for row in cursor.fetchall()] + + def get_record_by_date(self, project_id: int, record_date: str) -> Optional[Dict[str, Any]]: + """Retrieves a specific record for a project and date.""" + with self._get_connection() as connection: + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + cursor.execute("SELECT * FROM records WHERE project_id = ? AND date = ?", (project_id, record_date)) + row = cursor.fetchone() + return dict(row) if row else None + + def get_total_balance(self, project_id: int) -> int: + """Calculates the sum of differences for all records in a project.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute("SELECT SUM(difference) FROM records WHERE project_id = ?", (project_id,)) + result = cursor.fetchone()[0] + return result if result is not None else 0 + +# --- GLOBAL SINGLETON --- +db = DatabaseManager(constants.DATABASE_PATH) + +# --- BACKWARD COMPATIBILITY / SHIMS (TO BE REMOVED) --- +def load_data(): + """Shim for compatibility during migration. Returns active project as a dict.""" + project_id = db.get_active_project_id() + project = db.get_project_by_id(project_id) + records_list = db.get_records(project_id) + + # Convert list of records to dict by date + records_dict = {r['date']: {'hours': r['hours'], 'minutes': r['minutes'], 'difference': r['difference']} for r in records_list} + return { "metadata": { - "project_name": "General", - "hours_base": constants.BASE_HOURS, - "minutes_base": constants.BASE_MINUTES, - "version": "1.0", - "language": "auto" + "project_name": project['name'], + "hours_base": project['base_hours'], + "minutes_base": project['base_minutes'], + "language": db.get_setting("language", "auto") }, - "records": {} - } - - -def _normalize_loaded_data(data): - """Returns data in the current structured schema, ensuring basic keys exist.""" - default_data = _empty_data_skeleton() - - if not isinstance(data, dict): - return default_data - - metadata = data.get("metadata", {}) - records = data.get("records", {}) - - if not isinstance(metadata, dict): - metadata = {} - if not isinstance(records, dict): - records = {} - - normalized_metadata = default_data["metadata"].copy() - normalized_metadata.update(metadata) - - return { - "metadata": normalized_metadata, - "records": records + "records": records_dict } - -def load_data(file_path=None): - """Loads history from the structured JSON file.""" - path = _resolve_file_path(file_path) - - # Return empty skeleton if file doesn't exist - if not os.path.exists(path): - return _empty_data_skeleton() - - try: - with open(path, "r", encoding="utf-8") as json_file: - return _normalize_loaded_data(json.load(json_file)) - except (ValueError, json.JSONDecodeError): - # On error, return an empty structure safely - return _empty_data_skeleton() - - def save_data(data, file_path=None): - """Saves complete history using atomic writing and directory fsync.""" - path = _resolve_file_path(file_path) - dest_dir = os.path.dirname(path) or "." - if dest_dir != "." and not os.path.exists(dest_dir): - os.makedirs(dest_dir, exist_ok=True) - - content = json.dumps(data, indent=4, ensure_ascii=False) - fd, temp_path = tempfile.mkstemp(prefix="history_", suffix=".json", dir=dest_dir) - try: - with os.fdopen(fd, 'w', encoding='utf-8') as temp_file: - temp_file.write(content) - temp_file.flush() - try: - os.fsync(temp_file.fileno()) - except OSError: - pass # Some platforms/filesystems don't support fsync on files - - try: - os.replace(temp_path, path) - except OSError as error: - if getattr(error, 'errno', None) == errno.EXDEV: - shutil.copy2(temp_path, path) - os.remove(temp_path) - else: - raise - - # Best-effort directory fsync for crash-safety - try: - dir_fd = os.open(dest_dir, os.O_RDONLY) - try: - os.fsync(dir_fd) - finally: - os.close(dir_fd) - except (AttributeError, OSError): - pass # Windows or some older Unixes might not support fsync on directories - - finally: - if 'temp_path' in locals() and os.path.exists(temp_path): - try: - os.remove(temp_path) - except OSError: - pass - - -def _create_backup(file_path): - """Creates a timestamped backup of the given file.""" - if not os.path.exists(file_path): - return None - timestamp = datetime.now().strftime('%Y%m%dT%H%M%S') - backup = f"{file_path}.bak.{timestamp}" - shutil.copy2(file_path, backup) - try: - shutil.copy2(file_path, f"{file_path}.bak") - except Exception: + """Shim for compatibility. Saves metadata to the active project.""" + if file_path: + # If a file_path is provided, we might be exporting or using a non-standard flow + # For now, we ignore it and warn, or implement as needed. pass - return backup + + project_id = db.get_active_project_id() + metadata = data.get("metadata", {}) + db.update_project(project_id, metadata['project_name'], metadata['hours_base'], metadata['minutes_base']) + db.set_setting("language", metadata.get("language", "auto")) From 89da531b9a8132d4bb23b6a3550dfd3292acfd7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 01:42:59 +0200 Subject: [PATCH 2/9] refactor: enhance CLI with rich console output and update project version to 0.4.0 --- docs/DEVELOPMENT.md | 5 +- docs/es/DEVELOPMENT.es.md | 5 +- pyproject.toml | 5 +- setup.py | 5 +- tests/test_cli.py | 63 ++++----- time_balance/cli.py | 281 ++++++++++++++++++++++---------------- time_balance/constants.py | 2 +- time_balance/i18n.py | 38 ++++-- 8 files changed, 232 insertions(+), 172 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9572e29..9eb9a55 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,9 +4,8 @@ This guide provides technical details for developers who want to contribute to v ## Project Philosophy -- **Standard Library Only**: The core and persistence must rely solely on native Python libraries (like `sqlite3`). -- **Clean Code**: Descriptive naming (minimum 3 characters), modularity, and high test coverage. -- **Transactionality**: All database writes must be consistent and secure. +- **Standard Library Core**: The business logic and persistence rely solely on native Python libraries. +- **Professional UI**: Uses `rich` to provide a modern, readable, and visually appealing terminal interface. ## Layer Structure diff --git a/docs/es/DEVELOPMENT.es.md b/docs/es/DEVELOPMENT.es.md index 101f4f5..6450833 100644 --- a/docs/es/DEVELOPMENT.es.md +++ b/docs/es/DEVELOPMENT.es.md @@ -4,9 +4,8 @@ Esta guía proporciona detalles técnicos para desarrolladores que deseen contri ## Filosofía del Proyecto -- **Solo Biblioteca Estándar**: El core y la persistencia deben depender únicamente de librerías nativas de Python (como `sqlite3`). -- **Clean Code**: Naming descriptivo (mínimo 3 caracteres), modularidad y alta cobertura de tests. -- **Transaccionalidad**: Todas las escrituras en la base de datos deben ser consistentes y seguras. +- **Librería Estándar para el Core**: El core y la persistencia dependen únicamente de librerías nativas de Python (como `sqlite3`). +- **UI Profesional**: Se utiliza `rich` para proporcionar una interfaz de terminal moderna, legible y visualmente atractiva. ## Estructura de Capas diff --git a/pyproject.toml b/pyproject.toml index 2db076a..0d06c51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "time-balance" -version = "0.3.0" +version = "0.4.0" description = "Control sencillo de jornadas y saldo horario" readme = "README.md" authors = [ { name = "autogenerated" } ] +dependencies = [ + "rich>=10.0.0" +] # setuptools (used by many CI runners) expects `project.license` to be a string/license-expression. # Change from a table to a simple string to avoid `configuration error: `project.license` must be string`. license = "GPL-3.0-only" diff --git a/setup.py b/setup.py index 6e63f4d..b6c0a0e 100644 --- a/setup.py +++ b/setup.py @@ -2,9 +2,12 @@ setup( name='time-balance', - version='0.3.0', + version='0.4.0', description='Control sencillo de jornadas y saldo horario', packages=find_packages(exclude=("tests",)), + install_requires=[ + 'rich>=10.0.0', + ], # Entry point para el comando interactivo `time-balance` entry_points={ 'console_scripts': [ diff --git a/tests/test_cli.py b/tests/test_cli.py index 3b391bb..84bf6da 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,9 @@ import unittest import tempfile -import os -import io +import io as python_io import pathlib from unittest import mock +from rich.console import Console import time_balance as ch class TestCLI(unittest.TestCase): @@ -11,11 +11,9 @@ def setUp(self): self.tmpdir = tempfile.TemporaryDirectory() self.db_path = pathlib.Path(self.tmpdir.name) / "test_cli.db" - # We need to re-initialize a DatabaseManager for tests from time_balance.storage import DatabaseManager self.db = DatabaseManager(self.db_path) - # Patch the global db instance in all modules where it's used self.patchers = [ mock.patch('time_balance.cli.db', self.db), mock.patch('time_balance.storage.db', self.db) @@ -29,57 +27,60 @@ def tearDown(self): self.tmpdir.cleanup() def test_register_day_overwrite_cancel(self): - """Verify that cancelling an overwrite does not change the record.""" - # Initial record + """Verify that cancelling an overwrite does not change the record using Rich prompts.""" self.db.upsert_record(1, "2026-01-01", 7, 0, -45) - # inputs: date, confirmation 'n' (cancel) - with mock.patch('builtins.input', side_effect=["2026-01-01", "n"]): - ch.register_day(lang="en") + # Patch request_date to return specific date and Confirm.ask for 'n' + with mock.patch('time_balance.cli.request_date', return_value="2026-01-01"): + with mock.patch('rich.prompt.Confirm.ask', return_value=False): + ch.register_day(lang="en") record = self.db.get_record_by_date(1, "2026-01-01") self.assertEqual(record["hours"], 7) def test_register_day_overwrite_confirm(self): - """Verify that confirming an overwrite updates the record in DB.""" - # Initial record + """Verify that confirming an overwrite updates the record in DB using Rich prompts.""" self.db.upsert_record(1, "2026-01-01", 7, 0, -45) - # inputs: date, confirmation 'y', hours '8', minutes '0' - with mock.patch('builtins.input', side_effect=["2026-01-01", "y", "8", "0"]): - ch.register_day(lang="en") + # Patch request_date, Confirm.ask for 'y', and Prompt.ask for hours/minutes + with mock.patch('time_balance.cli.request_date', return_value="2026-01-01"): + with mock.patch('rich.prompt.Confirm.ask', return_value=True): + with mock.patch('rich.prompt.Prompt.ask', side_effect=["8", "0"]): + ch.register_day(lang="en") record = self.db.get_record_by_date(1, "2026-01-01") self.assertEqual(record["hours"], 8) - self.assertEqual(record["difference"], 15) # 8h vs 7h 45m + self.assertEqual(record["difference"], 15) - def test_view_history_output(self): - """Verify that view_history prints the correct number of records.""" - for i in range(1, 6): + def test_view_history_table(self): + """Verify that view_history renders a Rich table with records.""" + for i in range(1, 4): self.db.upsert_record(1, f"2026-01-0{i}", 8, 0, 15) - buf = io.StringIO() - with mock.patch('sys.stdout', buf): + # Capture rich output + test_console = Console(file=python_io.StringIO(), force_terminal=False, width=100) + with mock.patch('time_balance.cli.console', test_console): ch.view_history(limit=5, lang="en") - out = buf.getvalue() - self.assertIn("--- Last 5 records ---", out) - count_dates = sum(1 for line in out.splitlines() if line.startswith("2026-")) - self.assertEqual(count_dates, 5) + output = test_console.file.getvalue() + self.assertIn("2026-01-01", output) + self.assertIn("2026-01-02", output) + self.assertIn("2026-01-03", output) + self.assertIn("8h 0m", output) - def test_cli_args_status(self): - """Verify the --status CLI argument output.""" + def test_cli_args_status_rich(self): + """Verify the --status CLI argument output with Rich formatting.""" self.db.update_project(1, "ProyA", 7, 45) self.db.upsert_record(1, "2026-01-01", 8, 0, 15) - buf = io.StringIO() - with mock.patch('sys.stdout', buf): + test_console = Console(file=python_io.StringIO(), force_terminal=False, width=100) + with mock.patch('time_balance.cli.console', test_console): with mock.patch('sys.argv', ['time-balance', '--status', '--lang', 'en']): ch.main() - out = buf.getvalue() - self.assertIn("Project: ProyA", out) - self.assertIn("Accumulated balance: 0h 15m", out) + output = test_console.file.getvalue() + self.assertIn("Project: ProyA", output) + self.assertIn("Accumulated balance: +0h 15m", output) if __name__ == "__main__": unittest.main() diff --git a/time_balance/cli.py b/time_balance/cli.py index 59f845c..9dfa254 100644 --- a/time_balance/cli.py +++ b/time_balance/cli.py @@ -1,14 +1,24 @@ import os import argparse from datetime import date, datetime +from typing import Optional + +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.prompt import Prompt, Confirm +from rich.align import Align +from rich import box + from . import constants from . import core from .storage import db from . import io from .i18n import translate, get_system_language +console = Console() -def get_current_lang(): +def get_current_lang() -> str: """Determines the active language based on settings or system.""" lang = db.get_setting("language", "auto") if lang == "auto": @@ -16,20 +26,46 @@ def get_current_lang(): return lang -def request_date(lang="en"): - """Requests date from user or uses today's as default.""" - today = date.today().strftime("%Y-%m-%d") - print(translate("date_prompt", lang=lang, today=today)) - date_input = input(translate("date_input", lang=lang)).strip() +def render_dashboard(project: dict, balance: int, lang: str): + """Renders a beautiful dashboard header with project info and balance.""" + balance_fmt = core.format_time(balance) + balance_color = "green" if balance >= 0 else "red" + if balance > 0: + balance_fmt = f"+{balance_fmt}" + + dashboard_content = ( + f"[bold cyan]{translate('project_label', lang=lang)}:[/bold cyan] {project['name'].upper()}\n" + f"[bold cyan]{translate('base_day_label', lang=lang)}:[/bold cyan] {project['base_hours']}h {project['base_minutes']}m\n" + f"[bold cyan]{translate('balance_label', lang=lang)}:[/bold cyan] [{balance_color}]{balance_fmt}[/{balance_color}]" + ) + + panel = Panel( + Align.center(dashboard_content), + title=f"[bold]{translate('dashboard_title', lang=lang)}[/bold]", + subtitle=f"v{constants.VERSION}", + border_style="bright_blue", + box=box.ROUNDED, + padding=(1, 2) + ) + console.print(panel) - if not date_input: - return today + +def request_date(lang="en") -> str: + """Requests date from user using rich prompts.""" + today = date.today().strftime("%Y-%m-%d") + console.print(f"\n[bold yellow]{translate('date_prompt', lang=lang, today=today)}[/bold yellow]") + + date_input = Prompt.ask( + translate("date_input", lang=lang), + default=today, + console=console + ).strip() try: datetime.strptime(date_input, "%Y-%m-%d") return date_input except ValueError: - print(translate("invalid_date", lang=lang)) + console.print(f"[bold red]{translate('invalid_date', lang=lang)}[/bold red]") return today @@ -41,22 +77,19 @@ def register_day(lang="en"): existing_record = db.get_record_by_date(active_id, work_date) if existing_record: - print(translate("duplicate_warning", lang=lang, date=work_date)) - print(translate("previous_record", lang=lang, hours=existing_record['hours'], minutes=existing_record['minutes'])) - confirmation = input(translate("overwrite_confirm", lang=lang)).lower() - if confirmation not in ('s', 'y'): - print(translate("op_cancelled", lang=lang)) + console.print(f"\n[bold yellow]{translate('duplicate_warning', lang=lang, date=work_date)}[/bold yellow]") + console.print(f" {translate('previous_record', lang=lang, hours=existing_record['hours'], minutes=existing_record['minutes'])}") + + if not Confirm.ask(translate("overwrite_confirm", lang=lang), default=False, console=console): + console.print(f"[blue]{translate('op_cancelled', lang=lang)}[/blue]") return - print(translate("input_header", lang=lang, date=work_date)) + console.print(f"\n[bold cyan]{translate('input_header', lang=lang, date=work_date)}[/bold cyan]") try: - hours_input = input(translate("hours_worked", lang=lang)).strip() - hours = int(hours_input) if hours_input else 0 - - minutes_input = input(translate("minutes_worked", lang=lang)).strip() - minutes = int(minutes_input) if minutes_input else 0 + hours = int(Prompt.ask(translate("hours_worked", lang=lang), default="0", console=console)) + minutes = int(Prompt.ask(translate("minutes_worked", lang=lang), default="0", console=console)) except ValueError: - print(translate("error_integers", lang=lang)) + console.print(f"[bold red]{translate('error_integers', lang=lang)}[/bold red]") return base_minutes = (project["base_hours"] * 60) + project["base_minutes"] @@ -65,96 +98,113 @@ def register_day(lang="en"): db.upsert_record(active_id, work_date, hours, minutes, difference) - print(translate("save_success", lang=lang, date=work_date)) - print(translate("day_diff", lang=lang, diff=core.format_time(difference))) + console.print(f"\n[bold green]{translate('save_success', lang=lang, date=work_date)}[/bold green]") + console.print(f" {translate('day_diff', lang=lang, diff=core.format_time(difference))}") def view_history(limit=5, lang="en"): - """Displays last records for the active project.""" + """Displays records for the active project in a rich table.""" active_id = db.get_active_project_id() records = db.get_records(active_id, limit=limit if limit > 0 else None) if limit > 0: - print(translate("recent_records_header", lang=lang, limit=limit)) + title = translate("recent_records_header", lang=lang, limit=limit) else: - print(translate("full_history_header", lang=lang)) + title = translate("full_history_header", lang=lang) if not records: - print(translate("no_records", lang=lang)) + console.print(f"\n[yellow]{translate('no_records', lang=lang)}[/yellow]") return - work_label = translate("work_label", lang=lang) - bal_label = translate("balance_short_label", lang=lang) + table = Table(title=f"\n[bold]{title}[/bold]", box=box.SIMPLE_HEAD, header_style="bold magenta") + table.add_column("Date", style="cyan", justify="center") + table.add_column(translate("work_label", lang=lang), justify="right") + table.add_column(translate("balance_short_label", lang=lang), justify="right") for record in records: time_fmt = core.format_time(record['difference']) + color = "green" if record['difference'] >= 0 else "red" if record['difference'] > 0: - time_fmt = "+" + time_fmt - print(f"{record['date']} | {work_label}: {record['hours']}h {record['minutes']}m | {bal_label}: {time_fmt}") + time_fmt = f"+{time_fmt}" + + table.add_row( + record['date'], + f"{record['hours']}h {record['minutes']}m", + f"[{color}]{time_fmt}[/{color}]" + ) + + console.print(table) def manage_projects(lang="en"): - """Submenu for multi-project management.""" + """Submenu for multi-project management with rich styling.""" while True: - os.system('cls' if os.name == 'nt' else 'clear') - print(f"\n--- {translate('projects_menu_header', lang=lang)} ---") + console.clear() projects = db.get_projects() active_id = db.get_active_project_id() - + + table = Table(title=f"\n[bold]{translate('projects_menu_header', lang=lang)}[/bold]", box=box.DOUBLE_EDGE) + table.add_column("ID", justify="center", style="dim") + table.add_column("Name", style="bold green") + table.add_column("Base", justify="center") + table.add_column("Active", justify="center") + for p in projects: - marker = " [*]" if p['id'] == active_id else "" - print(f"{p['id']}. {p['name']} ({p['base_hours']}h {p['base_minutes']}m){marker}") + is_active = "[bold cyan]●[/bold cyan]" if p['id'] == active_id else "" + table.add_row(str(p['id']), p['name'], f"{p['base_hours']}h {p['base_minutes']}m", is_active) + + console.print(table) - print(f"\n{translate('switch_project', lang=lang)}") - print(translate('create_project', lang=lang)) - print(translate('edit_project', lang=lang)) - print(translate('back', lang=lang)) + console.print(f"\n[bold cyan]1.[/bold cyan] {translate('switch_project', lang=lang)}") + console.print(f"[bold cyan]2.[/bold cyan] {translate('create_project', lang=lang)}") + console.print(f"[bold cyan]3.[/bold cyan] {translate('edit_project', lang=lang)}") + console.print(f"[bold cyan]4.[/bold cyan] {translate('back', lang=lang)}") - choice = input(f"\n{translate('choose_option', lang=lang)}").strip() + choice = Prompt.ask(f"\n{translate('choose_option', lang=lang)}", choices=["1", "2", "3", "4"], console=console) if choice == "1": - target_id = input(translate("enter_project_id", lang=lang)).strip() + target_id = Prompt.ask(translate("enter_project_id", lang=lang), console=console) if target_id.isdigit() and any(p['id'] == int(target_id) for p in projects): db.set_active_project_id(int(target_id)) else: - print(translate("invalid_option", lang=lang)) - input(translate("press_enter", lang=lang)) + console.print(f"[bold red]{translate('invalid_option', lang=lang)}[/bold red]") + Prompt.ask(translate("press_enter", lang=lang), console=console) elif choice == "2": - name = input(translate("project_name_prompt", lang=lang, current="New")).strip() + name = Prompt.ask(translate("project_name_prompt", lang=lang, current="New"), console=console).strip() if name: try: - h = int(input(translate("base_hours_prompt", lang=lang, current=constants.BASE_HOURS)) or constants.BASE_HOURS) - m = int(input(translate("base_minutes_prompt", lang=lang, current=constants.BASE_MINUTES)) or constants.BASE_MINUTES) + h = int(Prompt.ask(translate("base_hours_prompt", lang=lang, current=constants.BASE_HOURS), default=str(constants.BASE_HOURS), console=console)) + m = int(Prompt.ask(translate("base_minutes_prompt", lang=lang, current=constants.BASE_MINUTES), default=str(constants.BASE_MINUTES), console=console)) new_id = db.create_project(name, h, m) db.set_active_project_id(new_id) except ValueError: - print(translate("error_integers", lang=lang)) - input(translate("press_enter", lang=lang)) + console.print(f"[bold red]{translate('error_integers', lang=lang)}[/bold red]") + Prompt.ask(translate("press_enter", lang=lang), console=console) elif choice == "3": p = db.get_project_by_id(active_id) - name = input(translate("project_name_prompt", lang=lang, current=p['name'])).strip() or p['name'] + name = Prompt.ask(translate("project_name_prompt", lang=lang, current=p['name']), default=p['name'], console=console).strip() try: - h_input = input(translate("base_hours_prompt", lang=lang, current=p['base_hours'])).strip() - h = int(h_input) if h_input else p['base_hours'] - - m_input = input(translate("base_minutes_prompt", lang=lang, current=p['base_minutes'])).strip() - m = int(m_input) if m_input else p['base_minutes'] + h = int(Prompt.ask(translate("base_hours_prompt", lang=lang, current=p['base_hours']), default=str(p['base_hours']), console=console)) + m = int(Prompt.ask(translate("base_minutes_prompt", lang=lang, current=p['base_minutes']), default=str(p['base_minutes']), console=console)) db.update_project(active_id, name, h, m) - lang_opt = input(translate("language_prompt", lang=lang, current=db.get_setting("language", "auto"))).strip().lower() - if lang_opt in ("en", "es", "auto"): - db.set_setting("language", lang_opt) - lang = get_current_lang() + lang_opt = Prompt.ask( + translate("language_prompt", lang=lang, current=db.get_setting("language", "auto")), + choices=["en", "es", "auto"], + default="auto", + console=console + ).strip().lower() + db.set_setting("language", lang_opt) except ValueError: - print(translate("error_integers", lang=lang)) - input(translate("press_enter", lang=lang)) + console.print(f"[bold red]{translate('error_integers', lang=lang)}[/bold red]") + Prompt.ask(translate("press_enter", lang=lang), console=console) elif choice == "4": break def migrate_from_json(file_path, lang="en"): - """Migrates records from a legacy JSON file to a new SQLite project.""" + """Migrates records from a legacy JSON file using rich feedback.""" try: source_data = io.read_history_file(file_path) records = source_data.get("records", {}) @@ -168,7 +218,6 @@ def migrate_from_json(file_path, lang="en"): metadata.get('minutes_base', constants.BASE_MINUTES) ) except Exception: - # If name exists (e.g. General), use active project project_id = db.get_active_project_id() name = db.get_project_by_id(project_id)['name'] @@ -177,76 +226,78 @@ def migrate_from_json(file_path, lang="en"): db.upsert_record(project_id, date_str, info['hours'], info['minutes'], info['difference']) count += 1 - print(translate("migration_success", lang=lang, count=count, name=name)) + console.print(f"[bold green]{translate('migration_success', lang=lang, count=count, name=name)}[/bold green]") except Exception as e: - print(translate("migration_error", lang=lang, error=str(e))) + console.print(f"[bold red]{translate('migration_error', lang=lang, error=str(e))}[/bold red]") def interactive_menu(): - """Main interactive menu loop.""" + """Main interactive menu loop with rich grouping.""" while True: lang = get_current_lang() active_id = db.get_active_project_id() project = db.get_project_by_id(active_id) total_balance = db.get_total_balance(active_id) - os.system('cls' if os.name == 'nt' else 'clear') + console.clear() + render_dashboard(project, total_balance, lang) + + console.print(f"\n[bold magenta]>> {translate('daily_header', lang=lang)}[/bold magenta]") + console.print(f" [bold cyan]1.[/bold cyan] {translate('option_1', lang=lang)[3:]}") + console.print(f" [bold cyan]2.[/bold cyan] {translate('option_2', lang=lang)[3:]}") + + console.print(f"\n[bold magenta]>> {translate('config_header_menu', lang=lang)}[/bold magenta]") + console.print(f" [bold cyan]3.[/bold cyan] {translate('option_3', lang=lang)[3:]}") - print("\n" + "="*50) - print(f" {translate('project_label', lang=lang)}: {project['name'].upper()}") - print(f" {translate('balance_label', lang=lang)}: {core.format_time(total_balance)}") - print(f" ({translate('base_day_label', lang=lang)}: {project['base_hours']}h {project['base_minutes']}m)") - print("="*50) + console.print(f"\n[bold magenta]>> {translate('data_header', lang=lang)}[/bold magenta]") + console.print(f" [bold cyan]4.[/bold cyan] {translate('option_4', lang=lang)[3:]}") + console.print(f" [bold cyan]5.[/bold cyan] {translate('option_5', lang=lang)[3:]}") - print(f"\n{translate('option_1', lang=lang)}") - print(translate('option_2', lang=lang)) - print(translate('option_3', lang=lang)) - print(translate('option_4', lang=lang)) - print(translate('option_5', lang=lang)) - print(translate('option_6', lang=lang)) + console.print(f"\n[bold magenta]>> {translate('option_6', lang=lang)[3:]}[/bold magenta]") + console.print(f" [bold cyan]6.[/bold cyan] {translate('option_6', lang=lang)[3:]}") - option = input(f"\n{translate('choose_option', lang=lang)}").strip() + option = Prompt.ask(f"\n{translate('choose_option', lang=lang)}", choices=["1", "2", "3", "4", "5", "6"], show_choices=False, console=console) if option == "1": register_day(lang=lang) - input(translate('press_enter', lang=lang)) + Prompt.ask(translate('press_enter', lang=lang), console=console) elif option == "2": view_history(lang=lang) - input(translate('press_enter', lang=lang)) + Prompt.ask(translate('press_enter', lang=lang), console=console) elif option == "3": manage_projects(lang=lang) elif option == "4": - path = input(translate('export_dest_prompt', lang=lang)).strip() + path = Prompt.ask(translate('export_dest_prompt', lang=lang), console=console).strip() if path: try: - # Convert DB data back to JSON-like structure for export - project_data = db.get_project_by_id(active_id) records = db.get_records(active_id) data_to_export = { "metadata": { - "project_name": project_data['name'], - "hours_base": project_data['base_hours'], - "minutes_base": project_data['base_minutes'], + "project_name": project['name'], + "hours_base": project['base_hours'], + "minutes_base": project['base_minutes'], "version": "1.0", "language": db.get_setting("language", "auto") }, "records": {r['date']: {'hours': r['hours'], 'minutes': r['minutes'], 'difference': r['difference']} for r in records} } dest = io.export_history(data_to_export, path) - print(translate('export_success', lang=lang, path=dest)) + console.print(f"[bold green]{translate('export_success', lang=lang, path=dest)}[/bold green]") except Exception as err: - print(translate('export_error', lang=lang, error=err)) - input(translate('press_enter', lang=lang)) + console.print(f"[bold red]{translate('export_error', lang=lang, error=err)}[/bold red]") + Prompt.ask(translate('press_enter', lang=lang), console=console) elif option == "5": - path = input(translate('import_src_prompt', lang=lang)).strip() + path = Prompt.ask(translate('import_src_prompt', lang=lang), console=console).strip() if path: - mode_input = input(translate('import_mode_prompt', lang=lang, merge=constants.MODE_MERGE, overwrite=constants.MODE_OVERWRITE)).strip().lower() - mode = mode_input if mode_input else constants.MODE_MERGE + mode = Prompt.ask( + translate('import_mode_prompt', lang=lang, merge=constants.MODE_MERGE, overwrite=constants.MODE_OVERWRITE), + choices=[constants.MODE_MERGE, constants.MODE_OVERWRITE], + default=constants.MODE_MERGE, + console=console + ) try: source_data = io.read_history_file(path) - if mode == constants.MODE_OVERWRITE: - # Clear existing records for this project if overwriting with db._get_connection() as conn: conn.execute("DELETE FROM records WHERE project_id = ?", (active_id,)) @@ -254,12 +305,12 @@ def interactive_menu(): for date_str, info in source_data['records'].items(): db.upsert_record(active_id, date_str, info['hours'], info['minutes'], info['difference']) count += 1 - print(translate('import_success', lang=lang, count=count)) + console.print(f"[bold green]{translate('import_success', lang=lang, count=count)}[/bold green]") except Exception as err: - print(translate('import_error', lang=lang, error=err)) - input(translate('press_enter', lang=lang)) + console.print(f"[bold red]{translate('import_error', lang=lang, error=err)}[/bold red]") + Prompt.ask(translate('press_enter', lang=lang), console=console) elif option == "6": - print(translate('exit_msg', lang=lang)) + console.print(f"\n[bold blue]{translate('exit_msg', lang=lang)}[/bold blue]") break @@ -267,26 +318,16 @@ def main(): parser = argparse.ArgumentParser( description="time-balance: A professional tool to track your working hours balance globally." ) - parser.add_argument( - "-s", "--status", action="store_true", help="Show only the accumulated balance." - ) - parser.add_argument( - "-l", "--list", type=int, nargs="?", const=5, help="List last N records (default 5)." - ) - parser.add_argument( - "--version", action="store_true", help="Show application version." - ) - parser.add_argument( - "--lang", type=str, choices=["en", "es", "auto"], default="auto", help="Force interface language." - ) - parser.add_argument( - "--migrate", type=str, metavar="JSON_FILE", help="Migrate records from a legacy JSON file." - ) + parser.add_argument("-s", "--status", action="store_true", help="Show only the accumulated balance.") + parser.add_argument("-l", "--list", type=int, nargs="?", const=5, help="List last N records (default 5).") + parser.add_argument("--version", action="store_true", help="Show application version.") + parser.add_argument("--lang", type=str, choices=["en", "es", "auto"], default="auto", help="Force interface language.") + parser.add_argument("--migrate", type=str, metavar="JSON_FILE", help="Migrate records from a legacy JSON file.") args = parser.parse_args() if args.version: - print(f"time-balance v{constants.VERSION}") + console.print(f"time-balance [bold cyan]v{constants.VERSION}[/bold cyan]") return lang = args.lang @@ -302,8 +343,12 @@ def main(): if args.status: total_balance = db.get_total_balance(active_id) - print(translate("status_project", lang=lang, name=project['name'])) - print(translate("status_balance", lang=lang, balance=core.format_time(total_balance))) + balance_fmt = core.format_time(total_balance) + color = "green" if total_balance >= 0 else "red" + if total_balance > 0: balance_fmt = f"+{balance_fmt}" + + console.print(f"[bold cyan]{translate('status_project', lang=lang, name=project['name'])}[/bold cyan]") + console.print(f"[bold]{translate('status_balance', lang=lang, balance=f'[{color}]{balance_fmt}[/{color}]')}[/bold]") return if args.list is not None: diff --git a/time_balance/constants.py b/time_balance/constants.py index debd6f4..5500c4a 100644 --- a/time_balance/constants.py +++ b/time_balance/constants.py @@ -1,7 +1,7 @@ import os import pathlib -VERSION = "0.3.0" +VERSION = "0.4.0" # --- PATH CONFIGURATION --- def get_data_dir() -> pathlib.Path: diff --git a/time_balance/i18n.py b/time_balance/i18n.py index f3f44ec..9f2e143 100644 --- a/time_balance/i18n.py +++ b/time_balance/i18n.py @@ -8,13 +8,18 @@ "project_label": "PROJECT", "balance_label": "TOTAL ACCUMULATED BALANCE", "base_day_label": "Daily base", - "option_1": "1. Register workday (or correct day)", - "option_2": "2. View recent records", - "option_3": "3. Manage projects (switch/create/edit)", - "option_4": "4. Export history to file", - "option_5": "5. Import history from file", - "option_6": "6. Exit", - "option_7": "7. (Reserved)", + "option_1": "1. 📝 Register workday (or correct day)", + "option_2": "2. 📊 View recent records", + "option_3": "3. ⚙️ Manage projects (switch/create/edit)", + "option_4": "4. 💾 Export history to file", + "option_5": "5. 📥 Import history from file", + "option_6": "6. 🚪 Exit", + "daily_header": "DAILY ACTIONS", + "config_header_menu": "CONFIGURATION", + "data_header": "DATA MANAGEMENT", + "dashboard_title": "DASHBOARD", + "choose_option": "Choose option", + "projects_menu_header": "PROJECT MANAGEMENT", "switch_project": "1. Switch project", "create_project": "2. Create new project", @@ -66,13 +71,18 @@ "project_label": "PROYECTO", "balance_label": "SALDO TOTAL ACUMULADO", "base_day_label": "Base diaria", - "option_1": "1. Registrar jornada (o corregir día)", - "option_2": "2. Ver últimos registros", - "option_3": "3. Gestionar proyectos (cambiar/crear/editar)", - "option_4": "4. Exportar historial a archivo", - "option_5": "5. Importar historial desde archivo", - "option_6": "6. Salir", - "option_7": "7. (Reservado)", + "option_1": "1. 📝 Registrar jornada (o corregir día)", + "option_2": "2. 📊 Ver últimos registros", + "option_3": "3. ⚙️ Gestionar proyectos (cambiar/crear/editar)", + "option_4": "4. 💾 Exportar historial a archivo", + "option_5": "5. 📥 Importar historial desde archivo", + "option_6": "6. 🚪 Salir", + "daily_header": "ACCIONES DIARIAS", + "config_header_menu": "CONFIGURACIÓN", + "data_header": "GESTIÓN DE DATOS", + "dashboard_title": "CENTRO DE CONTROL", + "choose_option": "Elige opción", + "projects_menu_header": "GESTIÓN DE PROYECTOS", "switch_project": "1. Cambiar de proyecto", "create_project": "2. Crear nuevo proyecto", From 5164c444769be3d507bb1a4316e1f7ff19eca73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 09:59:08 +0200 Subject: [PATCH 3/9] refactor: enhance project management and configuration menus with pagination support --- README.es.md | 28 ++-- README.md | 41 ++--- docs/CLI-GUIDE.md | 68 ++++---- docs/es/CLI-GUIDE.es.md | 68 ++++---- tests/test_cli.py | 32 ++++ tests/test_storage.py | 29 ++-- time_balance/__init__.py | 4 + time_balance/cli.py | 335 ++++++++++++++++++++++----------------- time_balance/i18n.py | 50 ++++++ time_balance/storage.py | 15 +- 10 files changed, 404 insertions(+), 266 deletions(-) diff --git a/README.es.md b/README.es.md index e1acc2e..39f189c 100644 --- a/README.es.md +++ b/README.es.md @@ -9,7 +9,7 @@ ## Descripción -`time-balance` es ahora una aplicación de consola **global**. Ya no depende de dónde la ejecutes; tus proyectos y registros se almacenan en una base de datos SQLite centralizada en tu sistema. Calcula automáticamente la diferencia diaria respecto a una jornada base y mantiene un saldo acumulado por cada proyecto de forma independiente. +`time-balance` es una aplicación de consola **global**. Ya no depende de dónde la ejecutes; tus proyectos y registros se almacenan en una base de datos SQLite centralizada en tu sistema. Calcula automáticamente la diferencia diaria respecto a una jornada base y mantiene un saldo acumulado por cada proyecto de forma independiente. --- @@ -37,15 +37,14 @@ Ejecuta el comando desde cualquier carpeta para abrir el gestor: time-balance ``` -### 2. Gestión de Proyectos -Dentro del menú, la **opción 3** te permite crear nuevos proyectos (ej: "Cliente A", "Proyecto Personal") y cambiar entre ellos. La aplicación recordará cuál fue el último proyecto que usaste la próxima vez que la abras. +### 2. Navegación Intuitiva +La interfaz utiliza un estándar claro: +- **Números (1-5)** para seleccionar acciones. +- **Letras** para navegación: `V` para volver, `N`/`P` para navegar por las páginas del historial. -### 3. Migración de datos antiguos (v0.2.x) -Si tienes archivos `historial_hours.json` de versiones anteriores, puedes importarlos fácilmente a un nuevo proyecto en la base de datos global: - -```bash -time-balance --migrate ./ruta/al/historial_hours.json -``` +### 3. Gestión de Proyectos y Configuración +- **Opción 3 (Configuración)**: Ajusta el nombre, la jornada base o el idioma del proyecto activo. También permite importar/exportar datos. +- **Opción 4 (Cambiar Proyecto)**: Cambia entre tus diferentes contextos de trabajo o crea uno nuevo. ### 4. Consultas Rápidas Consulta tu estado sin entrar al menú: @@ -56,19 +55,16 @@ time-balance --status # Listar los últimos 10 registros del proyecto activo time-balance --list 10 - -# Forzar un idioma (es/en) -time-balance --lang es ``` --- -## Características de la Versión 0.3.0 +## Características Principales +- ✅ **Historial Paginado**: Navega cómodamente por tus registros, sin importar cuántos tengas. - ✅ **Almacenamiento SQLite**: Persistencia robusta y profesional en rutas estándar (XDG). -- ✅ **Multi-proyecto**: Gestiona diferentes contextos de trabajo desde una única instalación. -- ✅ **Instalación Global**: Accede a tus datos desde cualquier ubicación en la terminal. -- ✅ **Migración Automática**: Comando dedicado para traer tus datos de la era JSON. +- ✅ **Multi-proyecto**: Gestiona diferentes contextos de trabajo de forma independiente. +- ✅ **Navegación Estandarizada**: Uso consistente de teclas para una mejor experiencia de usuario. - ✅ **Privacidad**: Todo sigue siendo 100% local en tu equipo. --- diff --git a/README.md b/README.md index 70ea76c..2b6f9c2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## Description -`time-balance` is now a **global** console application. It no longer depends on where you run it; your projects and records are stored in a centralized SQLite database on your machine. It automatically calculates the daily difference against a base workday and maintains an accumulated balance for each project independently. +`time-balance` is a **global** console application. It no longer depends on where you run it; your projects and records are stored in a centralized SQLite database on your machine. It automatically calculates the daily difference against a base workday and maintains an accumulated balance for each project independently. --- @@ -37,15 +37,14 @@ Simply run the command from any folder to open the manager: time-balance ``` -### 2. Multi-Project Management -Inside the menu, **option 3** allows you to create new projects (e.g., "Client A", "Personal") and switch between them. The app remembers your last active project globally. +### 2. Intuitive Navigation +The interface uses a clear standard: +- **Numbers (1-5)** to select actions. +- **Letters** for navigation: `V` to go back, `N`/`P` to navigate history pages. -### 3. Migrating Legacy Data (v0.2.x) -If you have `historial_hours.json` files from previous versions, you can easily import them into a new project in the global database: - -```bash -time-balance --migrate ./path/to/historial_hours.json -``` +### 3. Project Management and Configuration +- **Option 3 (Configuration)**: Adjust the name, base workday, or language of the active project. Also allows data import/export. +- **Option 4 (Change Project)**: Switch between different work contexts or create a new one. ### 4. Quick Commands Check your status without entering the menu: @@ -56,19 +55,16 @@ time-balance --status # List last 10 records for the active project time-balance --list 10 - -# Force a specific language (en/es) -time-balance --lang en ``` --- -## Version 0.3.0 Features +## Main Features +- ✅ **Paginated History**: Comfortably browse your records, no matter how many you have. - ✅ **SQLite Backend**: Robust and professional persistence in standard paths (XDG). -- ✅ **Multi-project**: Manage different work contexts from a single installation. -- ✅ **Global Installation**: Access your data from any terminal location. -- ✅ **Migration Tool**: Dedicated command to bring your data from the JSON era. +- ✅ **Multi-project**: Manage different work contexts independently. +- ✅ **Standardized Navigation**: Consistent use of keys for a better user experience. - ✅ **Privacy-Focused**: All data remains 100% local on your machine. --- @@ -91,16 +87,3 @@ If you want to contribute or understand how it works internally: ## License This project is Open Source under the [GPL-3.0](LICENSE) license. - ---- - -## Development and Contributions - -If you want to contribute or understand how it works internally: -- [**ARCHITECTURE.md**](docs/ARCHITECTURE.md): System design and modules. -- [**DEVELOPMENT.md**](docs/DEVELOPMENT.md): Technical guide for developers. -- [**CONTRIBUTING.md**](docs/CONTRIBUTING.md): How to submit improvements and translations. - -## License - -This project is Open Source under the [GPL-3.0](LICENSE) license. diff --git a/docs/CLI-GUIDE.md b/docs/CLI-GUIDE.md index 854efd5..8b879f6 100644 --- a/docs/CLI-GUIDE.md +++ b/docs/CLI-GUIDE.md @@ -1,6 +1,6 @@ # Usage Guide: CLI Interface -`time-balance` offers a dual interface: an interactive menu for daily use and direct commands for quick queries. In version 0.3.0, the application is **global** and supports **multiple projects**. +`time-balance` offers a dual interface: an interactive menu for daily use and direct commands for quick queries. In the current version, the application is **global** and supports **multiple projects** with optimized navigation. ## Direct Commands (Fast Mode) @@ -31,54 +31,54 @@ To start the full control center: time-balance ``` +### Standard Navigation +For a smooth experience, `time-balance` uses a hybrid system: +- **Numbers (1-5)**: To select actions and configuration options. +- **Letters**: For navigation and movement. + - `V`: Go Back to previous menu. + - `N`: Next page (in history). + - `P`: Previous page (in history). + +--- + ### Main Menu -The interface automatically detects your system language. It shows the status of the **active project**. +The main menu is sober and direct, always showing the **Dashboard** of the active project at the top. ``` -================================================== - PROJECT: MY WORK PROJECT - TOTAL ACCUMULATED BALANCE: +2h 15m - (Daily base: 7h 45m) -================================================== - -Options: -1. Register workday (or correct day) -2. View recent records -3. Manage projects (switch/create/edit) -4. Export history to file -5. Import history from file -6. Exit - -Choose option: _ +1. Register workday +2. View records +3. Configuration +4. Change project +5. Exit ``` -## Detailed Options +## Detailed Sections ### 1. Register Workday -Record worked hours for a specific date (defaults to today). It calculates the difference against the base workday of the **current active project**. - -### 2. View Recent Records -Displays the last 5 records (or as many as specified) for the active project. +Record worked hours for a specific date (defaults to today). It calculates the difference against the base workday of the active project. -### 3. Manage Projects -Opens a submenu to: -- **Switch project**: Change which project is currently active globally. -- **Create new project**: Initialize a new work context with its own base workday. -- **Edit project**: Change the name or base workday of the current project. +### 2. View Records (Paginated) +Displays the complete project history in tables of 10 records. +- Use `N` and `P` to navigate between pages. +- Use `V` to go back to the main menu. -### 4. Export History -Exports the active project's data to a structured JSON file. +### 3. Configuration +Submenu divided into sections for clear management: +- **Project Settings**: Edit name, adjust daily base (hours/minutes), and language. +- **Data Management**: Import and Export options for JSON files. +- Use `V` to go back. -### 5. Import History -Imports data from a JSON file into the **current active project**. -- **Merge Mode**: Adds new records and updates existing ones. -- **Overwrite Mode**: Clears all current records before importing. +### 4. Change Project +Dedicated section for multi-tenancy management. +- Allows selecting an existing project from the list. +- Allows creating a new project from scratch. +- Use `V` to go back. --- ## Data Persistence -Data is stored in a centralized SQLite database. You no longer need to worry about `historial_hours.json` files in your project folders unless you want to export or migrate them. +Data is stored in a centralized SQLite database. You no longer need to worry about local files in your project folders. ### Default Paths - **macOS**: `~/Library/Application Support/time-balance/` diff --git a/docs/es/CLI-GUIDE.es.md b/docs/es/CLI-GUIDE.es.md index c5bd900..0b9520f 100644 --- a/docs/es/CLI-GUIDE.es.md +++ b/docs/es/CLI-GUIDE.es.md @@ -1,6 +1,6 @@ # Guía de Uso: Interfaz CLI -`time-balance` ofrece una interfaz dual: un menú interactivo para el uso diario y comandos directos para consultas rápidas. En la versión 0.3.0, la aplicación es **global** y soporta **múltiples proyectos**. +`time-balance` ofrece una interfaz dual: un menú interactivo para el uso diario y comandos directos para consultas rápidas. En la versión actual, la aplicación es **global** y soporta **múltiples proyectos** con una navegación optimizada. ## Comandos Directos (Modo Rápido) @@ -31,54 +31,54 @@ Para iniciar el centro de control completo: time-balance ``` +### Navegación Estándar +Para una experiencia fluida, `time-balance` utiliza un sistema híbrido: +- **Números (1-5)**: Para seleccionar acciones y opciones de configuración. +- **Letras**: Para navegación y movimiento. + - `V`: Volver al menú anterior. + - `N`: Página siguiente (en historial). + - `P`: Página anterior (en historial). + +--- + ### Menú Principal -La interfaz detecta automáticamente el idioma de tu sistema. Muestra el estado del **proyecto activo**. +El menú principal es sobrio y directo, mostrando siempre el **Dashboard** del proyecto activo en la parte superior. ``` -================================================== - PROYECTO: MI PROYECTO DE TRABAJO - SALDO TOTAL ACUMULADO: +2h 15m - (Base diaria: 7h 45m) -================================================== - -Opciones: -1. Registrar jornada (o corregir día) -2. Ver últimos registros -3. Gestionar proyectos (cambiar/crear/editar) -4. Exportar historial a archivo -5. Importar historial desde archivo -6. Salir - -Elige opción: _ +1. Registrar jornada +2. Ver registros +3. Configuración +4. Cambiar proyecto +5. Salir ``` -## Opciones Detalladas +## Secciones Detalladas ### 1. Registrar Jornada -Permite anotar las horas trabajadas para una fecha (por defecto hoy). Calcula la diferencia respecto a la jornada base del **proyecto activo actual**. - -### 2. Ver Últimos Registros -Muestra los últimos 5 registros (o los que especifiques) para el proyecto activo. +Permite anotar las horas trabajadas para una fecha (por defecto hoy). Calcula la diferencia respecto a la jornada base del proyecto activo. -### 3. Gestionar Proyectos -Abre un submenú para: -- **Cambiar de proyecto**: Cambiar cuál es el proyecto activo a nivel global. -- **Crear nuevo proyecto**: Inicializar un nuevo contexto de trabajo con su propia jornada base. -- **Editar proyecto**: Cambiar el nombre o la jornada base del proyecto actual. +### 2. Ver Registros (Paginado) +Muestra el historial completo del proyecto en tablas de 10 registros. +- Usa `N` y `P` para navegar entre páginas. +- Usa `V` para volver al menú principal. -### 4. Exportar Historial -Exporta los datos del proyecto activo a un archivo JSON estructurado. +### 3. Configuración +Submenú dividido en secciones para una gestión clara: +- **Ajustes del Proyecto**: Editar nombre, ajustar jornada base (horas/minutos) e idioma. +- **Gestión de Datos**: Opciones de Importación y Exportación de archivos JSON. +- Usa `V` para volver. -### 5. Importar Historial -Importa datos desde un archivo JSON hacia el **proyecto activo actual**. -- **Modo Merge**: Añade nuevos registros y actualiza los existentes. -- **Modo Overwrite**: Borra todos los registros actuales antes de importar. +### 4. Cambiar Proyecto +Sección dedicada a la gestión de multi-tenencia. +- Permite seleccionar un proyecto existente de la lista. +- Permite crear un nuevo proyecto desde cero. +- Usa `V` para volver. --- ## Persistencia de Datos -Los datos se guardan en una base de datos SQLite centralizada. Ya no necesitas preocuparte por archivos `historial_hours.json` en las carpetas de tus proyectos, a menos que quieras exportarlos o migrarlos. +Los datos se guardan en una base de datos SQLite centralizada. Ya no necesitas preocuparte por archivos locales en las carpetas de tus proyectos. ### Rutas por Defecto - **macOS**: `~/Library/Application Support/time-balance/` diff --git a/tests/test_cli.py b/tests/test_cli.py index 84bf6da..6356ba4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,6 +68,38 @@ def test_view_history_table(self): self.assertIn("2026-01-03", output) self.assertIn("8h 0m", output) + def test_view_history_pagination(self): + """Verify that view_history handles interactive pagination (Next, Back).""" + # Create 15 records to have 2 pages + for i in range(1, 16): + self.db.upsert_record(1, f"2026-01-{i:02d}", 8, 0, 15) + + test_console = Console(file=python_io.StringIO(), force_terminal=False, width=100) + # Mock inputs: 'n' (next page), then 'v' (back to menu) + with mock.patch('time_balance.cli.console', test_console): + with mock.patch('rich.prompt.Prompt.ask', side_effect=["n", "v"]): + ch.view_history(lang="en") + + output = test_console.file.getvalue() + # Page 1 should have newest records + self.assertIn("2026-01-15", output) + # Page 2 info should be visible after 'n' + self.assertIn("Page 2 of 2", output) + self.assertIn("2026-01-01", output) + + def test_config_menu_v_navigation(self): + """Verify that config_menu uses 'v' for navigation and sections are visible.""" + test_console = Console(file=python_io.StringIO(), force_terminal=False, width=100) + # Mock input: 'v' to exit config menu immediately + with mock.patch('time_balance.cli.console', test_console): + with mock.patch('rich.prompt.Prompt.ask', side_effect=["v"]): + ch.config_menu(lang="en") + + output = test_console.file.getvalue() + self.assertIn("PROJECT SETTINGS", output) + self.assertIn("DATA MANAGEMENT", output) + self.assertIn("V. Back to main menu", output) + def test_cli_args_status_rich(self): """Verify the --status CLI argument output with Rich formatting.""" self.db.update_project(1, "ProyA", 7, 45) diff --git a/tests/test_storage.py b/tests/test_storage.py index 03cac23..573cac8 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -41,20 +41,31 @@ def test_create_and_get_projects(self): self.assertEqual(project['base_minutes'], 30) def test_upsert_and_get_records(self): - """Verify record creation, update and retrieval sorting.""" + """Verify record creation, update and retrieval sorting with pagination.""" project_id = 1 - self.db.upsert_record(project_id, "2026-04-20", 8, 0, 15) - self.db.upsert_record(project_id, "2026-04-21", 7, 45, 0) + for i in range(1, 6): + self.db.upsert_record(project_id, f"2026-04-{10+i}", 8, 0, 15) + # Test basic retrieval records = self.db.get_records(project_id) - self.assertEqual(len(records), 2) - # Records should be sorted by date DESC by default - self.assertEqual(records[0]['date'], "2026-04-21") + self.assertEqual(len(records), 5) + self.assertEqual(records[0]['date'], "2026-04-15") + + # Test limit and offset + records_p1 = self.db.get_records(project_id, limit=2, offset=0) + self.assertEqual(len(records_p1), 2) + self.assertEqual(records_p1[0]['date'], "2026-04-15") + + records_p2 = self.db.get_records(project_id, limit=2, offset=2) + self.assertEqual(len(records_p2), 2) + self.assertEqual(records_p2[0]['date'], "2026-04-13") + # Test count + self.assertEqual(self.db.count_records(project_id), 5) + # Test update (UPSERT) - self.db.upsert_record(project_id, "2026-04-21", 9, 0, 75) - records = self.db.get_records(project_id) - self.assertEqual(len(records), 2) + self.db.upsert_record(project_id, "2026-04-15", 9, 0, 75) + records = self.db.get_records(project_id, limit=1) self.assertEqual(records[0]['hours'], 9) self.assertEqual(records[0]['difference'], 75) diff --git a/time_balance/__init__.py b/time_balance/__init__.py index 72b0173..352470a 100644 --- a/time_balance/__init__.py +++ b/time_balance/__init__.py @@ -24,6 +24,8 @@ request_date, register_day, view_history, + config_menu, + project_menu, main ) @@ -47,5 +49,7 @@ 'request_date', 'register_day', 'view_history', + 'config_menu', + 'project_menu', 'main' ] diff --git a/time_balance/cli.py b/time_balance/cli.py index 9dfa254..bc1fbb2 100644 --- a/time_balance/cli.py +++ b/time_balance/cli.py @@ -102,51 +102,197 @@ def register_day(lang="en"): console.print(f" {translate('day_diff', lang=lang, diff=core.format_time(difference))}") -def view_history(limit=5, lang="en"): - """Displays records for the active project in a rich table.""" +def view_history(limit=None, lang="en"): + """Displays records for the active project. If limit is None, it enters interactive pagination.""" active_id = db.get_active_project_id() - records = db.get_records(active_id, limit=limit if limit > 0 else None) - if limit > 0: - title = translate("recent_records_header", lang=lang, limit=limit) - else: - title = translate("full_history_header", lang=lang) + if limit is not None: + # Static mode for CLI --list argument + records = db.get_records(active_id, limit=limit if limit > 0 else None) + if not records: + console.print(f"\n[yellow]{translate('no_records', lang=lang)}[/yellow]") + return - if not records: - console.print(f"\n[yellow]{translate('no_records', lang=lang)}[/yellow]") + title = translate("full_history_header", lang=lang) if limit <= 0 else translate("recent_records_header", lang=lang, limit=limit) + table = Table(title=f"\n[bold]{title}[/bold]", box=box.SIMPLE_HEAD, header_style="bold cyan") + table.add_column("Date", style="dim", justify="center") + table.add_column(translate("work_label", lang=lang), justify="right") + table.add_column(translate("balance_short_label", lang=lang), justify="right") + + for record in records: + time_fmt = core.format_time(record['difference']) + color = "green" if record['difference'] >= 0 else "red" + if record['difference'] > 0: + time_fmt = f"+{time_fmt}" + table.add_row(record['date'], f"{record['hours']}h {record['minutes']}m", f"[{color}]{time_fmt}[/{color}]") + console.print(table) return - table = Table(title=f"\n[bold]{title}[/bold]", box=box.SIMPLE_HEAD, header_style="bold magenta") - table.add_column("Date", style="cyan", justify="center") - table.add_column(translate("work_label", lang=lang), justify="right") - table.add_column(translate("balance_short_label", lang=lang), justify="right") + # Interactive Pagination mode + page_size = 10 + current_page = 0 + while True: + total_count = db.count_records(active_id) + if total_count == 0: + console.print(f"\n[yellow]{translate('no_records', lang=lang)}[/yellow]") + Prompt.ask(translate('press_enter', lang=lang), console=console) + break + + total_pages = (total_count + page_size - 1) // page_size + offset = current_page * page_size + records = db.get_records(active_id, limit=page_size, offset=offset) - for record in records: - time_fmt = core.format_time(record['difference']) - color = "green" if record['difference'] >= 0 else "red" - if record['difference'] > 0: - time_fmt = f"+{time_fmt}" + console.clear() + title = translate("full_history_header", lang=lang) + table = Table(title=f"\n[bold]{title}[/bold]", box=box.SIMPLE_HEAD, header_style="bold cyan") + table.add_column("Date", style="dim", justify="center") + table.add_column(translate("work_label", lang=lang), justify="right") + table.add_column(translate("balance_short_label", lang=lang), justify="right") + + for record in records: + time_fmt = core.format_time(record['difference']) + color = "green" if record['difference'] >= 0 else "red" + if record['difference'] > 0: + time_fmt = f"+{time_fmt}" + + table.add_row( + record['date'], + f"{record['hours']}h {record['minutes']}m", + f"[{color}]{time_fmt}[/{color}]" + ) - table.add_row( - record['date'], - f"{record['hours']}h {record['minutes']}m", - f"[{color}]{time_fmt}[/{color}]" - ) - - console.print(table) + console.print(table) + console.print(f"\n {translate('pagination_info', lang=lang, current=current_page+1, total=total_pages, count=total_count)}") + + choices = ["v"] + nav_msg = f"\n [bold cyan]V.[/bold cyan] {translate('pagination_back', lang=lang)}" + if current_page < total_pages - 1: + nav_msg += f" [bold cyan]N.[/bold cyan] {translate('pagination_next', lang=lang)}" + choices.append("n") + if current_page > 0: + nav_msg += f" [bold cyan]P.[/bold cyan] {translate('pagination_prev', lang=lang)}" + choices.append("p") + + console.print(nav_msg) + choice = Prompt.ask("", choices=choices, show_choices=False, console=console).lower() + + if choice == "n": + current_page += 1 + elif choice == "p": + current_page -= 1 + elif choice == "v": + break -def manage_projects(lang="en"): - """Submenu for multi-project management with rich styling.""" +def config_menu(lang="en"): + """Submenu for project-specific and global configuration.""" while True: + active_id = db.get_active_project_id() + project = db.get_project_by_id(active_id) + console.clear() + console.print(f"\n[bold cyan]--- {translate('option_3_clean', lang=lang).upper()} ---[/bold cyan]") + + # Display current configuration + console.print(f"\n [dim]{translate('project_label', lang=lang)}:[/dim] [bold]{project['name']}[/bold]") + console.print(f" [dim]{translate('base_day_label', lang=lang)}:[/dim] [bold]{project['base_hours']}h {project['base_minutes']}m[/bold]") + console.print(f" [dim]Language:[/dim] [bold]{db.get_setting('language', 'auto')}[/bold]") + + console.print(f"\n[bold dim]>> {translate('config_section_project', lang=lang)}[/bold dim]") + console.print(f" [bold cyan]1.[/bold cyan] {translate('config_option_edit_name', lang=lang)}") + console.print(f" [bold cyan]2.[/bold cyan] {translate('config_option_edit_base', lang=lang)}") + console.print(f" [bold cyan]3.[/bold cyan] {translate('config_option_lang', lang=lang)}") + + console.print(f"\n[bold dim]>> {translate('config_section_data', lang=lang)}[/bold dim]") + console.print(f" [bold cyan]4.[/bold cyan] {translate('config_option_import', lang=lang)}") + console.print(f" [bold cyan]5.[/bold cyan] {translate('config_option_export', lang=lang)}") + + console.print(f"\n [bold cyan]V.[/bold cyan] {translate('config_option_back', lang=lang)}") + + choice = Prompt.ask(f"\n{translate('choose_option', lang=lang)}", choices=["1", "2", "3", "4", "5", "v"], show_choices=False, console=console).lower() + + if choice == "1": + new_name = Prompt.ask(translate("project_name_prompt", lang=lang, current=project['name']), default=project['name'], console=console).strip() + if new_name: + db.update_project(active_id, new_name, project['base_hours'], project['base_minutes']) + elif choice == "2": + try: + h = int(Prompt.ask(translate("base_hours_prompt", lang=lang, current=project['base_hours']), default=str(project['base_hours']), console=console)) + m = int(Prompt.ask(translate("base_minutes_prompt", lang=lang, current=project['base_minutes']), default=str(project['base_minutes']), console=console)) + db.update_project(active_id, project['name'], h, m) + except ValueError: + console.print(f"[bold red]{translate('error_integers', lang=lang)}[/bold red]") + Prompt.ask(translate("press_enter", lang=lang), console=console) + elif choice == "3": + lang_opt = Prompt.ask( + translate("language_prompt", lang=lang, current=db.get_setting("language", "auto")), + choices=["en", "es", "auto"], + default="auto", + console=console + ).strip().lower() + db.set_setting("language", lang_opt) + # Update local lang for immediate feedback + lang = lang_opt if lang_opt != "auto" else get_system_language() + elif choice == "4": + path = Prompt.ask(translate('import_src_prompt', lang=lang), console=console).strip() + if path: + mode = Prompt.ask( + translate('import_mode_prompt', lang=lang, merge=constants.MODE_MERGE, overwrite=constants.MODE_OVERWRITE), + choices=[constants.MODE_MERGE, constants.MODE_OVERWRITE], + default=constants.MODE_MERGE, + console=console + ) + try: + source_data = io.read_history_file(path) + if mode == constants.MODE_OVERWRITE: + with db._get_connection() as conn: + conn.execute("DELETE FROM records WHERE project_id = ?", (active_id,)) + + count = 0 + for date_str, info in source_data['records'].items(): + db.upsert_record(active_id, date_str, info['hours'], info['minutes'], info['difference']) + count += 1 + console.print(f"[bold green]{translate('import_success', lang=lang, count=count)}[/bold green]") + except Exception as err: + console.print(f"[bold red]{translate('import_error', lang=lang, error=err)}[/bold red]") + Prompt.ask(translate('press_enter', lang=lang), console=console) + elif choice == "5": + path = Prompt.ask(translate('export_dest_prompt', lang=lang), console=console).strip() + if path: + try: + records = db.get_records(active_id) + data_to_export = { + "metadata": { + "project_name": project['name'], + "hours_base": project['base_hours'], + "minutes_base": project['base_minutes'], + "version": "1.0", + "language": db.get_setting("language", "auto") + }, + "records": {r['date']: {'hours': r['hours'], 'minutes': r['minutes'], 'difference': r['difference']} for r in records} + } + dest = io.export_history(data_to_export, path) + console.print(f"[bold green]{translate('export_success', lang=lang, path=dest)}[/bold green]") + except Exception as err: + console.print(f"[bold red]{translate('export_error', lang=lang, error=err)}[/bold red]") + Prompt.ask(translate('press_enter', lang=lang), console=console) + elif choice == "v": + break + + +def project_menu(lang="en"): + """Submenu for switching and creating projects.""" + while True: projects = db.get_projects() active_id = db.get_active_project_id() - table = Table(title=f"\n[bold]{translate('projects_menu_header', lang=lang)}[/bold]", box=box.DOUBLE_EDGE) + console.clear() + console.print(f"\n[bold cyan]--- {translate('option_4_clean', lang=lang).upper()} ---[/bold cyan]") + + table = Table(box=box.SIMPLE, header_style="bold dim") table.add_column("ID", justify="center", style="dim") - table.add_column("Name", style="bold green") - table.add_column("Base", justify="center") + table.add_column("Name", style="bold") + table.add_column("Base", justify="center", style="dim") table.add_column("Active", justify="center") for p in projects: @@ -155,17 +301,17 @@ def manage_projects(lang="en"): console.print(table) - console.print(f"\n[bold cyan]1.[/bold cyan] {translate('switch_project', lang=lang)}") - console.print(f"[bold cyan]2.[/bold cyan] {translate('create_project', lang=lang)}") - console.print(f"[bold cyan]3.[/bold cyan] {translate('edit_project', lang=lang)}") - console.print(f"[bold cyan]4.[/bold cyan] {translate('back', lang=lang)}") + console.print(f"\n [bold cyan]1.[/bold cyan] {translate('project_option_select', lang=lang)}") + console.print(f" [bold cyan]2.[/bold cyan] {translate('project_option_create', lang=lang)}") + console.print(f"\n [bold cyan]V.[/bold cyan] {translate('project_option_back', lang=lang)}") - choice = Prompt.ask(f"\n{translate('choose_option', lang=lang)}", choices=["1", "2", "3", "4"], console=console) + choice = Prompt.ask(f"\n{translate('choose_option', lang=lang)}", choices=["1", "2", "v"], show_choices=False, console=console).lower() if choice == "1": target_id = Prompt.ask(translate("enter_project_id", lang=lang), console=console) if target_id.isdigit() and any(p['id'] == int(target_id) for p in projects): db.set_active_project_id(int(target_id)) + break else: console.print(f"[bold red]{translate('invalid_option', lang=lang)}[/bold red]") Prompt.ask(translate("press_enter", lang=lang), console=console) @@ -177,62 +323,16 @@ def manage_projects(lang="en"): m = int(Prompt.ask(translate("base_minutes_prompt", lang=lang, current=constants.BASE_MINUTES), default=str(constants.BASE_MINUTES), console=console)) new_id = db.create_project(name, h, m) db.set_active_project_id(new_id) + break except ValueError: console.print(f"[bold red]{translate('error_integers', lang=lang)}[/bold red]") Prompt.ask(translate("press_enter", lang=lang), console=console) - elif choice == "3": - p = db.get_project_by_id(active_id) - name = Prompt.ask(translate("project_name_prompt", lang=lang, current=p['name']), default=p['name'], console=console).strip() - try: - h = int(Prompt.ask(translate("base_hours_prompt", lang=lang, current=p['base_hours']), default=str(p['base_hours']), console=console)) - m = int(Prompt.ask(translate("base_minutes_prompt", lang=lang, current=p['base_minutes']), default=str(p['base_minutes']), console=console)) - - db.update_project(active_id, name, h, m) - - lang_opt = Prompt.ask( - translate("language_prompt", lang=lang, current=db.get_setting("language", "auto")), - choices=["en", "es", "auto"], - default="auto", - console=console - ).strip().lower() - db.set_setting("language", lang_opt) - except ValueError: - console.print(f"[bold red]{translate('error_integers', lang=lang)}[/bold red]") - Prompt.ask(translate("press_enter", lang=lang), console=console) - elif choice == "4": + elif choice == "v": break -def migrate_from_json(file_path, lang="en"): - """Migrates records from a legacy JSON file using rich feedback.""" - try: - source_data = io.read_history_file(file_path) - records = source_data.get("records", {}) - metadata = source_data.get("metadata", {}) - - name = metadata.get('project_name', f"Migrated_{datetime.now().strftime('%Y%m%d')}") - try: - project_id = db.create_project( - name, - metadata.get('hours_base', constants.BASE_HOURS), - metadata.get('minutes_base', constants.BASE_MINUTES) - ) - except Exception: - project_id = db.get_active_project_id() - name = db.get_project_by_id(project_id)['name'] - - count = 0 - for date_str, info in records.items(): - db.upsert_record(project_id, date_str, info['hours'], info['minutes'], info['difference']) - count += 1 - - console.print(f"[bold green]{translate('migration_success', lang=lang, count=count, name=name)}[/bold green]") - except Exception as e: - console.print(f"[bold red]{translate('migration_error', lang=lang, error=str(e))}[/bold red]") - - def interactive_menu(): - """Main interactive menu loop with rich grouping.""" + """Main interactive menu loop with a clean, sober layout.""" while True: lang = get_current_lang() active_id = db.get_active_project_id() @@ -242,78 +342,29 @@ def interactive_menu(): console.clear() render_dashboard(project, total_balance, lang) - console.print(f"\n[bold magenta]>> {translate('daily_header', lang=lang)}[/bold magenta]") - console.print(f" [bold cyan]1.[/bold cyan] {translate('option_1', lang=lang)[3:]}") - console.print(f" [bold cyan]2.[/bold cyan] {translate('option_2', lang=lang)[3:]}") - - console.print(f"\n[bold magenta]>> {translate('config_header_menu', lang=lang)}[/bold magenta]") - console.print(f" [bold cyan]3.[/bold cyan] {translate('option_3', lang=lang)[3:]}") - - console.print(f"\n[bold magenta]>> {translate('data_header', lang=lang)}[/bold magenta]") - console.print(f" [bold cyan]4.[/bold cyan] {translate('option_4', lang=lang)[3:]}") - console.print(f" [bold cyan]5.[/bold cyan] {translate('option_5', lang=lang)[3:]}") - - console.print(f"\n[bold magenta]>> {translate('option_6', lang=lang)[3:]}[/bold magenta]") - console.print(f" [bold cyan]6.[/bold cyan] {translate('option_6', lang=lang)[3:]}") + console.print(f"\n [bold cyan]1.[/bold cyan] {translate('option_1_clean', lang=lang)}") + console.print(f" [bold cyan]2.[/bold cyan] {translate('option_2_clean', lang=lang)}") + console.print(f" [bold cyan]3.[/bold cyan] {translate('option_3_clean', lang=lang)}") + console.print(f" [bold cyan]4.[/bold cyan] {translate('option_4_clean', lang=lang)}") + console.print(f" [bold cyan]5.[/bold cyan] {translate('option_5_clean', lang=lang)}") - option = Prompt.ask(f"\n{translate('choose_option', lang=lang)}", choices=["1", "2", "3", "4", "5", "6"], show_choices=False, console=console) + option = Prompt.ask(f"\n{translate('choose_option', lang=lang)}", choices=["1", "2", "3", "4", "5"], show_choices=False, console=console) if option == "1": register_day(lang=lang) Prompt.ask(translate('press_enter', lang=lang), console=console) elif option == "2": view_history(lang=lang) - Prompt.ask(translate('press_enter', lang=lang), console=console) elif option == "3": - manage_projects(lang=lang) + config_menu(lang=lang) elif option == "4": - path = Prompt.ask(translate('export_dest_prompt', lang=lang), console=console).strip() - if path: - try: - records = db.get_records(active_id) - data_to_export = { - "metadata": { - "project_name": project['name'], - "hours_base": project['base_hours'], - "minutes_base": project['base_minutes'], - "version": "1.0", - "language": db.get_setting("language", "auto") - }, - "records": {r['date']: {'hours': r['hours'], 'minutes': r['minutes'], 'difference': r['difference']} for r in records} - } - dest = io.export_history(data_to_export, path) - console.print(f"[bold green]{translate('export_success', lang=lang, path=dest)}[/bold green]") - except Exception as err: - console.print(f"[bold red]{translate('export_error', lang=lang, error=err)}[/bold red]") - Prompt.ask(translate('press_enter', lang=lang), console=console) + project_menu(lang=lang) elif option == "5": - path = Prompt.ask(translate('import_src_prompt', lang=lang), console=console).strip() - if path: - mode = Prompt.ask( - translate('import_mode_prompt', lang=lang, merge=constants.MODE_MERGE, overwrite=constants.MODE_OVERWRITE), - choices=[constants.MODE_MERGE, constants.MODE_OVERWRITE], - default=constants.MODE_MERGE, - console=console - ) - try: - source_data = io.read_history_file(path) - if mode == constants.MODE_OVERWRITE: - with db._get_connection() as conn: - conn.execute("DELETE FROM records WHERE project_id = ?", (active_id,)) - - count = 0 - for date_str, info in source_data['records'].items(): - db.upsert_record(active_id, date_str, info['hours'], info['minutes'], info['difference']) - count += 1 - console.print(f"[bold green]{translate('import_success', lang=lang, count=count)}[/bold green]") - except Exception as err: - console.print(f"[bold red]{translate('import_error', lang=lang, error=err)}[/bold red]") - Prompt.ask(translate('press_enter', lang=lang), console=console) - elif option == "6": console.print(f"\n[bold blue]{translate('exit_msg', lang=lang)}[/bold blue]") break + def main(): parser = argparse.ArgumentParser( description="time-balance: A professional tool to track your working hours balance globally." diff --git a/time_balance/i18n.py b/time_balance/i18n.py index 9f2e143..d9793e0 100644 --- a/time_balance/i18n.py +++ b/time_balance/i18n.py @@ -20,6 +20,31 @@ "dashboard_title": "DASHBOARD", "choose_option": "Choose option", + "option_1_clean": "Register workday", + "option_2_clean": "View records", + "option_3_clean": "Configuration", + "option_4_clean": "Change project", + "option_5_clean": "Exit", + + "config_option_edit_name": "Edit project name", + "config_option_edit_base": "Adjust daily base hours/minutes", + "config_option_lang": "Change system language", + "config_option_import": "Import history from JSON", + "config_option_export": "Export history to JSON", + "config_option_back": "Back to main menu", + + "project_option_select": "Select existing project", + "project_option_create": "Create new project", + "project_option_back": "Back to main menu", + + "pagination_next": "Next page (N)", + "pagination_prev": "Previous page (P)", + "pagination_back": "Back to menu (V)", + "pagination_info": "Page {current} of {total} (Total: {count})", + + "config_section_project": "PROJECT SETTINGS", + "config_section_data": "DATA MANAGEMENT", + "projects_menu_header": "PROJECT MANAGEMENT", "switch_project": "1. Switch project", "create_project": "2. Create new project", @@ -83,6 +108,31 @@ "dashboard_title": "CENTRO DE CONTROL", "choose_option": "Elige opción", + "option_1_clean": "Registrar jornada", + "option_2_clean": "Ver registros", + "option_3_clean": "Configuración", + "option_4_clean": "Cambiar proyecto", + "option_5_clean": "Salir", + + "config_option_edit_name": "Editar nombre del proyecto", + "config_option_edit_base": "Ajustar jornada base (horas/minutos)", + "config_option_lang": "Cambiar idioma del sistema", + "config_option_import": "Importar historial desde JSON", + "config_option_export": "Exportar historial a JSON", + "config_option_back": "Volver al menú principal", + + "project_option_select": "Seleccionar proyecto existente", + "project_option_create": "Crear nuevo proyecto", + "project_option_back": "Volver al menú principal", + + "pagination_next": "Página siguiente (N)", + "pagination_prev": "Página anterior (P)", + "pagination_back": "Volver (V)", + "pagination_info": "Página {current} de {total} (Total: {count})", + + "config_section_project": "AJUSTES DEL PROYECTO", + "config_section_data": "GESTIÓN DE DATOS", + "projects_menu_header": "GESTIÓN DE PROYECTOS", "switch_project": "1. Cambiar de proyecto", "create_project": "2. Crear nuevo proyecto", diff --git a/time_balance/storage.py b/time_balance/storage.py index 4cff81f..75081c5 100644 --- a/time_balance/storage.py +++ b/time_balance/storage.py @@ -142,13 +142,17 @@ def upsert_record(self, project_id: int, record_date: str, hours: int, minutes: VALUES (?, ?, ?, ?, ?) """, (project_id, record_date, hours, minutes, difference)) - def get_records(self, project_id: int, limit: Optional[int] = None) -> List[Dict[str, Any]]: - """Returns records for a specific project, sorted by date descending.""" + def get_records(self, project_id: int, limit: Optional[int] = None, offset: int = 0) -> List[Dict[str, Any]]: + """Returns records for a specific project, sorted by date descending with pagination support.""" query = "SELECT * FROM records WHERE project_id = ? ORDER BY date DESC" parameters = [project_id] + if limit: query += " LIMIT ?" parameters.append(limit) + if offset > 0: + query += " OFFSET ?" + parameters.append(offset) with self._get_connection() as connection: connection.row_factory = sqlite3.Row @@ -173,6 +177,13 @@ def get_total_balance(self, project_id: int) -> int: result = cursor.fetchone()[0] return result if result is not None else 0 + def count_records(self, project_id: int) -> int: + """Returns the total number of records for a specific project.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute("SELECT COUNT(*) FROM records WHERE project_id = ?", (project_id,)) + return cursor.fetchone()[0] + # --- GLOBAL SINGLETON --- db = DatabaseManager(constants.DATABASE_PATH) From 54d26aeb822f52080543abf2a07c9fc5dff24790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 10:02:34 +0200 Subject: [PATCH 4/9] refactor: improve user prompts in i18n.py for clarity and consistency --- time_balance/i18n.py | 50 +++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/time_balance/i18n.py b/time_balance/i18n.py index d9793e0..11f5953 100644 --- a/time_balance/i18n.py +++ b/time_balance/i18n.py @@ -50,23 +50,22 @@ "create_project": "2. Create new project", "edit_project": "3. Edit current project configuration", "back": "4. Back to main menu", - "enter_project_id": "Enter project ID to switch: ", + "enter_project_id": "Enter project ID to switch", "invalid_option": "❌ Invalid option.", "migration_success": "\n✅ Successfully migrated {count} records to project '{name}'.", "migration_error": "❌ Migration error: {error}", - "choose_option": "Choose option: ", "press_enter": "\nPress ENTER to continue...", "exit_msg": "See you tomorrow!", "date_prompt": "\nRecord date (Enter for TODAY: {today})", - "date_input": "Or enter date (YYYY-MM-DD): ", + "date_input": "Or enter date (YYYY-MM-DD)", "invalid_date": "❌ Invalid date format. Using today's date.", "duplicate_warning": "\n⚠️ WARNING: A record already exists for {date}.", "previous_record": " Previously recorded: {hours}h {minutes}m", - "overwrite_confirm": "Do you want to OVERWRITE it? (y/n): ", + "overwrite_confirm": "Do you want to OVERWRITE it? (y/n)", "op_cancelled": "Operation cancelled.", "input_header": "--- Entering data for: {date} ---", - "hours_worked": "Hours worked: ", - "minutes_worked": "Minutes worked: ", + "hours_worked": "Hours worked", + "minutes_worked": "Minutes worked", "error_integers": "❌ Error: Please enter integer numbers.", "save_success": "\n✅ Record saved for {date}.", "day_diff": " Day difference: {diff}", @@ -74,16 +73,16 @@ "full_history_header": "\n--- Full history ---", "no_records": "No records found.", "config_header": "\n--- Project Configuration ---", - "project_name_prompt": "Project name [{current}]: ", - "base_hours_prompt": "Daily base hours [{current}]: ", - "base_minutes_prompt": "Daily base minutes [{current}]: ", - "language_prompt": "System language (en/es/auto) [{current}]: ", + "project_name_prompt": "Project name [{current}]", + "base_hours_prompt": "Daily base hours [{current}]", + "base_minutes_prompt": "Daily base minutes [{current}]", + "language_prompt": "System language (en/es/auto) [{current}]", "invalid_language": "❌ Invalid language. Please use 'en', 'es', or 'auto'.", - "export_dest_prompt": "Destination path (e.g.: /path/my_export.json): ", + "export_dest_prompt": "Destination path (e.g.: /path/my_export.json)", "export_success": "\n✅ Exported to: {path}", "export_error": "Error exporting: {error}", - "import_src_prompt": "Source path to import: ", - "import_mode_prompt": "Mode ({merge}/{overwrite}) [{merge}]: ", + "import_src_prompt": "Source path to import", + "import_mode_prompt": "Mode ({merge}/{overwrite}) [{merge}]", "import_success": "\n✅ Import completed. Total entries now: {count}", "import_error": "Error importing: {error}", "status_project": "Project: {name}", @@ -138,23 +137,22 @@ "create_project": "2. Crear nuevo proyecto", "edit_project": "3. Editar configuración del proyecto actual", "back": "4. Volver al menú principal", - "enter_project_id": "Introduce el ID del proyecto para cambiar: ", + "enter_project_id": "Introduce el ID del proyecto para cambiar", "invalid_option": "❌ Opción inválida.", "migration_success": "\n✅ Migrados con éxito {count} registros al proyecto '{name}'.", "migration_error": "❌ Error de migración: {error}", - "choose_option": "Elige opción: ", "press_enter": "\nPresiona ENTER para continuar...", "exit_msg": "¡Hasta mañana!", "date_prompt": "\nFecha del registro (Enter para usar HOY: {today})", - "date_input": "O introduce fecha (YYYY-MM-DD): ", + "date_input": "O introduce fecha (YYYY-MM-DD)", "invalid_date": "❌ Formato de fecha incorrecto. Usando fecha de hoy.", "duplicate_warning": "\n⚠️ ATENCIÓN: Ya existe un registro para el día {date}.", "previous_record": " Registrado anteriormente: {hours}h {minutes}m", - "overwrite_confirm": "¿Quieres SOBREESCRIBIRLO? (s/n): ", + "overwrite_confirm": "¿Quieres SOBREESCRIBIRLO? (s/n)", "op_cancelled": "Operación cancelada.", "input_header": "--- Introduciendo datos para: {date} ---", - "hours_worked": "Horas trabajadas: ", - "minutes_worked": "Minutos trabajados: ", + "hours_worked": "Horas trabajadas", + "minutes_worked": "Minutos trabajados", "error_integers": "❌ Error: Introduce números enteros.", "save_success": "\n✅ Registro guardado para el {date}.", "day_diff": " Diferencia del día: {diff}", @@ -162,16 +160,16 @@ "full_history_header": "\n--- Historial completo ---", "no_records": "No hay registros.", "config_header": "\n--- Configuración del Proyecto ---", - "project_name_prompt": "Nombre del proyecto [{current}]: ", - "base_hours_prompt": "Horas base diaria [{current}]: ", - "base_minutes_prompt": "Minutos base diaria [{current}]: ", - "language_prompt": "Idioma del sistema (en/es/auto) [{current}]: ", + "project_name_prompt": "Nombre del proyecto [{current}]", + "base_hours_prompt": "Horas base diaria [{current}]", + "base_minutes_prompt": "Minutos base diaria [{current}]", + "language_prompt": "Idioma del sistema (en/es/auto) [{current}]", "invalid_language": "❌ Idioma inválido. Por favor, usa 'en', 'es' o 'auto'.", - "export_dest_prompt": "Ruta destino (ej: /ruta/mi_export.json): ", + "export_dest_prompt": "Ruta destino (ej: /ruta/mi_export.json)", "export_success": "\n✅ Exportado en: {path}", "export_error": "Error al exportar: {error}", - "import_src_prompt": "Ruta fuente a importar: ", - "import_mode_prompt": "Modo ({merge}/{overwrite}) [{merge}]: ", + "import_src_prompt": "Ruta fuente a importar", + "import_mode_prompt": "Modo ({merge}/{overwrite}) [{merge}]", "import_success": "\n✅ Importación completada. Entradas totales ahora: {count}", "import_error": "Error al importar: {error}", "status_project": "Proyecto: {name}", From 607c9d17842089cb9a265e692e106f752ccb942e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 10:05:23 +0200 Subject: [PATCH 5/9] refactor: add error handling for user input in CLI to improve robustness --- time_balance/cli.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/time_balance/cli.py b/time_balance/cli.py index bc1fbb2..1439667 100644 --- a/time_balance/cli.py +++ b/time_balance/cli.py @@ -350,18 +350,22 @@ def interactive_menu(): option = Prompt.ask(f"\n{translate('choose_option', lang=lang)}", choices=["1", "2", "3", "4", "5"], show_choices=False, console=console) - if option == "1": - register_day(lang=lang) + try: + if option == "1": + register_day(lang=lang) + Prompt.ask(translate('press_enter', lang=lang), console=console) + elif option == "2": + view_history(lang=lang) + elif option == "3": + config_menu(lang=lang) + elif option == "4": + project_menu(lang=lang) + elif option == "5": + console.print(f"\n[bold blue]{translate('exit_msg', lang=lang)}[/bold blue]") + break + except KeyboardInterrupt: + console.print(f"\n\n[yellow] {translate('op_cancelled', lang=lang)} [/yellow]") Prompt.ask(translate('press_enter', lang=lang), console=console) - elif option == "2": - view_history(lang=lang) - elif option == "3": - config_menu(lang=lang) - elif option == "4": - project_menu(lang=lang) - elif option == "5": - console.print(f"\n[bold blue]{translate('exit_msg', lang=lang)}[/bold blue]") - break From 092244b904ccf46b874dc7be58d226993ef6cb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 10:58:37 +0200 Subject: [PATCH 6/9] refactor: enhance project balance management with caching and recalculation features --- tests/test_balance_cache.py | 168 ++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 6 +- time_balance/cli.py | 4 + time_balance/storage.py | 83 +++++++++++++++++- 4 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 tests/test_balance_cache.py diff --git a/tests/test_balance_cache.py b/tests/test_balance_cache.py new file mode 100644 index 0000000..4fd0eb5 --- /dev/null +++ b/tests/test_balance_cache.py @@ -0,0 +1,168 @@ +import unittest +import tempfile +import pathlib +import sqlite3 +from time_balance.storage import DatabaseManager + +class TestBalanceCache(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.db_path = pathlib.Path(self.tmp_dir.name) / "test_cache.db" + self.db = DatabaseManager(self.db_path) + self.project_id = 1 + + def tearDown(self): + self.tmp_dir.cleanup() + + def test_cache_activation_on_first_read(self): + """Verifica que el balance pasa de NULL a valor real tras la primera lectura.""" + # Insertamos registros directamente para que total_balance siga siendo NULL + with sqlite3.connect(self.db_path) as conn: + conn.execute("INSERT INTO records (project_id, date, hours, minutes, difference) VALUES (?, ?, ?, ?, ?)", + (self.project_id, "2026-04-01", 8, 0, 15)) + conn.execute("INSERT INTO records (project_id, date, hours, minutes, difference) VALUES (?, ?, ?, ?, ?)", + (self.project_id, "2026-04-02", 9, 0, 75)) + + # Verificamos que en la DB está a NULL + cursor = conn.execute("SELECT total_balance FROM projects WHERE id = ?", (self.project_id,)) + self.assertIsNone(cursor.fetchone()[0]) + + # Al pedir el balance, debe calcularse y activarse la caché + balance = self.db.get_total_balance(self.project_id) + self.assertEqual(balance, 90) + + # Verificamos que ahora ya no es NULL en la DB + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute("SELECT total_balance FROM projects WHERE id = ?", (self.project_id,)) + self.assertEqual(cursor.fetchone()[0], 90) + + def test_cache_update_on_upsert(self): + """Verifica que el balance se actualiza atómicamente al insertar o actualizar.""" + # Forzamos activación de caché (debería ser 0 inicialmente) + self.db.get_total_balance(self.project_id) + + # Insertar nuevo registro + self.db.upsert_record(self.project_id, "2026-04-01", 8, 0, 15) + self.assertEqual(self.db.get_total_balance(self.project_id), 15) + + # Insertar otro + self.db.upsert_record(self.project_id, "2026-04-02", 9, 0, 75) + self.assertEqual(self.db.get_total_balance(self.project_id), 90) + + # Actualizar uno existente (de 15 a -10, diferencia delta de -25) + self.db.upsert_record(self.project_id, "2026-04-01", 7, 0, -10) + # Total debería ser: 90 (anterior) - 15 (viejo) + (-10) (nuevo) = 65 + self.assertEqual(self.db.get_total_balance(self.project_id), 65) + + def test_cache_update_on_delete(self): + """Verifica que el balance se actualiza correctamente al borrar registros.""" + self.db.upsert_record(self.project_id, "2026-04-01", 8, 0, 20) + self.db.upsert_record(self.project_id, "2026-04-02", 8, 0, 30) + self.assertEqual(self.db.get_total_balance(self.project_id), 50) + + # Borrar uno + self.db.delete_record(self.project_id, "2026-04-01") + self.assertEqual(self.db.get_total_balance(self.project_id), 30) + + # Borrar el último + self.db.delete_record(self.project_id, "2026-04-02") + self.assertEqual(self.db.get_total_balance(self.project_id), 0) + + def test_manual_recalculate(self): + """Verifica que el recálculo forzado corrige discrepancias.""" + self.db.upsert_record(self.project_id, "2026-04-01", 8, 0, 100) + self.assertEqual(self.db.get_total_balance(self.project_id), 100) + + # Simulamos una corrupción de datos manual en la caché + with sqlite3.connect(self.db_path) as conn: + conn.execute("UPDATE projects SET total_balance = 999 WHERE id = ?", (self.project_id,)) + + self.assertEqual(self.db.get_total_balance(self.project_id), 999) # La caché manda + + # Ejecutamos auditoría + corrected = self.db.recalculate_project_balance(self.project_id) + self.assertEqual(corrected, 100) + self.assertEqual(self.db.get_total_balance(self.project_id), 100) + + def test_reset_balance(self): + """Verifica que reset_project_balance vuelve a poner el estado en NULL.""" + self.db.upsert_record(self.project_id, "2026-04-01", 8, 0, 100) + self.db.get_total_balance(self.project_id) # Activamos caché + + # Verificamos que no es NULL + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute("SELECT total_balance FROM projects WHERE id = ?", (self.project_id,)) + self.assertIsNotNone(cursor.fetchone()[0]) + + # Reseteamos + self.db.reset_project_balance(self.project_id) + + # Verificamos que vuelve a ser NULL + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute("SELECT total_balance FROM projects WHERE id = ?", (self.project_id,)) + self.assertIsNone(cursor.fetchone()[0]) + + def test_incremental_update_safety(self): + """ + Escenario: Tenemos un balance acumulado de muchos días. + Añadimos un día nuevo, lo editamos varias veces y verificamos que + el balance previo se mantiene constante y solo cambia el delta del día editado. + """ + # 1. Creamos un histórico sólido (10 días con 10 min de extra cada uno = 100 min) + base_days = 10 + for i in range(base_days): + self.db.upsert_record(self.project_id, f"2026-01-{i+1:02d}", 8, 0, 10) + + base_balance = self.db.get_total_balance(self.project_id) + self.assertEqual(base_balance, 100) + + # 2. Añadimos el día "objetivo" + target_date = "2026-02-01" + self.db.upsert_record(self.project_id, target_date, 8, 0, 50) # +50 min + self.assertEqual(self.db.get_total_balance(self.project_id), 150) + + # 3. Editamos el día objetivo VARIAS veces + # Cambio a +20 min (el total debería bajar 30 min -> 120) + self.db.upsert_record(self.project_id, target_date, 8, 0, 20) + self.assertEqual(self.db.get_total_balance(self.project_id), 120) + + # Cambio a -10 min (el total debería bajar otros 30 min -> 90) + self.db.upsert_record(self.project_id, target_date, 8, 0, -10) + self.assertEqual(self.db.get_total_balance(self.project_id), 90) + + # 4. Verificación final de integridad: + # Borramos el día objetivo. El balance debería volver EXACTAMENTE a los 100 iniciales. + # Si hubiera cualquier error de arrastre, este número bailaría. + self.db.delete_record(self.project_id, target_date) + self.assertEqual(self.db.get_total_balance(self.project_id), 100) + + def test_sign_flip_safety(self): + """ + Verifica que el balance se mantiene correcto incluso cuando un registro + cambia de signo (de positivo a negativo y viceversa). + """ + # 1. Estado inicial + self.db.upsert_record(self.project_id, "2026-03-01", 8, 0, 100) + self.assertEqual(self.db.get_total_balance(self.project_id), 100) + + # 2. Añadimos un día muy positivo (+60 min) + target_date = "2026-03-02" + self.db.upsert_record(self.project_id, target_date, 9, 0, 60) + self.assertEqual(self.db.get_total_balance(self.project_id), 160) + + # 3. Flip: Cambiamos ese día a muy negativo (-40 min) + # Debería ser: 160 - 60 (quitar positivo) - 40 (poner negativo) = 60 + self.db.upsert_record(self.project_id, target_date, 7, 0, -40) + self.assertEqual(self.db.get_total_balance(self.project_id), 60) + + # 4. Flip de vuelta: Cambiamos a positivo moderado (+10 min) + # Debería ser: 60 - (-40) (anular deuda) + 10 (poner nuevo) = 110 + self.db.upsert_record(self.project_id, target_date, 8, 0, 10) + self.assertEqual(self.db.get_total_balance(self.project_id), 110) + + # 5. Borrado final: Volver al estado inicial + self.db.delete_record(self.project_id, target_date) + self.assertEqual(self.db.get_total_balance(self.project_id), 100) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 6356ba4..cf93b2d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,9 +14,13 @@ def setUp(self): from time_balance.storage import DatabaseManager self.db = DatabaseManager(self.db_path) + # Capture console output to keep tests quiet + self.test_console = Console(file=python_io.StringIO(), force_terminal=False, width=100) + self.patchers = [ mock.patch('time_balance.cli.db', self.db), - mock.patch('time_balance.storage.db', self.db) + mock.patch('time_balance.storage.db', self.db), + mock.patch('time_balance.cli.console', self.test_console) ] for patcher in self.patchers: patcher.start() diff --git a/time_balance/cli.py b/time_balance/cli.py index 1439667..22ea606 100644 --- a/time_balance/cli.py +++ b/time_balance/cli.py @@ -244,6 +244,10 @@ def config_menu(lang="en"): ) try: source_data = io.read_history_file(path) + + # Invalidate balance cache before import + db.reset_project_balance(active_id) + if mode == constants.MODE_OVERWRITE: with db._get_connection() as conn: conn.execute("DELETE FROM records WHERE project_id = ?", (active_id,)) diff --git a/time_balance/storage.py b/time_balance/storage.py index 75081c5..1d8ce98 100644 --- a/time_balance/storage.py +++ b/time_balance/storage.py @@ -18,7 +18,7 @@ def _get_connection(self) -> sqlite3.Connection: return sqlite3.connect(self.db_path) def _initialize_database(self): - """Creates the necessary tables if they do not exist.""" + """Creates the necessary tables if they do not exist and handles migrations.""" with self._get_connection() as connection: cursor = connection.cursor() @@ -32,6 +32,13 @@ def _initialize_database(self): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + + # Migration: Add total_balance if it doesn't exist + try: + cursor.execute("ALTER TABLE projects ADD COLUMN total_balance INTEGER DEFAULT NULL") + except sqlite3.OperationalError: + # Column already exists + pass # Records table: daily time registrations cursor.execute(""" @@ -134,13 +141,55 @@ def set_setting(self, key: str, value: str): cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)) def upsert_record(self, project_id: int, record_date: str, hours: int, minutes: int, difference: int): - """Inserts or updates a daily time record.""" + """Inserts or updates a daily time record and updates project total balance cache.""" with self._get_connection() as connection: cursor = connection.cursor() + + # 1. Get old difference if record exists to adjust balance + cursor.execute("SELECT difference FROM records WHERE project_id = ? AND date = ?", (project_id, record_date)) + row = cursor.fetchone() + old_difference = row[0] if row else 0 + + # 2. Upsert the record cursor.execute(""" INSERT OR REPLACE INTO records (project_id, date, hours, minutes, difference) VALUES (?, ?, ?, ?, ?) """, (project_id, record_date, hours, minutes, difference)) + + # 3. Update project total balance cache + # If total_balance is NULL, we don't update it (it stays NULL until next calculation) + cursor.execute(""" + UPDATE projects + SET total_balance = total_balance - ? + ? + WHERE id = ? AND total_balance IS NOT NULL + """, (old_difference, difference, project_id)) + + connection.commit() + + def delete_record(self, project_id: int, record_date: str): + """Deletes a record and updates project total balance cache.""" + with self._get_connection() as connection: + cursor = connection.cursor() + + # 1. Get difference to subtract from total + cursor.execute("SELECT difference FROM records WHERE project_id = ? AND date = ?", (project_id, record_date)) + row = cursor.fetchone() + if not row: + return + + difference = row[0] + + # 2. Delete the record + cursor.execute("DELETE FROM records WHERE project_id = ? AND date = ?", (project_id, record_date)) + + # 3. Update project total balance cache + cursor.execute(""" + UPDATE projects + SET total_balance = total_balance - ? + WHERE id = ? AND total_balance IS NOT NULL + """, (difference, project_id)) + + connection.commit() def get_records(self, project_id: int, limit: Optional[int] = None, offset: int = 0) -> List[Dict[str, Any]]: """Returns records for a specific project, sorted by date descending with pagination support.""" @@ -170,12 +219,38 @@ def get_record_by_date(self, project_id: int, record_date: str) -> Optional[Dict return dict(row) if row else None def get_total_balance(self, project_id: int) -> int: - """Calculates the sum of differences for all records in a project.""" + """Retrieves the cached balance or calculates it if NULL.""" + with self._get_connection() as connection: + cursor = connection.cursor() + + # Try to get cached balance + cursor.execute("SELECT total_balance FROM projects WHERE id = ?", (project_id,)) + row = cursor.fetchone() + + if row and row[0] is not None: + return row[0] + + # Recalculate if NULL (outside the previous 'with' to avoid nested connections) + return self.recalculate_project_balance(project_id) + + def recalculate_project_balance(self, project_id: int) -> int: + """Forces a full recalculation of the balance from all records and updates the cache.""" with self._get_connection() as connection: cursor = connection.cursor() cursor.execute("SELECT SUM(difference) FROM records WHERE project_id = ?", (project_id,)) result = cursor.fetchone()[0] - return result if result is not None else 0 + total = result if result is not None else 0 + + cursor.execute("UPDATE projects SET total_balance = ? WHERE id = ?", (total, project_id)) + connection.commit() + return total + + def reset_project_balance(self, project_id: int): + """Resets the project balance cache to NULL, forcing a recalculation on next access.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute("UPDATE projects SET total_balance = NULL WHERE id = ?", (project_id,)) + connection.commit() def count_records(self, project_id: int) -> int: """Returns the total number of records for a specific project.""" From f6bccb3fa4a58ef23d0151c4e666aac420c9975d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 11:01:01 +0200 Subject: [PATCH 7/9] refactor: implement incremental caching strategy for project balance management --- CHANGELOG.md | 12 ++++++++++++ README.es.md | 1 + README.md | 1 + docs/ARCHITECTURE.md | 12 +++++++++++- docs/es/ARCHITECTURE.es.md | 12 +++++++++++- time_balance/constants.py | 2 +- 6 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0982ec8..49d73e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## [0.4.1] - 2026-04-24 + +### Added +- **High-Performance Balance Cache**: Added `total_balance` column to projects table to avoid $O(N)$ calculations. +- **Atomic Balance Updates**: Balance is now updated incrementally on each record creation, modification, or deletion. +- **Balance Audit Tool**: New internal methods to recalculate and validate the total balance. +- **Automated Balance Tests**: Added comprehensive test suite for the balance cache engine. + +### Changed +- **Optimized CLI Performance**: The status command and main menu now display the balance instantly. +- **Clean Test Output**: Silenced CLI print statements during automated testing for professional output. + ## [0.3.0] - 2026-04-24 ### Added - **Global Installation**: The app now stores data in standard system paths (XDG compliant), making it truly global. diff --git a/README.es.md b/README.es.md index 39f189c..6ca727c 100644 --- a/README.es.md +++ b/README.es.md @@ -61,6 +61,7 @@ time-balance --list 10 ## Características Principales +- ✅ **Caché de Alto Rendimiento**: Actualizaciones atómicas del saldo para resultados instantáneos, incluso con años de datos. - ✅ **Historial Paginado**: Navega cómodamente por tus registros, sin importar cuántos tengas. - ✅ **Almacenamiento SQLite**: Persistencia robusta y profesional en rutas estándar (XDG). - ✅ **Multi-proyecto**: Gestiona diferentes contextos de trabajo de forma independiente. diff --git a/README.md b/README.md index 2b6f9c2..0821b97 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ time-balance --list 10 ## Main Features +- ✅ **High-Performance Caching**: Atomic balance updates for instant results, even with years of data. - ✅ **Paginated History**: Comfortably browse your records, no matter how many you have. - ✅ **SQLite Backend**: Robust and professional persistence in standard paths (XDG). - ✅ **Multi-project**: Manage different work contexts independently. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de178ef..1820e8d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,8 +64,18 @@ Simple translation engine supporting English and Spanish. | name | TEXT UNIQUE | Project name | | base_hours | INTEGER | Base workday (hours) | | base_minutes | INTEGER | Base workday (minutes) | +| total_balance | INTEGER | Cached total balance in minutes (NULL if dirty) | + +## Data Flow and Performance + +In version 0.4.1, the application moved from dynamic calculations to an **incremental caching strategy**: +- **O(1) Access**: The `total_balance` is cached in the `projects` table. +- **Atomic Updates**: When a record is added, edited, or deleted, the cache is updated using a delta calculation (`total_balance - old_diff + new_diff`). +- **Deferred Activation**: If a project's balance is `NULL`, it is recalculated from scratch on the next read and cached thereafter. +- **Audit Tool**: `recalculate_project_balance` is available to force a full validation of the cache against the individual records. + +## Reliability and Safety -### `records` table | Column | Type | Description | | :--- | :--- | :--- | | id | INTEGER PK | Unique identifier | diff --git a/docs/es/ARCHITECTURE.es.md b/docs/es/ARCHITECTURE.es.md index a55e605..6004810 100644 --- a/docs/es/ARCHITECTURE.es.md +++ b/docs/es/ARCHITECTURE.es.md @@ -64,8 +64,18 @@ Motor de traducción simple que soporta inglés y español. | name | TEXT UNIQUE | Nombre del proyecto | | base_hours | INTEGER | Jornada base (horas) | | base_minutes | INTEGER | Jornada base (minutos) | +| total_balance | INTEGER | Saldo total cacheado en minutos (NULL si está sucio) | + +## Flujo de Datos y Rendimiento + +En la versión 0.4.1, la aplicación pasó de cálculos dinámicos a una **estrategia de caché incremental**: +- **Acceso O(1)**: El `total_balance` se guarda en la tabla `projects`. +- **Actualizaciones Atómicas**: Cuando se añade, edita o borra un registro, la caché se actualiza mediante un cálculo delta (`total_balance - vieja_dif + nueva_dif`). +- **Activación Diferida**: Si el saldo de un proyecto es `NULL`, se recalcula desde cero en la siguiente lectura y se guarda. +- **Herramienta de Auditoría**: `recalculate_project_balance` está disponible para forzar una validación completa de la caché frente a los registros individuales. + +## Fiabilidad y Seguridad -### Tabla `records` | Columna | Tipo | Descripción | | :--- | :--- | :--- | | id | INTEGER PK | Identificador único | diff --git a/time_balance/constants.py b/time_balance/constants.py index 5500c4a..1c6d6bc 100644 --- a/time_balance/constants.py +++ b/time_balance/constants.py @@ -1,7 +1,7 @@ import os import pathlib -VERSION = "0.4.0" +VERSION = "0.4.1" # --- PATH CONFIGURATION --- def get_data_dir() -> pathlib.Path: From 9197e9d860740392c6ad5edd1b2090cc999206eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 11:03:30 +0200 Subject: [PATCH 8/9] refactor: update versioning system and enhance caching mechanism for improved performance --- docs/ARCHITECTURE.md | 2 +- docs/es/ARCHITECTURE.es.md | 2 +- pyproject.toml | 8 +++++++- setup.py | 13 ++++++++++++- time_balance/VERSION | 1 + time_balance/constants.py | 10 +++++++++- 6 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 time_balance/VERSION diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1820e8d..487487e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Overview -`time-balance` is a terminal application to track working hours and manage accumulated balances. In version 0.3.0, the application evolved into a **global** and **multi-project** tool, using **SQLite** as the persistence engine and following XDG standards for data storage. +`time-balance` is a terminal application to track working hours and manage accumulated balances. In version 0.4.1, the application evolved into a **global** and **multi-project** tool with a high-performance balance caching system. ## Project Structure diff --git a/docs/es/ARCHITECTURE.es.md b/docs/es/ARCHITECTURE.es.md index 6004810..f88eb72 100644 --- a/docs/es/ARCHITECTURE.es.md +++ b/docs/es/ARCHITECTURE.es.md @@ -2,7 +2,7 @@ ## Visión General -`time-balance` es una aplicación de terminal para registrar jornadas laborales y gestionar el saldo horario acumulado. En su versión 0.3.0, la aplicación ha evolucionado a una herramienta **global** y **multiproyecto**, utilizando **SQLite** como motor de persistencia siguiendo los estándares XDG para el almacenamiento de datos. +`time-balance` es una aplicación de terminal para registrar jornadas laborales y gestionar el saldo horario acumulado. En su versión 0.4.1, la aplicación ha evolucionado a una herramienta **global** y **multiproyecto** con un sistema de caché de saldo de alto rendimiento. ## Estructura del Proyecto diff --git a/pyproject.toml b/pyproject.toml index 0d06c51..731364b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "time-balance" -version = "0.4.0" +dynamic = ["version"] description = "Control sencillo de jornadas y saldo horario" readme = "README.md" authors = [ { name = "autogenerated" } ] @@ -16,5 +16,11 @@ dependencies = [ license = "GPL-3.0-only" license-files = ["LICENSE"] +[tool.setuptools.dynamic] +version = {file = "time_balance/VERSION"} + +[tool.setuptools.package-data] +time_balance = ["VERSION"] + [project.scripts] time-balance = "time_balance:main" diff --git a/setup.py b/setup.py index b6c0a0e..baaf3d3 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,21 @@ from setuptools import setup, find_packages +import os + +def get_version(): + version_path = os.path.join(os.path.dirname(__file__), 'time_balance', 'VERSION') + with open(version_path, 'r') as f: + return f.read().strip() + setup( name='time-balance', - version='0.4.0', + version=get_version(), description='Control sencillo de jornadas y saldo horario', packages=find_packages(exclude=("tests",)), + include_package_data=True, + package_data={ + 'time_balance': ['VERSION'], + }, install_requires=[ 'rich>=10.0.0', ], diff --git a/time_balance/VERSION b/time_balance/VERSION new file mode 100644 index 0000000..267577d --- /dev/null +++ b/time_balance/VERSION @@ -0,0 +1 @@ +0.4.1 diff --git a/time_balance/constants.py b/time_balance/constants.py index 1c6d6bc..e5293bf 100644 --- a/time_balance/constants.py +++ b/time_balance/constants.py @@ -1,7 +1,15 @@ import os import pathlib -VERSION = "0.4.1" +# --- VERSION --- +def _get_version(): + try: + version_path = pathlib.Path(__file__).parent / "VERSION" + return version_path.read_text().strip() + except Exception: + return "0.0.0-unknown" + +VERSION = _get_version() # --- PATH CONFIGURATION --- def get_data_dir() -> pathlib.Path: From 36fe2f583c55c5d6fa3ba7024e8e21c6ec7dcf03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Nicol=C3=A1s=20P=C3=A9rez=20Mesa?= Date: Fri, 24 Apr 2026 11:22:09 +0200 Subject: [PATCH 9/9] refactor: enhance migration process and database management with improved error handling and connection management --- CHANGELOG.md | 11 +++++++++ README.es.md | 3 +++ time_balance/cli.py | 41 ++++++++++++++++++++++++++++----- time_balance/storage.py | 50 ++++++++++++++++++++++++----------------- 4 files changed, 79 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d73e9..cc14e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## [0.4.2] - 2026-04-24 + +### Fixed +- **NameError in Migration**: Fixed missing `migrate_from_json` function reported in code review. +- **SQLite Connection Leaks**: Implemented a robust context manager in `DatabaseManager` to ensure all database connections are properly closed after use. +- **Redundant Schema Alterations**: Integrated `total_balance` directly into the initial table creation for cleaner database initialization. +- **Smooth Exit Flow**: Improved `KeyboardInterrupt` handling to exit immediately without requiring extra prompts. + +### Changed +- **Higher-Level Storage API**: Refactored CLI code to use `DatabaseManager` methods instead of raw SQL queries for better separation of concerns. + ## [0.4.1] - 2026-04-24 ### Added diff --git a/README.es.md b/README.es.md index 6ca727c..75507e7 100644 --- a/README.es.md +++ b/README.es.md @@ -55,6 +55,9 @@ time-balance --status # Listar los últimos 10 registros del proyecto activo time-balance --list 10 + +# Migrar un historial antiguo en formato JSON +time-balance --migrate ./ruta/al/archivo.json ``` --- diff --git a/time_balance/cli.py b/time_balance/cli.py index 22ea606..67d1ffb 100644 --- a/time_balance/cli.py +++ b/time_balance/cli.py @@ -245,14 +245,14 @@ def config_menu(lang="en"): try: source_data = io.read_history_file(path) - # Invalidate balance cache before import - db.reset_project_balance(active_id) - if mode == constants.MODE_OVERWRITE: - with db._get_connection() as conn: - conn.execute("DELETE FROM records WHERE project_id = ?", (active_id,)) + db.clear_project_records(active_id) + else: + # Only reset cache if merging (overwrite already sets balance to 0) + db.reset_project_balance(active_id) count = 0 + for date_str, info in source_data['records'].items(): db.upsert_record(active_id, date_str, info['hours'], info['minutes'], info['difference']) count += 1 @@ -369,8 +369,37 @@ def interactive_menu(): break except KeyboardInterrupt: console.print(f"\n\n[yellow] {translate('op_cancelled', lang=lang)} [/yellow]") - Prompt.ask(translate('press_enter', lang=lang), console=console) + break + +def migrate_from_json(path: str, lang: str): + """Migrates records from a legacy JSON file to the SQLite database.""" + try: + source_data = io.read_history_file(path) + metadata = source_data.get("metadata", {}) + + console.print(f"\n[bold cyan]{translate('import_header', lang=lang, date=path)}[/bold cyan]") + + # 1. Create the project + project_name = metadata.get("project_name", f"Imported {date.today()}") + h = metadata.get("hours_base", constants.BASE_HOURS) + m = metadata.get("minutes_base", constants.BASE_MINUTES) + + new_id = db.create_project(project_name, h, m) + db.set_active_project_id(new_id) + + # 2. Insert records + count = 0 + records = source_data.get("records", {}) + for date_str, info in records.items(): + db.upsert_record(new_id, date_str, info['hours'], info['minutes'], info['difference']) + count += 1 + + console.print(f"[bold green]{translate('import_success', lang=lang, count=count)}[/bold green]") + console.print(f" {translate('project_label', lang=lang)}: [bold]{project_name}[/bold] (ID: {new_id})") + + except Exception as err: + console.print(f"[bold red]{translate('import_error', lang=lang, error=err)}[/bold red]") def main(): diff --git a/time_balance/storage.py b/time_balance/storage.py index 1d8ce98..1efbc5f 100644 --- a/time_balance/storage.py +++ b/time_balance/storage.py @@ -1,6 +1,7 @@ import sqlite3 import pathlib import os +import contextlib from typing import List, Dict, Optional, Any from . import constants @@ -13,9 +14,16 @@ def __init__(self, db_path: pathlib.Path): self.db_path.parent.mkdir(parents=True, exist_ok=True) self._initialize_database() - def _get_connection(self) -> sqlite3.Connection: - """Returns a standard sqlite3 connection.""" - return sqlite3.connect(self.db_path) + @contextlib.contextmanager + def _get_connection(self): + """Context manager that yields a database connection, handling transactions and closing.""" + connection = sqlite3.connect(self.db_path) + try: + # The connection object itself is a context manager for transactions + with connection: + yield connection + finally: + connection.close() def _initialize_database(self): """Creates the necessary tables if they do not exist and handles migrations.""" @@ -23,23 +31,18 @@ def _initialize_database(self): cursor = connection.cursor() # Projects table: configuration for different work contexts + # We include total_balance directly here as per review feedback cursor.execute(""" CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, base_hours INTEGER NOT NULL, base_minutes INTEGER NOT NULL, + total_balance INTEGER DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - # Migration: Add total_balance if it doesn't exist - try: - cursor.execute("ALTER TABLE projects ADD COLUMN total_balance INTEGER DEFAULT NULL") - except sqlite3.OperationalError: - # Column already exists - pass - # Records table: daily time registrations cursor.execute(""" CREATE TABLE IF NOT EXISTS records ( @@ -62,17 +65,19 @@ def _initialize_database(self): value TEXT ) """) - - connection.commit() # Seed initial data if empty cursor.execute("SELECT COUNT(*) FROM projects") if cursor.fetchone()[0] == 0: - self.create_project("General", constants.BASE_HOURS, constants.BASE_MINUTES) + # We don't use self.create_project here to keep it within the same connection/transaction + cursor.execute( + "INSERT INTO projects (name, base_hours, base_minutes) VALUES (?, ?, ?)", + ("General", constants.BASE_HOURS, constants.BASE_MINUTES) + ) # Set the first project as active by default cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('active_project_id', '1')") cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('language', 'auto')") - connection.commit() + def create_project(self, name: str, base_hours: int, base_minutes: int) -> int: """Creates a new project and returns its ID.""" @@ -163,8 +168,7 @@ def upsert_record(self, project_id: int, record_date: str, hours: int, minutes: SET total_balance = total_balance - ? + ? WHERE id = ? AND total_balance IS NOT NULL """, (old_difference, difference, project_id)) - - connection.commit() + def delete_record(self, project_id: int, record_date: str): """Deletes a record and updates project total balance cache.""" @@ -188,8 +192,15 @@ def delete_record(self, project_id: int, record_date: str): SET total_balance = total_balance - ? WHERE id = ? AND total_balance IS NOT NULL """, (difference, project_id)) - - connection.commit() + + + def clear_project_records(self, project_id: int): + """Removes all records for a project and resets its balance cache.""" + with self._get_connection() as connection: + cursor = connection.cursor() + cursor.execute("DELETE FROM records WHERE project_id = ?", (project_id,)) + cursor.execute("UPDATE projects SET total_balance = 0 WHERE id = ?", (project_id,)) + def get_records(self, project_id: int, limit: Optional[int] = None, offset: int = 0) -> List[Dict[str, Any]]: """Returns records for a specific project, sorted by date descending with pagination support.""" @@ -242,7 +253,6 @@ def recalculate_project_balance(self, project_id: int) -> int: total = result if result is not None else 0 cursor.execute("UPDATE projects SET total_balance = ? WHERE id = ?", (total, project_id)) - connection.commit() return total def reset_project_balance(self, project_id: int): @@ -250,7 +260,7 @@ def reset_project_balance(self, project_id: int): with self._get_connection() as connection: cursor = connection.cursor() cursor.execute("UPDATE projects SET total_balance = NULL WHERE id = ?", (project_id,)) - connection.commit() + def count_records(self, project_id: int) -> int: """Returns the total number of records for a specific project."""