diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b15aae..8af8f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,26 @@ All notable changes to this project will be documented in this file. +## [0.2.0] - 2026-04-20 +### Added +- **Globalization (i18n)**: Fully translated UI with automatic language detection (English and Spanish supported). +- **Data Context**: Each history file now stores its own project name and base workday configuration. +- **English Source Code**: All internal functions, variables, and comments renamed to English for international collaboration. +- **CLI Arguments**: New command-line flags `--status`, `--list`, `--lang`, and `--version`. +- **Modular Architecture**: Code decoupled into `core`, `constants`, `storage`, `io`, `cli`, and `i18n`. +- **New Modular Test Suite**: 17 automated tests validating all logic and translations. +- **Support for Module Execution**: Can now be run using `python -m time_balance`. + +### Changed +- `README.md` and all documentation files migrated to English. +- Updated project structure for better maintainability. +### Fixed +- Fixed directory fsync for better crash-safety on atomic writes. + ## [0.1.1] - 2026-01-26 ### Changed - Removed compatibility shim `control_horas.py`; consumers should import directly from `time_balance`. - Updated README to reflect the removal of the shim and usage examples. -- Bumped package version to 0.1.1. - -### Fixed -- Updated tests to import `time_balance` directly and ensured backup creation during import/overwrite works as expected. ## [0.1.0] - initial release -- Initial implementation with interactive UI, import/export and compatibility shim. +- Initial implementation with interactive UI and basic JSON storage. diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..41ce5d3 --- /dev/null +++ b/README.es.md @@ -0,0 +1,103 @@ +# time-balance 🕒 + +> Una herramienta de terminal ligera y profesional para registrar tus jornadas laborales y controlar tu saldo acumulado de horas. + +[Read in English 🇺🇸](README.md) + +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) + +## 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. + +--- + +## Instalación rápida + +```bash +# Clonar y entrar al directorio +git clone +cd time-balance + +# Instalar la aplicación +python3 -m pip install . +``` + +--- + +## Uso de la aplicación + +### 1. Menú Interactivo (Recomendado) +Simplemente ejecuta el comando para abrir el centro de control: + +```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. Comandos Rápidos (Modo Directo) +Consulta tu estado sin entrar al menú: + +```bash +# Ver solo tu saldo acumulado actual +time-balance --status + +# Listar los últimos 10 días registrados +time-balance --list 10 + +# Forzar un idioma específico +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" +``` + +### Jornada Base +La aplicación usa por defecto **7h 45m**. Puedes modificarla a través del menú interactivo en la configuración del proyecto. + +--- + +## 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. + +--- + +## Desarrollo y Contribuciones + +Si quieres contribuir o entender cómo funciona internamente: +- [**ARCHITECTURE.es.md**](docs/es/ARCHITECTURE.es.md): Diseño y módulos del sistema. +- [**DEVELOPMENT.es.md**](docs/es/DEVELOPMENT.es.md): Guía técnica para desarrolladores. +- [**CONTRIBUTING.es.md**](docs/es/CONTRIBUTING.es.md): Cómo enviar mejoras y traducciones. + +## Licencia + +Este proyecto es Open Source bajo la licencia [GPL-3.0](LICENSE). diff --git a/README.md b/README.md index fb7c818..d847a3a 100644 --- a/README.md +++ b/README.md @@ -1,292 +1,104 @@ -# time-balance +# time-balance 🕒 -> Una pequeña herramienta de consola en Python para registrar jornadas laborales y calcular saldos acumulados. +> A lightweight, professional command-line tool to track your working hours and manage your accumulated balance. -## Tabla de Contenidos +[Leer en español 🇪🇸](README.es.md) -- [Descripción](#descripción) -- [Características](#características-principales) -- [Instalación](#instalación) -- [Uso](#uso) -- [Documentación](#documentación) -- [Requisitos](#requisitos) -- [Licencia](#licencia) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) -## Descripción +## Description -`time-balance` es una herramienta de consola minimalista para registrar jornadas laborales diarias y calcular el saldo acumulado respecto a una jornada base (7h 45m por defecto). +`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. -Guarda un historial simple en formato JSON (`historial_horas.json`), es **100% Python con biblioteca estándar** (sin dependencias externas), y proporciona tanto una interfaz interactiva como una API programática. +--- -## Características Principales - -- ✅ **Registro interactivo** de horas y minutos por día -- ✅ **Prevención de sobrescritura** con confirmación del usuario -- ✅ **Cálculo automático** de la diferencia diaria respecto a jornada base -- ✅ **Visualización clara** de últimos registros y saldo total -- ✅ **Import/Export** para respaldar o restaurar datos -- ✅ **Escritura segura** (operaciones atómicas, crash-safe) -- ✅ **Backups automáticos** con versionamiento por timestamp -- ✅ **Validación rigurosa** de datos importados -- ✅ **100% portátil** - macOS, Linux, Windows - -## Requisitos - -- **Python 3.8+** (solo usa módulos de la biblioteca estándar: `os`, `json`, `datetime`, `tempfile`, `shutil`) -- **Sistema operativo**: macOS / Linux / Windows - -## Instalación - -### 1. Clonar el Repositorio +## Quick Installation ```bash -git clone +# Clone and enter directory +git clone cd time-balance -``` - -### 2. Crear Entorno Virtual (Opcional) -```bash -python3 -m venv .venv -source .venv/bin/activate # macOS / Linux (zsh / bash) -.venv\Scripts\activate # Windows (cmd/PowerShell) +# Install the application +python3 -m pip install . ``` -### 3. Instalar el Paquete +--- -```bash -# En la raíz del proyecto -python3 -m pip install -e . -``` - -Esto instalará el console script `time-balance` en el entorno. Si `pyproject.toml` / `setup.py` especifican requisitos, también se instalarán. - -## Uso - -### Interfaz Interactiva (Recomendado) +## Usage -Tras la instalación, ejecuta desde la terminal: +### 1. Interactive Menu (Recommended) +Simply run the command to open the control center: ```bash time-balance ``` -Se mostrará un menú interactivo: - -``` -================================================== - SALDO TOTAL ACUMULADO: 0h 15m - (Base diaria: 7h 45m) -================================================== - -Opciones: -1. Registrar jornada (o corregir día) -2. Ver últimos registros -3. Exportar historial a archivo -4. Importar historial desde archivo -5. Salir - -Elige opción: _ -``` - -**Opción 1: Registrar Jornada** - -``` -Ingresa fecha (YYYY-MM-DD) o presiona Enter para hoy [2026-04-16]: -Ingresa horas trabajadas: 8 -Ingresa minutos trabajados: 30 -✓ Jornada registrada -``` - -**Opción 2: Ver Últimos Registros** - -Muestra los 5 registros más recientes con saldo diario. - -**Opción 3: Exportar Historial** - -``` -Ingresa ruta de destino para exportar: ~/backup/mi_export.json -✓ Historial exportado a: /Users/usuario/backup/mi_export.json -``` - -**Opción 4: Importar Historial** - -``` -Ingresa ruta de origen para importar: ~/backup/mi_export.json -Elige modo de importación (merge/overwrite) [merge]: merge -✓ Historial importado: 5 entradas procesadas -``` - -- **`merge`** (predeterminado): Combina datos; importados ganan en conflictos -- **`overwrite`**: Reemplaza completamente (crea backup antes) - -**Opción 5: Salir** - -``` -¡Hasta mañana! -``` - -Para más detalles sobre el CLI interactivo, consulta la [Guía de CLI](docs/CLI-GUIDE.md). - -## Documentación - -La documentación completa está organizada en la carpeta `docs/`: - -| Documento | Propósito | -|-----------|-----------| -| [**CLI-GUIDE.md**](docs/CLI-GUIDE.md) | Guía detallada de la interfaz interactiva | -| [**API-GUIDE.md**](docs/API-GUIDE.md) | Referencia de API para uso programático en Python | -| [**ARCHITECTURE.md**](docs/ARCHITECTURE.md) | Diseño interno, flujos de datos, decisiones de arquitectura | -| [**CONTRIBUTING.md**](docs/CONTRIBUTING.md) | Guía para contribuidores | - -### Acceso Rápido - -- ¿Cómo uso la herramienta? → [CLI-GUIDE.md](docs/CLI-GUIDE.md) -- ¿Cómo integro en mi código Python? → [API-GUIDE.md](docs/API-GUIDE.md) -- ¿Cómo está organizado internamente? → [ARCHITECTURE.md](docs/ARCHITECTURE.md) -- ¿Cómo contribuyo? → [CONTRIBUTING.md](docs/CONTRIBUTING.md) - -## Archivos y Configuración - -### Archivo de Datos +The interface automatically detects your system language (supports English and Spanish). You can register new days, view recent history, or import/export your data. -El historial se guarda en **`historial_horas.json`** en el directorio actual (por defecto). - -Ejemplo de contenido: - -```json -{ - "2026-04-16": { - "horas": 8, - "minutos": 30, - "diferencia": 45 - }, - "2026-04-15": { - "horas": 7, - "minutos": 45, - "diferencia": 0 - } -} -``` - -### Resolución de Rutas - -La ubicación del archivo se resuelve con **3 niveles de prioridad**: - -1. **Argumento `archivo_path`** en funciones de la API (cuando se usan programáticamente) -2. **Variable de entorno `HISTORIAL_PATH`** (si está definida) -3. **`historial_horas.json`** en el directorio actual (comportamiento por defecto) - -**Ejemplo con variable de entorno:** +### 2. Quick Commands (Direct Mode) +Consult your status without entering the menu: ```bash -export HISTORIAL_PATH=~/.local/share/time-balance/historial_horas.json -time-balance -``` - -### Backups Automáticos - -Las operaciones destructivas crean backups automáticos: - -- **`historial_horas.json.bak`** — Último backup (siempre actualizado) -- **`historial_horas.json.bak.20260416T111320`** — Backup versionado con timestamp ISO - -Se generan durante operaciones `overwrite` para permitir recuperación en caso de error. - -### Escrituras Seguras - -Todas las escrituras son **atómicas** (crash-safe): -- Se escribe a archivo temporal primero -- Se realiza reemplazo atómico (`os.replace()`) -- Si se interrumpe el proceso, el archivo original permanece intacto - -## Uso Programático (API) - -Para integrar `time-balance` en tu código Python: +# Show current accumulated balance only +time-balance --status -```python -from time_balance import cargar_datos, guardar_datos, formatear_tiempo, calcular_saldo_total +# List last 10 recorded days +time-balance --list 10 -# Cargar historial -datos = cargar_datos() +# Force a specific language +time-balance --lang en -# Acceder a datos -saldo = calcular_saldo_total(datos) -print(f"Saldo acumulado: {formatear_tiempo(saldo)}") - -# Registrar nuevo día -from datetime import date -hoy = str(date.today()) -datos[hoy] = {'horas': 8, 'minutos': 30, 'diferencia': 45} - -# Guardar cambios -guardar_datos(datos) +# Check installed version +time-balance --version ``` -### Funciones Disponibles +--- -| Función | Propósito | -|---------|-----------| -| `cargar_datos(archivo_path=None)` | Carga historial desde JSON | -| `guardar_datos(datos, archivo_path=None)` | Guarda historial (atómico) | -| `exportar_historial(ruta_destino, archivo_path=None)` | Exporta a archivo externo | -| `importar_historial(ruta_fuente, modo='merge', archivo_path=None)` | Importa con validación | -| `formatear_tiempo(minutos_totales)` | Convierte minutos a formato legible | -| `calcular_saldo_total(datos)` | Suma diferencias diarias | +## Key Features -Para documentación completa, consulta [API-GUIDE.md](docs/API-GUIDE.md). +- ✅ **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. -## Tests +--- -La suite de tests se ejecuta con: - -```bash -# Ejecutar discovery (la carpeta `tests/` es un paquete, por lo que la forma -# simple funciona correctamente): -python3 -m unittest discover -v -``` +## Advanced Configuration -Ejecutar un test concreto (ejemplos): +### History Location +By default, the app creates `historial_hours.json` in the current folder. To centralize it, define the environment variable: ```bash -# Ejecutar todos los tests del módulo `tests.test_import_export` -python -m unittest tests.test_import_export -v - -# Ejecutar un caso de prueba concreto dentro del módulo -python -m unittest tests.test_import_export.TestImportExport.test_exportar_historial_crea_archivo -v +export HISTORIAL_PATH="~/.config/time-balance/my_history.json" ``` -**Cobertura:** - -- **10 tests** de lógica principal (`tests/test_control_horas.py`) - - Formateo de tiempo, cálculo de saldo, I/O, detección de duplicados - -- **4 tests** de import/export (`tests/test_import_export.py`) - - Exportación, modo merge, modo overwrite, backups, validación - -**Características de los tests:** +### 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. -- ✅ Cada test usa directorio temporal aislado (no toca archivos reales) -- ✅ Validan correctitud de datos, operaciones atómicas, backups -- ✅ Se ejecutan rapidamente (~0.015s) +--- -## Mejoras Propuestas +## Future Steps (Roadmap) 🚀 -Posibles extensiones futuras (sin romper compatibilidad): +We are working to take `time-balance` to the next level: -- Soporte para XDG Base Directory (`~/.local/share/time-balance/`) -- Exportación a CSV / Excel -- Estadísticas y reportes (semanal, mensual, anual) -- Sincronización con servicios en la nube -- Integración con calendarios/agenda -- Notificaciones y recordatorios +- 🗄️ **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. -## Contribuciones +--- -Si encuentras bugs o tienes ideas de mejora, consulta [CONTRIBUTING.md](docs/CONTRIBUTING.md) para aprender cómo contribuir. +## Development and Contributions -## Licencia +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. -Este proyecto está bajo la licencia [GNU General Public License v3.0 (GPL-3.0)](LICENSE). +## License -El objetivo es fomentar el crecimiento colaborativo y garantizar que todas las mejoras derivadas sigan siendo de código abierto y beneficien a toda la comunidad. No se incentiva la creación de forks privativos. +This project is Open Source under the [GPL-3.0](LICENSE) license. diff --git a/docs/API-GUIDE.md b/docs/API-GUIDE.md deleted file mode 100644 index 0fa2fdc..0000000 --- a/docs/API-GUIDE.md +++ /dev/null @@ -1,382 +0,0 @@ -# Guía de API: Uso Programático - -La librería `time-balance` expone una API pública para integrar la funcionalidad de registro de horas en tus propias aplicaciones Python. - -## Instalación para Desarrollo - -```bash -# En la carpeta del proyecto -python3 -m pip install -e . -``` - -## Importación - -```python -from time_balance import ( - cargar_datos, - guardar_datos, - exportar_historial, - importar_historial, - registrar_jornada, - ver_historial, - formatear_tiempo, - calcular_saldo_total, -) -``` - -## Funciones Principales - -### 1. `cargar_datos(archivo_path=None)` - -Carga el historial de horas desde un archivo JSON. - -**Parámetros:** -- `archivo_path` (str, optional): Ruta al archivo JSON. Si es `None`, usa la resolución de prioridades (env var → archivo por defecto). - -**Retorna:** -- `dict`: Diccionario con estructura `{fecha: {horas, minutos, diferencia}}`, o `{}` si el archivo no existe o está corrupto. - -**Ejemplo:** - -```python -datos = cargar_datos() -print(datos) -# {'2026-04-16': {'horas': 8, 'minutos': 30, 'diferencia': 45}, -# '2026-04-15': {'horas': 7, 'minutos': 45, 'diferencia': 0}} -``` - -### 2. `guardar_datos(datos, archivo_path=None)` - -Guarda el historial en un archivo JSON de forma segura (operación atómica). - -**Parámetros:** -- `datos` (dict): Diccionario del historial con estructura `{fecha: {horas, minutos, diferencia}}`. -- `archivo_path` (str, optional): Ruta donde guardar. Si es `None`, usa resolución de prioridades. - -**Retorna:** -- `None`: Esta función guarda `datos` en disco y no retorna ningún valor. - -**Características de seguridad:** -- Escribe a archivo temporal primero -- Usa `os.replace()` para reemplazo atómico (crash-safe) -- Si algo falla a mitad de la escritura, el archivo original permanece intacto - -**Ejemplo:** - -```python -datos = { - '2026-04-16': {'horas': 8, 'minutos': 30, 'diferencia': 45}, - '2026-04-15': {'horas': 7, 'minutos': 45, 'diferencia': 0} -} -guardar_datos(datos) -print("✓ Datos guardados") -``` - -### 3. `formatear_tiempo(minutos_totales)` - -Convierte minutos a formato legible (ej: "2h 5m"). - -**Parámetros:** -- `minutos_totales` (int): Cantidad de minutos (puede ser positivo o negativo). - -**Retorna:** -- `str`: Formato legible; los valores negativos incluyen prefijo `-`, los positivos no incluyen `+`. Ej: `"2h 5m"`, `"-1h 15m"`, `"0h 0m"`. - -**Ejemplo:** - -```python -print(formatear_tiempo(125)) # "2h 5m" -print(formatear_tiempo(-75)) # "-1h 15m" -print(formatear_tiempo(0)) # "0h 0m" -print(formatear_tiempo(465)) # "7h 45m" -``` - -### 4. `calcular_saldo_total(datos)` - -Suma todas las diferencias diarias para obtener el saldo acumulado. - -**Parámetros:** -- `datos` (dict): Diccionario del historial con estructura `{fecha: {..., diferencia}}`. - -**Retorna:** -- `int`: Saldo total en minutos (puede ser positivo o negativo). - -**Ejemplo:** - -```python -datos = { - '2026-04-16': {'horas': 8, 'minutos': 30, 'diferencia': 45}, - '2026-04-15': {'horas': 7, 'minutos': 45, 'diferencia': 0}, - '2026-04-14': {'horas': 6, 'minutos': 30, 'diferencia': -75} -} -saldo = calcular_saldo_total(datos) -print(saldo) # -30 (minutos) -print(formatear_tiempo(saldo)) # "-0h 30m" -``` - -### 5. `exportar_historial(ruta_destino, archivo_path=None)` - -Exporta el historial completo a un archivo JSON externo. - -**Parámetros:** -- `ruta_destino` (str): Ruta donde crear el archivo de exportación. Soporta `~` (home directory). -- `archivo_path` (str, optional): Ruta del archivo de datos a exportar (usa resolución de prioridades si es `None`). - -**Retorna:** -- `str`: Ruta absoluta del archivo creado. - -**Excepciones:** -- Puede lanzar excepciones de I/O si la ruta de destino no es válida. - -**Ejemplo:** - -```python -try: - ruta = exportar_historial("~/backups/mi_export.json") - print(f"✓ Exportado a: {ruta}") -except Exception as e: - print(f"Error: {e}") -``` - -### 6. `importar_historial(ruta_fuente, modo='merge', archivo_path=None)` - -Importa historial desde un archivo JSON externo. - -**Parámetros:** -- `ruta_fuente` (str): Ruta del archivo a importar. Soporta `~` (home directory). -- `modo` (str): Tipo de importación: - - `'merge'` (predeterminado): Combina datos; los importados sobrescriben conflictos. - - `'overwrite'`: Reemplaza todo el historial (crea backup antes). -- `archivo_path` (str, optional): Ruta del archivo de datos (usa resolución de prioridades si es `None`). - -**Retorna:** -- `dict`: El historial actualizado con la estructura `{fecha: {horas, minutos, diferencia}}`. - -**Backups en modo `overwrite`:** -- Se crea `historial_horas.json.bak` (último backup) -- Se crea `historial_horas.json.bak.20260416T111320` (versión con timestamp) - -**Validación:** -- Valida que el JSON importado tenga estructura correcta -- Lanza `ValueError` si la estructura es inválida - -**Ejemplo - Modo Merge:** - -```python -# Importar y combinar -datos = importar_historial("~/export.json", modo="merge") -print(f"✓ Importados {len(datos)} registros") -``` - -**Ejemplo - Modo Overwrite:** - -```python -# Importar y reemplazar (con backup automático) -try: - datos = importar_historial("~/backup_completo.json", modo="overwrite") - print("✓ Historial restaurado completamente") -except ValueError as e: - print(f"Error de validación: {e}") -``` - -## Gestión de Archivos - -### Resolución de Rutas - -La mayoría de funciones usan un sistema de resolución de rutas con 3 niveles de prioridad: - -1. **Argumento `archivo_path`** en la función -2. **Variable de entorno `HISTORIAL_PATH`** -3. **Archivo por defecto** `historial_horas.json` en el directorio actual - -```python -import os - -# Establecer ubicación personalizada para el historial -os.environ["HISTORIAL_PATH"] = "/home/usuario/.local/share/time-balance/historial.json" - -# Ahora todas las funciones usan esta ubicación -datos = cargar_datos() -``` - -### Backups Automáticos - -Los backups se crean automáticamente en modo `overwrite` durante importación: - -- **Simple backup**: `historial_horas.json.bak` (siempre es el más reciente) -- **Versionado**: `historial_horas.json.bak.20260416T111320` (con timestamp ISO) - -```python -# Después de un importar con overwrite, tienes ambos archivos -# En caso de error, puedes restaurar desde el backup -``` - -## Ejemplos Prácticos - -### Ejemplo 1: Automatizar Registro Diario - -```python -from datetime import date -from time_balance import cargar_datos, guardar_datos - -# Cargar datos existentes -datos = cargar_datos() - -# Registrar hoy -hoy = str(date.today()) # "2026-04-16" -datos[hoy] = { - 'horas': 8, - 'minutos': 15, - 'diferencia': 45 # minutos -} - -# Guardar -guardar_datos(datos) -print(f"✓ Registrado {hoy}") -``` - -### Ejemplo 2: Generar Reporte - -```python -from time_balance import ( - cargar_datos, - calcular_saldo_total, - formatear_tiempo, -) - -datos = cargar_datos() -saldo = calcular_saldo_total(datos) - -print("=== REPORTE DE HORAS ===") -print(f"Registros totales: {len(datos)}") -print(f"Saldo acumulado: {formatear_tiempo(saldo)}") - -# Mostrar últimos 5 -for fecha in sorted(datos.keys(), reverse=True)[:5]: - h = datos[fecha]['horas'] - m = datos[fecha]['minutos'] - diff = datos[fecha]['diferencia'] - print(f" {fecha}: {h}h {m}m (diff: {formatear_tiempo(diff)})") -``` - -### Ejemplo 3: Sincronización entre Máquinas - -```python -import os -from time_balance import exportar_historial, importar_historial - -# Máquina 1: Exportar -print("Exportando desde máquina 1...") -ruta = exportar_historial("~/Dropbox/time-balance-sync.json") -print(f"✓ Exportado a: {ruta}") - -# Máquina 2: Importar y combinar -print("Importando en máquina 2...") -datos = importar_historial("~/Dropbox/time-balance-sync.json", modo="merge") -print(f"✓ Sincronizados {len(datos)} registros") -``` - -### Ejemplo 4: Integración con Script Cron - -```bash -#!/bin/bash -# script: registrar_horas_cron.sh -# Ejecutar diariamente con cron para recordar registrar horas - -python3 << 'EOF' -import os -from datetime import date -from time_balance import cargar_datos, guardar_datos - -# Verificar si ya está registrado hoy -datos = cargar_datos() -hoy = str(date.today()) - -if hoy not in datos: - print(f"Recordatorio: Falta registrar horas para {hoy}") - # Podría enviar notificación, email, etc. -else: - print(f"✓ {hoy} ya está registrado") -EOF -``` - -## Constantes de Configuración - -Desde el módulo puedes acceder a estas constantes: - -```python -from time_balance import HORAS_BASE, MINUTOS_BASE, ARCHIVO_DATOS, ENV_HISTORIAL - -print(HORAS_BASE) # 7 -print(MINUTOS_BASE) # 45 -print(ARCHIVO_DATOS) # "historial_horas.json" -print(ENV_HISTORIAL) # "HISTORIAL_PATH" -``` - -## Estructura de Datos - -### Formato del Historial - -```python -{ - "2026-04-16": { - "horas": 8, # int: horas trabajadas - "minutos": 30, # int: minutos trabajados - "diferencia": 45 # int: minutos respecto a base (7h 45m = 465 minutos) - }, - "2026-04-15": { - "horas": 7, - "minutos": 45, - "diferencia": 0 - } -} -``` - -- **horas**: Entero, rango típico 0-12 -- **minutos**: Entero, rango 0-59 -- **diferencia**: Entero, diferencia en minutos respecto a jornada base (465 minutos) - - Positivo: más horas que la base - - Negativo: menos horas que la base - - Cero: exactamente la jornada base - -## Manejo de Errores - -```python -from time_balance import importar_historial - -try: - datos = importar_historial("archivo_inexistente.json") -except FileNotFoundError: - print("Archivo no encontrado") -except ValueError as e: - print(f"JSON inválido: {e}") -except Exception as e: - print(f"Error inesperado: {e}") -``` - -Errores comunes: -- `FileNotFoundError`: Archivo a importar no existe -- `ValueError`: JSON tiene estructura incorrecta -- `json.JSONDecodeError`: JSON malformado (se captura y retorna dict vacío en `cargar_datos`) - -## Testing - -Si deseas usar la API en tests: - -```python -import tempfile -import os -from time_balance import cargar_datos, guardar_datos - -# Usar directorio temporal para tests -with tempfile.TemporaryDirectory() as tmpdir: - archivo_test = os.path.join(tmpdir, "test_historial.json") - - # Usar archivo de prueba específico - datos_test = {"2026-04-16": {"horas": 8, "minutos": 0, "diferencia": 15}} - guardar_datos(datos_test, archivo_path=archivo_test) - - # Verificar - datos_cargados = cargar_datos(archivo_path=archivo_test) - assert datos_cargados == datos_test -``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5dcbe79..96c0f94 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,306 +1,110 @@ -# Arquitectura del Proyecto +# Project Architecture -## Visión General +## Overview -`time-balance` es una aplicación Python simplificada para registrar jornadas laborales y calcular saldos acumulados. Utiliza un enfoque mínimalista con almacenamiento en JSON y una interfaz interactiva de menú. +`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. -## Estructura del Proyecto +## Project Structure ``` time-balance/ -├── time_balance/ # Paquete principal -│ └── __init__.py # Módulo principal con toda la lógica -├── tests/ # Suite de tests -│ ├── test_control_horas.py -│ └── test_import_export.py -├── docs/ # Documentación (nuevo) -│ ├── CLI-GUIDE.md -│ ├── API-GUIDE.md -│ ├── ARCHITECTURE.md # Este archivo -│ └── CONTRIBUTING.md -├── examples/ # Archivos de ejemplo -│ └── historial_horas.json -├── README.md # Documentación principal -├── CHANGELOG.md # Historial de cambios -├── pyproject.toml # Metadatos y configuración -├── setup.py # Configuración setuptools -├── historial_horas.json # Archivo de datos (generado en runtime) -└── .gitignore -``` - -## Módulos y Componentes - -### `time_balance/__init__.py` (módulo principal) - -Contiene toda la lógica de la aplicación: - -#### 1. **Resolución de Rutas** -- `_resolver_archivo(archivo_path=None)`: Implementa la lógica de prioridades para encontrar el archivo de datos - - Prioridad 1: Parámetro `archivo_path` - - Prioridad 2: Variable de entorno `HISTORIAL_PATH` - - Prioridad 3: `historial_horas.json` en directorio actual - -#### 2. **I/O Segura** -- `cargar_datos(archivo_path=None)`: Carga JSON con manejo seguro de errores -- `guardar_datos(datos, archivo_path=None)`: Escritura atómica usando `tempfile` + `os.replace()` -- `_crear_backup(archivo_path)`: Crea backups con timestamp e histórico - -#### 3. **Lógica de Negocio** -- `formatear_tiempo(minutos_totales)`: Convierte minutos a formato legible -- `calcular_saldo_total(datos)`: Suma diferencias diarias -- `registrar_jornada(datos, archivo_path=None)`: Interfaz interactiva para registrar horas -- `solicitar_fecha()`: Valida entrada de fecha con YYYY-MM-DD -- `ver_historial(datos)`: Muestra últimos 5 registros - -#### 4. **Import/Export** -- `exportar_historial(ruta_destino, archivo_path=None)`: Copia a JSON externo -- `importar_historial(ruta_fuente, modo='merge', archivo_path=None)`: Importa y valida -- `_validar_historial(datos)`: Valida estructura JSON importada - -#### 5. **Punto de Entrada** -- `main()`: Loop interactivo del menú principal - -## Flujo de Datos - -### Diagrama de Flujo General - -``` -┌─────────────────────────────────────────────────────────────┐ -│ main() - Loop Interactivo │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌─────────┼─────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌────────┐ ┌──────────┐ - │ Registrar│ │ Ver │ │Exportar/I│ - │ Jornada │ │Histor. │ │ mportar │ - └──────────┘ └────────┘ └──────────┘ - │ │ │ - └─────────┼─────────┘ - │ - ┌─────────▼──────────┐ - │ cargar/guardar_datos│ - │ (JSON I/O) │ - └────────────────────┘ - │ - ┌─────────▼──────────┐ - │ historial_horas.json│ - └────────────────────┘ -``` - -### Flujo de Registrar Jornada - -``` -registrar_jornada() - ├─ solicitar_fecha() → Validar YYYY-MM-DD - ├─ input horas/minutos → Convertir a int - ├─ Verificar si existe - │ └─ Si existe → Pedir confirmación - ├─ Calcular diferencia - │ └─ diferencia = (horas*60 + minutos) - 465 (base) - ├─ Guardar en memoria - ├─ guardar_datos() - │ ├─ Crear archivo temporal - │ ├─ Escribir JSON - │ ├─ os.replace() - operación atómica - │ └─ Limpiar temporales - └─ Retornar a menú -``` - -### Flujo de Importación - -``` -importar_historial(ruta_fuente, modo='merge'|'overwrite') - ├─ Cargar JSON desde ruta_fuente - ├─ _validar_historial() → Validar estructura - │ └─ Si inválido → Lanzar ValueError - │ - ├─ Si modo='merge' - │ ├─ Combinar: datos_local.update(datos_importados) - │ └─ Los importados ganan en conflictos - │ - ├─ Si modo='overwrite' - │ ├─ _crear_backup(archivo_actual) - │ │ ├─ Copiar a archivo_path.bak - │ │ └─ Copiar a archivo_path.bak.20260416T111320 - │ └─ Reemplazar completamente - │ - ├─ guardar_datos(resultado) - └─ Retornar datos actualizado -``` - -## Características de Confiabilidad - -### 1. **Escritura Atómica** - -```python -# Implementación en guardar_datos() -fd, tmp_path = tempfile.mkstemp() -with os.fdopen(fd, 'w') as f: - json.dump(datos, f) -os.replace(tmp_path, archivo_datos) # Operación atómica -``` - -Beneficios: -- Si el proceso se interrumpe durante la escritura, el archivo original permanece intacto - - No hay riesgo de corrupción de datos cuando el temporal se crea en el mismo filesystem - - Atómico dentro del mismo filesystem; si el temporal se crea en otro dispositivo el rename puede fallar con EXDEV - -### 2. **Backups Automáticos** - -``` -historial_horas.json (archivo principal) -historial_horas.json.bak (último backup) -historial_horas.json.bak.20260416T111320 (backup versionado) -``` - -- Se crean antes de operaciones destructivas (overwrite) -- Timestamp ISO 8601 para orden cronológico -- El archivo `.bak` se actualiza siempre con el más reciente - -### 3. **Validación de Datos** - -```python -def _validar_historial(datos): - # Validar tipo dict - # Validar todas las claves YYYY-MM-DD - # Validar estructura: horas, minutos y diferencia como int - # Lanzar ValueError con mensaje específico si algo falla -``` - -### 4. **Manejo de Errores Resiliente** - -- JSON corrupto: `cargar_datos()` retorna `{}` vacío -- Archivo faltante: Se crea uno nuevo en primera escritura -- Entrada inválida: Bucles de reintentos en entrada interactiva -- Rutas inválidas: Errores claros y permitir reintentar - -## Configuración - -### Constantes - -```python -HORAS_BASE = 7 # Horas en jornada base -MINUTOS_BASE = 45 # Minutos adicionales en jornada base -ARCHIVO_DATOS = "historial_horas.json" # Nombre por defecto -ENV_HISTORIAL = "HISTORIAL_PATH" # Variable de entorno -``` - -La jornada base es: **7 horas 45 minutos = 465 minutos** - -### Variables de Entorno - -```bash -# Ubicación personalizada del historial -export HISTORIAL_PATH="/home/usuario/.local/share/time-balance/historial.json" -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 +│ ├── core.py # Business logic (formatting, calculations) +│ ├── storage.py # Persistence and atomic storage +│ ├── io.py # Validation, import, and export +│ ├── cli.py # User interface and arguments +│ └── i18n.py # Internationalization system +├── tests/ # Modular test suite +│ ├── test_core.py +│ ├── test_storage.py +│ ├── test_io.py +│ └── test_cli.py +├── docs/ # Documentation +├── README.md # User guide +└── CHANGELOG.md # Change history +``` + +## 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. + +### 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. + +### 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). + +### 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`). + +### 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 + } + } +} +``` + +## 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). ## Testing -### Estrategia de Testing - -- **Aislamiento**: Cada test usa `tempfile.TemporaryDirectory()` -- **No toca archivos reales**: El repositorio `historial_horas.json` nunca se modifica -- **Cobertura**: I/O, validación, cálculos, import/export, backups - -### Test Files - -- `tests/test_control_horas.py`: Tests de lógica principal (10 tests) -- `tests/test_import_export.py`: Tests de import/export (4 tests) - -Ejecución: -```bash -# Ejecutar discovery (la carpeta `tests/` es un paquete, por lo que la forma -# simple funciona correctamente): -python3 -m unittest discover -v -``` - -Ejecutar un test concreto (útil durante desarrollo): - -```bash -# Ejecutar todos los tests del módulo -python -m unittest tests.test_import_export -v - -# Ejecutar un caso de prueba específico -python -m unittest tests.test_import_export.TestImportExport.test_exportar_historial_crea_archivo -v -``` - -## Dependencias - -### Dependencias Incluidas en Python Estándar - -- `os`: Operaciones de filesystem, variables de entorno -- `json`: Serialización/deserialización JSON -- `datetime`: Manejo de fechas -- `tempfile`: Archivos temporales para escritura segura -- `shutil`: Copia de archivos con metadatos - -### Dependencias de Desarrollo - -- `unittest`: Testing (incorporado en Python) - -**No hay dependencias externas**. `time-balance` es 100% pure Python con biblioteca estándar. - -## Decisiones de Diseño - -### 1. **Almacenamiento en JSON en vez de Database** - -✅ Ventajas: -- Portable, human-readable, fácil de respaldar -- No requiere daemon o configuración extra -- Fácil de sincronizar entre máquinas - -### 2. **Interfaz Interactiva (menú) en vez de Subcomandos** - -✅ Ventajas: -- Previene errores de usuario (menú clara) -- No requiere memorizar comandos -- Más accesible para usuarios no técnicos - -### 3. **Operaciones Atómicas en Escritura** - -✅ Ventajas: -- Garantiza integridad de datos -- Resistente a fallos de energía -- Seguro para ejecución concurrente - -### 4. **Validación Rigurosa en Import** - -✅ Ventajas: -- Previene datos corrupto -- Errores específicos y claros -- Fácil depuración - -## Extensibilidad Futura - -Posibles mejoras sin romper compatibilidad: - -1. **Soporte para XDG Base Directory** (`~/.local/share/time-balance/`) -2. **Exportación a formatos adicionales** (CSV, Excel) -3. **Estadísticas y reportes** (semanal, mensual) -4. **Sincronización en la nube** -5. **Integración con calendar/agenda** -6. **Notificaciones/reminders** +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. -## Performance +## Future Extensibility (Technical Evolution) -- **Carga de datos**: O(1) - lectura simple de JSON -- **Guardar datos**: O(n) - serialización JSON lineal -- **Cálculo de saldo**: O(n) - suma de diferencias -- **Displayar historial**: O(1) - últimos 5 registros (constante) +The system is designed to evolve into a professional time management solution: -Esperado para < 10,000 registros sin degradación perceptible. +1. **Relational Storage (SQLite)**: + - Migrate internal persistence from JSON to SQLite. + - JSON format will remain exclusively for import/export flows (portability). -## Seguridad +2. **Centralized Multi-project Support**: + - Implement a global project registry to switch contexts without changing directories. -- **Datos locales**: Almacenados en archivo JSON accesible (usuario responsable de permisos) -- **Sin autenticación**: Diseño local-first -- **Sin networking**: No hay conexiones remotas por defecto -- **Validación estricta**: Rechaza JSON malformados +3. **Cloud Sync**: + - Allow database location in cloud storage services for automatic synchronization. -Se recomienda: -- Usar permisos de archivo restrictivos (`chmod 600`) si es crítico -- Hacer backups regulares -- Si es necesario sincronizar, usar herramientas seguras (rsync, Syncthing, etc.) +4. **Modern UI (Rich UI)**: + - Integrate libraries like `rich` to improve the presentation of tables and colors. diff --git a/docs/CLI-GUIDE.md b/docs/CLI-GUIDE.md index 56e932e..17ecc21 100644 --- a/docs/CLI-GUIDE.md +++ b/docs/CLI-GUIDE.md @@ -1,244 +1,84 @@ -# Guía de Uso: Interfaz CLI Interactiva +# Usage Guide: CLI Interface -`time-balance` proporciona una interfaz interactiva de línea de comandos basada en menús. No hay argumentos de línea de comandos ni subcomandos. +`time-balance` offers a dual interface: an interactive menu for daily use and direct commands for quick queries. -## Inicio de la Aplicación +## Direct Commands (Fast Mode) -```bash -time-balance -``` - -## Menú Principal - -Al ejecutar `time-balance`, verás el menú principal: - -``` -================================================== - SALDO TOTAL ACUMULADO: [balance acumulado] - (Base diaria: 7h 45m) -================================================== - -Opciones: -1. Registrar jornada (o corregir día) -2. Ver últimos registros -3. Exportar historial a archivo -4. Importar historial desde archivo -5. Salir - -Elige opción: _ -``` - -## Opciones Detalladas - -### 1. Registrar Jornada - -Permite registrar las horas trabajadas en un día o corregir un registro anterior. - -**Flujo de interacción:** - -``` -Elige opción: 1 - -Ingresa fecha (YYYY-MM-DD) o presiona Enter para hoy [2026-04-16]: -Ingresa horas trabajadas: 8 -Ingresa minutos trabajados: 30 -``` - -**Comportamiento:** -- Si la fecha ya existe, solicita confirmación antes de sobrescribir -- Calcula automáticamente la diferencia respecto a la jornada base (7h 45m) -- Guarda los cambios en el archivo de historial -- Retorna al menú principal - -**Ejemplo de sobrescritura:** - -``` -¿El día 2026-04-16 ya existe (8h 30m, diferencia: +0h 45m). -¿Deseas sobrescribir? (s/n): s -✓ Jornada registrada/actualizada -``` - -### 2. Ver Últimos Registros - -Muestra los 5 registros más recientes del historial. - -**Flujo de interacción:** - -``` -Elige opción: 2 - -Últimos registros: -2026-04-16: 8h 30m (diferencia: +0h 45m) -2026-04-15: 7h 45m (diferencia: 0h 0m) -2026-04-14: 6h 30m (diferencia: -1h 15m) -2026-04-13: 9h 0m (diferencia: +1h 15m) -2026-04-12: 8h 15m (diferencia: +0h 30m) -``` - -**Información mostrada:** -- Fecha en formato YYYY-MM-DD -- Horas y minutos trabajados -- Diferencia respecto a la jornada base (positiva/negativa) +You can query information without entering the interactive menu using flags: -### 3. Exportar Historial - -Crea una copia de tu historial completo en un archivo JSON externo. - -**Flujo de interacción:** - -``` -Elige opción: 3 - -Ingresa ruta de destino para exportar: /path/a/mi_export.json -✓ Historial exportado a: /path/a/mi_export.json -``` - -**Características:** -- El archivo se crea con formato JSON -- Si el archivo ya existe, se sobrescribe sin advertencia -- Las rutas se pueden expresar con `~` (home directory) -- Retorna la ruta absoluta del archivo creado - -**Formato del archivo exportado:** - -```json -{ - "2026-04-16": { - "horas": 8, - "minutos": 30, - "diferencia": 45 - }, - "2026-04-15": { - "horas": 7, - "minutos": 45, - "diferencia": 0 - } -} -``` - -### 4. Importar Historial - -Incorpora datos desde un archivo JSON externo. +```bash +# View current accumulated balance +time-balance --status -**Flujo de interacción:** +# List last 10 records +time-balance --list 10 -``` -Elige opción: 4 +# Force language (en/es) +time-balance --lang en -Ingresa ruta de origen para importar: /path/a/mi_export.json -Elige modo de importación (merge/overwrite) [merge]: -✓ Historial importado: 5 entradas procesadas +# Check version +time-balance --version ``` -**Modos de Importación:** +## Interactive Interface -#### Modo `merge` (predeterminado) -``` -merge -``` -- Combina el historial importado con el existente -- **Los datos importados tienen preferencia** en caso de conflicto -- Si una fecha existe en ambos, la versión importada sobrescribe la local -- Recomendado para: restaurar datos sin perder registros locales recientes - -**Ejemplo:** -``` -Local: {"2026-04-16": {...}, "2026-04-15": {...}} -Importado: {"2026-04-16": {...}, "2026-04-14": {...}} -Resultado: {"2026-04-16": {...(importado)}, "2026-04-15": {...}, "2026-04-14": {...}} -``` +To start the full control center: -#### Modo `overwrite` -``` -overwrite +```bash +time-balance ``` -- Reemplaza todo el historial con los datos importados -- **Crea un backup automático** antes de la operación: - - `historial_horas.json.bak` (siempre actualizado) - - `historial_horas.json.bak.20260416T111320` (versión con timestamp) -- El backup se puede usar para restaurar datos en caso de error -- Recomendado para: migración completa de datos o restauración de backup -### 5. Salir +### Main Menu -Cierra la aplicación y regresa al símbolo del sistema. +The interface automatically detects your system language, but you can change it in the project configuration. ``` -Elige opción: 5 -¡Hasta mañana! -``` - -## Resolución de Rutas - -Cuando se solicita una ruta (en exportar/importar): - -1. **Prioridad de búsqueda del archivo de datos** (usado internamente): - 1. Argumento `archivo_path` en funciones de API - 2. Variable de entorno `HISTORIAL_PATH` - 3. `historial_horas.json` en el directorio actual (predeterminado) - -2. **Expansión de rutas:** - - Se expande `~` a tu directorio de inicio - - Soporta rutas relativas y absolutas +================================================== + PROJECT: PROJECT NAME + TOTAL ACCUMULATED BALANCE: +2h 15m + (Daily base: 7h 45m) +================================================== -**Ejemplo con variable de entorno:** +Options: +1. Register workday (or correct day) +2. View recent records +3. Configure project (name/hours) +4. Export history to file +5. Import history from file +6. Exit -```bash -# Usa una ubicación personalizada para el historial -export HISTORIAL_PATH=~/.local/share/time-balance/historial_horas.json -time-balance +Choose option: _ ``` -## Manejo de Errores - -- **Fecha inválida:** Se pide reintentar con formato YYYY-MM-DD -- **Entrada numérica inválida:** Se pide reintentar (solo números) -- **Archivo JSON corrupto:** Se retorna un error específico -- **Ruta de exportación no existente:** Se crea el archivo nuevo -- **Ruta de importación no encontrada:** Se muestra error y se regresa al menú +## Detailed Options -## Ejemplos Comunes +### 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**. -### Workflow típico del día +### 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). -```bash -$ time-balance +### 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. -# Opción 1: registrar horas del día -Elige opción: 1 -Ingresa fecha (YYYY-MM-DD) o presiona Enter para hoy [2026-04-16]: -Ingresa horas trabajadas: 8 -Ingresa minutos trabajados: 15 +### 4. Export History +Creates a backup copy in structured JSON format at the path of your choice. -# Opción 2: ver el saldo actual -Elige opción: 2 -# Muestra los últimos registros +### 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. -# Opción 5: salir -Elige opción: 5 -``` +## Environment Variables Configuration -### Backup y Restauración +You can centralize your history by defining the path in your shell configuration file (`.bashrc` or `.zshrc`): ```bash -# Exportar (backup manual) -$ time-balance -Elige opción: 3 -Ingresa ruta de destino para exportar: ~/backups/historial_backup.json - -# ... más tarde, si es necesario ... - -# Restaurar completo -$ time-balance -Elige opción: 4 -Ingresa ruta de origen para importar: ~/backups/historial_backup.json -Elige modo de importación (merge/overwrite) [merge]: overwrite -# Se crea backup automático antes de restaurar +export HISTORIAL_PATH="~/.config/time-balance/main_history.json" ``` -## Consejos +--- -- **Usa Enter** en el campo de fecha para registrar el día actual (más rápido) -- **Exporta regularmente** para tener backups de seguridad externos -- **Utiliza overwrite** solo cuando estés restaurando, usa **merge** para combinar datos -- El **modo interactivo** previene sobrescrituras accidentales pidiendo confirmación +## 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. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 69cd39a..e5551f2 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,243 +1,57 @@ -# Guía de Contribución +# Contributing to time-balance -Gracias por tu interés en contribuir a `time-balance`. Este documento describe cómo colaborar con el proyecto. +First of all, thank you for considering contributing to `time-balance`! It's people like you that make the open-source community such an amazing place to learn, inspire, and create. -## Filosofía del Proyecto +## How Can I Contribute? -- **Minimalismo**: Mantener la complejidad baja, usar solo biblioteca estándar de Python -- **Confiabilidad**: Operaciones seguras, backups automáticos, validación rigurosa -- **Simplicidad**: Interfaz clara, documentación accesible -- **Portabilidad**: macOS, Linux, Windows (sin dependencias externas) +### Reporting Bugs -## Antes de Contribuir +- Check if the bug has already been reported in the Issues section. +- If not, open a new issue. Include a clear title, a description of the problem, and steps to reproduce it. -1. Lee el [README.md](/../../README.md) para entender el proyecto -2. Revisa [ARCHITECTURE.md](ARCHITECTURE.md) para entender el diseño -3. Revisa el [CHANGELOG.md](/../../CHANGELOG.md) para ver el historial +### Suggesting Enhancements -## Configuración del Entorno de Desarrollo +- Open an enhancement issue to discuss your idea before writing code. +- Explain why this feature would be useful and how it should work. -### 1. Clonar el Repositorio +### Adding New Languages (i18n) -```bash -git clone -cd time-balance -``` +We want `time-balance` to be accessible to everyone. To add a new language: -### 2. Crear Entorno Virtual +1. Locate `time_balance/i18n.py`. +2. Find the `STRINGS` dictionary. +3. Copy the `"en"` dictionary as a template. +4. Add your language code (e.g., `"it"`, `"pt"`, `"de"`) and translate the values. +5. Submit a Pull Request with the title `feat(i18n): add [Language] support`. -```bash -python3 -m venv .venv -source .venv/bin/activate # macOS/Linux -.venv\Scripts\activate # Windows -``` +### Improving Documentation -### 3. Instalar en Modo Desarrollo +- Fix typos or grammatical errors. +- Clarify confusing sections. +- Translate documentation files to other languages. -```bash -python3 -m pip install -e . -``` +## Development Process -Esto instala el paquete en modo editable, permitiendo ver cambios inmediatamente. +1. **Fork the repo** and create your branch from `main`. +2. **Setup your environment** (Python 3.8+ recommended). +3. **Write your code** following the project's style (Clean Code, descriptive naming). +4. **Add tests** for any new functionality. +5. **Ensure all tests pass** with `python3 -m unittest discover tests`. +6. **Submit a Pull Request**. -### 4. Ejecutar Tests +## Pull Request Guidelines -```bash -# Ejecutar discovery (la carpeta `tests/` es un paquete, por lo que la forma -# simple funciona correctamente): -python3 -m unittest discover -v -``` +- Use descriptive commit messages (e.g., `fix(core): improve balance calculation`). +- Update the `CHANGELOG.md` with your changes. +- Ensure the CI/CD pipeline passes. -#### Ejecutar un test concreto +## Style Guide -Si quieres ejecutar un único módulo o caso de prueba para depurar rápidamente: +- Internal code (functions, variables, comments) MUST be in **English**. +- Use 4 spaces for indentation. +- Follow PEP 8 guidelines. +- Never use single-letter variable names (except in very obvious list comprehensions). -```bash -# Ejecutar todos los tests del módulo -python -m unittest tests.test_import_export -v +--- -# Ejecutar un caso de prueba específico -python -m unittest tests.test_import_export.TestImportExport.test_exportar_historial_crea_archivo -v -``` - -Todos los tests deben pasar antes de hacer commit. - -## Haciendo Cambios - -### 1. Crea una Rama - -```bash -git checkout -b feature/mi-caracteristica -# o -git checkout -b fix/correccion-bug -``` - -### 2. Implementa tu Cambio - -- Haz cambios pequeños y enfocados -- Mantén el estilo consistente con el código existente -- Usa nombres descriptivos (variables, funciones) -- Comenta solo lo que necesita aclaración - -### 3. Escribe o Actualiza Tests - -Para cada cambio en funcionalidad: - -- Agrega tests nuevos en `tests/` si necesario -- Actualiza tests existentes si cambias comportamiento - - Todos los tests deben pasar: `python3 -m unittest discover -v` -- Usa `tempfile.TemporaryDirectory()` para tests (no toques archivos reales) - -**Ejemplo de test:** - -```python -import unittest -import tempfile -import os -from time_balance import guardar_datos, cargar_datos - -class TestMiCambio(unittest.TestCase): - def test_mi_funcionalidad(self): - with tempfile.TemporaryDirectory() as tmpdir: - archivo = os.path.join(tmpdir, "test.json") - datos = {"2026-04-16": {"horas": 8, "minutos": 0, "diferencia": 15}} - - guardar_datos(datos, archivo_path=archivo) - resultado = cargar_datos(archivo_path=archivo) - - self.assertEqual(resultado, datos) -``` - -### 4. Verifica el Estilo - -- No hay linter configurado, pero sigue PEP 8 -- Máximo 100 caracteres por línea (preferible) -- Usa type hints donde sea posible (funciones nuevas) -- Documenta con docstrings - -### 5. Actualiza Documentación - -Si tu cambio afecta comportamiento visible: - -- Actualiza [CLI-GUIDE.md](CLI-GUIDE.md) (cambios en menú o opciones) -- Actualiza [API-GUIDE.md](API-GUIDE.md) (cambios en funciones públicas) -- Actualiza [ARCHITECTURE.md](ARCHITECTURE.md) (cambios de diseño) -- Actualiza [README.md](/../../README.md) (resumen o características) - -### 6. Commit y Push - -```bash -git add . -git commit -m "feature: descripción clara del cambio" -git push origin feature/mi-caracteristica -``` - -**Formato de commit message:** -- `feature: ...` para características nuevas -- `fix: ...` para correcciones -- `docs: ...` para cambios de documentación -- `test: ...` para tests -- `refactor: ...` para reorganización sin cambio funcional - -## Pull Request - -### 1. Crea el PR - -- Título claro: "Add export to CSV" o "Fix date parsing bug" -- Descripción: Explica qué hace, por qué, y cómo testear - -### 2. Template de PR - -```markdown -## Descripción -Breve descripción del cambio - -## Tipo -- [ ] Característica nueva -- [ ] Corrección de bug -- [ ] Cambio de documentación -- [ ] Refactoring - -## Testing -- [ ] Agregué tests nuevos -- [ ] Todos los tests pasan -- [ ] Probé manualmente los cambios - -## Checklist -- [ ] Mi código sigue el estilo del proyecto -- [ ] Actualicé la documentación -- [ ] No tengo conflictos sin resolver -``` - -### 3. Revisión - -El mantenedor revisará tu PR: -- Cambios pequeños: merge rápido -- Cambios mayores: discusión y sugerencias -- Tests fallando: se rechaza hasta que pasen - -## Tipos de Contribución Bienvenidos - -### 🟢 Muy Bienvenidas - -1. **Correcciones de Bugs** - - Reporta primero en issue - - Incluye pasos para reproducir - - Agrega test que valide la corrección - -2. **Mejoras de Documentación** - - Ejemplos más claros - - Explicaciones mejoradas - - Corrección de typos - -3. **Tests Adicionales** - - Cobertura de casos edge - - Tests de rendimiento - - Validación de manejo de errores - -### 🟡 Requiere Discusión - -1. **Características Nuevas** - - Abre issue primero para discutir - - Debe mantenerse el minimalismo - - Evitar dependencias externas - -2. **Cambios de API** - - Discussión sobre impacto - - Backwards compatibility - - Migración para usuarios - -3. **Cambios de Almacenamiento** - - Alternativas a JSON - - Impacto en portabilidad - - Compatibilidad hacia atrás - -### 🔴 Probablemente No - -1. **GUI o aplicación web** (fuera de scope) -2. **Dependencias externas** (contradice filosofía) -3. **Autenticación/sincronización** (complejidad innecesaria) -4. **Múltiples idiomas** (complicidad extra, mantener en English+Español) - -## Código de Conducta - -Por favor sé respetuoso con otros contribuidores. Esperamos: - -- Comunicación honrada y constructiva -- Reconocimiento del trabajo de otros -- Apertura a diferentes perspectivas -- Paciencia con nuevos contribuidores - -## Licencia - -Al contribuir, aceptas que tu código se distribuye bajo [GPL-3.0](../LICENSE). - -## Preguntas - -- Revisa el [README.md](/../../README.md) para overview -- Revisa [ARCHITECTURE.md](ARCHITECTURE.md) para entender el diseño -- Abre una issue si tienes dudas - -## Agradecimientos - -¡Gracias por contribuir! Los contribuidores hacen que los proyectos de código abierto sean increíbles. +By contributing, you agree that your contributions will be licensed under its **GPL-3.0 License**. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..1f50d53 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,71 @@ +# Developer Guide + +This guide provides technical details for developers who want to integrate with `time-balance` or contribute to its development. + +## 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. + +## Module Structure + +The project is organized into logical layers: + +### 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. + +### 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. + +## Running Tests + +We use the standard `unittest` framework: + +```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)" + } + } +} +``` diff --git a/docs/es/ARCHITECTURE.es.md b/docs/es/ARCHITECTURE.es.md new file mode 100644 index 0000000..c584944 --- /dev/null +++ b/docs/es/ARCHITECTURE.es.md @@ -0,0 +1,108 @@ +# Arquitectura del Proyecto + +## 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. + +## Estructura del Proyecto + +``` +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 +│ ├── 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 +│ └── i18n.py # Sistema de internacionalización +├── tests/ # Suite de tests modular +│ ├── test_core.py +│ ├── test_storage.py +│ ├── test_io.py +│ └── test_cli.py +├── docs/ # Documentación +├── README.md # Guía de usuario (Inglés) +└── CHANGELOG.md # Historial de cambios +``` + +## 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. + +### 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. + +### 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). + +### 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`). + +### 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 + } + } +} +``` + +## 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). + +## 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. diff --git a/docs/es/CLI-GUIDE.es.md b/docs/es/CLI-GUIDE.es.md new file mode 100644 index 0000000..921970d --- /dev/null +++ b/docs/es/CLI-GUIDE.es.md @@ -0,0 +1,84 @@ +# 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. + +## Comandos Directos (Modo Rápido) + +Puedes consultar información sin entrar al menú interactivo usando flags: + +```bash +# Ver el saldo acumulado actual +time-balance --status + +# Listar los últimos 10 registros +time-balance --list 10 + +# Forzar idioma (en/es) +time-balance --lang en + +# Ver versión +time-balance --version +``` + +## Interfaz Interactiva + +Para iniciar el centro de control completo: + +```bash +time-balance +``` + +### Menú Principal + +La interfaz detecta automáticamente el idioma de tu sistema, pero puedes cambiarlo en la configuración. + +``` +================================================== + PROYECTO: NOMBRE DEL PROYECTO + SALDO TOTAL ACUMULADO: +2h 15m + (Base diaria: 7h 45m) +================================================== + +Opciones: +1. Registrar jornada (o corregir día) +2. Ver últimos registros +3. Configurar proyecto (nombre/jornada) +4. Exportar historial a archivo +5. Importar historial desde archivo +6. Salir + +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**. + +### 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). + +### 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. + +### 4. Exportar Historial +Crea una copia de seguridad en formato JSON estructurado en la ruta que elijas. + +### 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" +``` + +--- + +## 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. diff --git a/docs/es/CONTRIBUTING.es.md b/docs/es/CONTRIBUTING.es.md new file mode 100644 index 0000000..8d53061 --- /dev/null +++ b/docs/es/CONTRIBUTING.es.md @@ -0,0 +1,51 @@ +# Contribuyendo a time-balance + +En primer lugar, ¡gracias por considerar contribuir a `time-balance`! Personas como tú hacen que la comunidad de código abierto sea un lugar increíble para aprender, inspirar y crear. + +## ¿Cómo puedo contribuir? + +### Reportando Bugs + +- Comprueba si el error ya ha sido reportado en la sección de Issues. +- Si no, abre un nuevo issue. Incluye un título claro, una descripción del problema y los pasos para reproducirlo. + +### Sugiriendo Mejoras + +- Abre un issue de mejora para discutir tu idea antes de escribir el código. +- Explica por qué esta característica sería útil y cómo debería funcionar. + +### Añadiendo Nuevos Idiomas (i18n) + +Queremos que `time-balance` sea accesible para todos. Para añadir un nuevo idioma: + +1. Localiza `time_balance/i18n.py`. +2. Busca el diccionario `STRINGS`. +3. Copia el diccionario `"en"` como plantilla. +4. Añade tu código de idioma (ej. `"it"`, `"pt"`, `"de"`) y traduce los valores. +5. Envía un Pull Request con el título `feat(i18n): add [Language] support`. + +### Mejorando la Documentación + +- Corrige erratas o errores gramaticales. +- Aclara secciones confusas. +- Traduce los archivos de documentación a otros idiomas. + +## Proceso de Desarrollo + +1. **Haz un fork del repo** y crea tu rama desde `main`. +2. **Configura tu entorno** (se recomienda Python 3.8+). +3. **Escribe tu código** siguiendo el estilo del proyecto (Clean Code, naming descriptivo). +4. **Añade tests** para cualquier nueva funcionalidad. +5. **Asegúrate de que todos los tests pasan** con `python3 -m unittest discover tests`. +6. **Envía un Pull Request**. + +## Guía de Estilo + +- El código interno (funciones, variables, comentarios) DEBE estar en **Inglés**. +- Usa 4 espacios para la sangría. +- Sigue las guías de PEP 8. +- Nunca uses nombres de variables de una sola letra (excepto en list comprehensions muy obvias). + +--- + +Al contribuir, aceptas que tus contribuciones estarán bajo la licencia **GPL-3.0**. diff --git a/docs/es/DEVELOPMENT.es.md b/docs/es/DEVELOPMENT.es.md new file mode 100644 index 0000000..2afe812 --- /dev/null +++ b/docs/es/DEVELOPMENT.es.md @@ -0,0 +1,71 @@ +# 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. + +## 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. + +## Estructura de Módulos + +El proyecto está organizado en capas lógicas: + +### 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. + +### 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. + +## Ejecución de Tests + +Utilizamos el framework estándar `unittest`: + +```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)" + } + } +} +``` diff --git a/examples/historial_horas.json b/examples/historial_horas.json deleted file mode 100644 index 0e33e85..0000000 --- a/examples/historial_horas.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "2023-10-16": { - "horas": 8, - "minutos": 0, - "diferencia": 0 - }, - "2023-10-17": { - "horas": 8, - "minutos": 30, - "diferencia": 30 - }, - "2023-10-18": { - "horas": 7, - "minutos": 45, - "diferencia": -15 - }, - "2023-10-19": { - "horas": 9, - "minutos": 15, - "diferencia": 75 - }, - "2023-10-20": { - "horas": 6, - "minutos": 30, - "diferencia": -90 - } -} \ No newline at end of file diff --git a/examples/history_example.json b/examples/history_example.json new file mode 100644 index 0000000..126502c --- /dev/null +++ b/examples/history_example.json @@ -0,0 +1,26 @@ +{ + "metadata": { + "project_name": "Example Project", + "hours_base": 7, + "minutes_base": 45, + "version": "1.0", + "language": "en" + }, + "records": { + "2026-04-15": { + "hours": 8, + "minutes": 0, + "difference": 15 + }, + "2026-04-16": { + "hours": 7, + "minutes": 45, + "difference": 0 + }, + "2026-04-17": { + "hours": 6, + "minutes": 30, + "difference": -75 + } + } +} diff --git a/plan-timeBalance.prompt.md b/plan-timeBalance.prompt.md new file mode 100644 index 0000000..43e1720 --- /dev/null +++ b/plan-timeBalance.prompt.md @@ -0,0 +1,96 @@ +Checklist (resumen rápido) +- [ ] Revisar y mapear funciones actuales en `time_balance/__init__.py`. +- [ ] Diseñar módulos objetivos y asignar funciones a cada uno. +- [ ] Crear nuevos archivos en `time_balance/` y actualizar `__init__.py` para reexportar API. +- [ ] Ejecutar tests y ajustar imports/documentación si es necesario. +- [ ] Iterar según feedback. + +## Plan adaptado: Reorganizar `__init__.py` en módulos (con referencia) + +Objetivo: dividir `time_balance/__init__.py` (actualmente ~414 líneas) en módulos cohesivos para mejorar la mantenibilidad, facilitar pruebas y mantener una fachada estable para consumidores del paquete. + +Estrategia general +- Mantener compatibilidad hacia atrás exportando las funciones públicas desde `time_balance/__init__.py` (estrategia de transición). +- Implementar refactor por pasos, commits atómicos y ejecución de la suite de tests tras cada paso. + +Módulos propuestos y asignación de funciones +- `time_balance/storage.py` + - Responsabilidad: resolución de rutas, lectura/escritura atómica y backups. + - Contenido: `_resolver_archivo`, `cargar_datos`, `guardar_datos`, `_crear_backup`. + +- `time_balance/io.py` + - Responsabilidad: import/export de historiales y validación de formato. + - Contenido: `_validar_historial`, `exportar_historial`, `importar_historial`. + +- `time_balance/utils.py` + - Responsabilidad: utilidades puras y constantes. + - Contenido: `formatear_tiempo`, `calcular_saldo_total`, constantes `HORAS_BASE`, `MINUTOS_BASE`, `ARCHIVO_DATOS`, `ENV_HISTORIAL` (si se desea centralizar aquí). + +- `time_balance/cli.py` + - Responsabilidad: interfaz de usuario (stdin/stdout), `main`. + - Contenido: `solicitar_fecha`, `registrar_jornada`, `ver_historial`, `main`. + +- `time_balance/__init__.py` (fachada) + - Reexportar API pública: `cargar_datos`, `guardar_datos`, `exportar_historial`, `importar_historial`, `formatear_tiempo`, `calcular_saldo_total`, `main`, etc. + - Definir `__all__` para controlar la API pública. + +Opcional: `time_balance/__main__.py` para permitir `python -m time_balance` (delegará a `cli.main`). + +Orden de trabajo (commits atómicos) +1) Preparación + - Crear rama: `git checkout -b refactor/split-init`. + - Asegurarse tests pasan antes de empezar. + +2) Commit A — Extraer `storage` + - Crear `time_balance/storage.py` con las funciones correspondientes. + - Reemplazar en `__init__.py` la implementación por `from .storage import ...` (reexport). + - Ejecutar tests: `python3 -m unittest discover -v` y arreglar fallos. + - Commit: `refactor(storage): extraer lógica de persistencia a time_balance/storage.py` + +3) Commit B — Extraer `io` + - Crear `time_balance/io.py` con `_validar_historial`, `exportar_historial`, `importar_historial`. + - `io` puede importar `storage._resolver_archivo` o `utils` según convenga. + - Actualizar `__init__.py` para reexportar. + - Ejecutar tests y corregir. + - Commit: `refactor(io): extraer import/export y validación` + +4) Commit C — Extraer `utils` + - Crear `time_balance/utils.py` y mover utilidades/constantes. + - Ajustar `storage`, `io`, `cli` para importar desde `utils`. + - Ejecutar tests. + - Commit: `refactor(utils): extraer utilidades y constantes` + +5) Commit D — Extraer `cli` y `__main__` + - Crear `time_balance/cli.py` con la UI y `main`. + - Añadir `time_balance/__main__.py` con `from .cli import main; main()`. + - Reexportar `main` en `__init__`. + - Ejecutar tests y probar `python -m time_balance`. + - Commit: `refactor(cli): extraer interfaz de usuario y main` + +6) Commit E — Limpieza + - Añadir `__all__` en `__init__.py`. + - Actualizar documentación si hace falta. + - Ejecutar tests finales. + - Commit: `chore(refactor): reexports en __init__ y actualizar docs` + +Pruebas y validación +- Ejecutar tras cada commit: + - `python3 -m unittest discover -v` + - Probar `python -m time_balance` si `__main__` añadido. + +Buenas prácticas y riesgos +- Evitar ciclos de import (diseñar dependencia utils <- storage/io <- cli). +- Reexportar en `__init__` para mantener compatibilidad de imports existentes. +- Añadir tests que cubran `_validar_historial()` con claves inválidas (p. ej. `"foo"`) para prevenir regresiones. + +Comandos útiles +```bash +git checkout -b refactor/split-init +# editar archivos... +python3 -m unittest discover -v +git add +git commit -m "refactor(): ..." +``` + +¿Quieres que empiece aplicando el primer paso (extraer `storage`) ahora? Si confirmas lo hago, ejecuto tests y te paso el diff/commits para revisión. + diff --git a/pyproject.toml b/pyproject.toml index 8db1203..09f8474 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "time-balance" -version = "0.1.1" +version = "0.2.0" description = "Control sencillo de jornadas y saldo horario" readme = "README.md" authors = [ { name = "autogenerated" } ] diff --git a/setup.py b/setup.py index 397e129..051cf35 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='time-balance', - version='0.1.1', + version='0.2.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 new file mode 100644 index 0000000..42c12f5 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,79 @@ +import unittest +import tempfile +import os +import io +import json +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 + + def tearDown(self): + os.chdir(self._orig_cwd) + ch.constants.DATA_FILE = self._orig_archivo + 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}} + } + # 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) + + 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}} + } + # 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) + + 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} + } + } + buf = io.StringIO() + with mock.patch('sys.stdout', buf): + ch.view_history(datos, 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) + 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) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_control_horas.py b/tests/test_control_horas.py deleted file mode 100644 index 6018002..0000000 --- a/tests/test_control_horas.py +++ /dev/null @@ -1,131 +0,0 @@ -import unittest -import tempfile -import os -import io -from unittest import mock - -import time_balance as ch - -class TestControlHoras(unittest.TestCase): - def setUp(self): - # Crear un directorio temporal y usarlo como cwd y como lugar del archivo de datos - self._orig_cwd = os.getcwd() - self._orig_archivo = getattr(ch, 'ARCHIVO_DATOS', None) - # Guardar y limpiar variable de entorno HISTORIAL_PATH para evitar que tests toquen rutas reales - self._orig_historial_path = os.environ.get('HISTORIAL_PATH') - os.environ.pop('HISTORIAL_PATH', None) - self.tmpdir = tempfile.TemporaryDirectory() - os.chdir(self.tmpdir.name) - # Archivo de datos en el tempdir - self.data_file = os.path.join(self.tmpdir.name, 'historial_horas.json') - ch.ARCHIVO_DATOS = self.data_file - - def tearDown(self): - # Restaurar cwd y ARCHIVO_DATOS - os.chdir(self._orig_cwd) - if self._orig_archivo is not None: - ch.ARCHIVO_DATOS = self._orig_archivo - # Restaurar variable de entorno original - 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_formatear_tiempo_positive(self): - self.assertEqual(ch.formatear_tiempo(125), "2h 5m") - - def test_formatear_tiempo_zero(self): - self.assertEqual(ch.formatear_tiempo(0), "0h 0m") - - def test_formatear_tiempo_negative(self): - self.assertEqual(ch.formatear_tiempo(-75), "-1h 15m") - - def test_calcular_saldo_total(self): - data = {"a":{"diferencia": 10}, "b":{"diferencia": -5}} - self.assertEqual(ch.calcular_saldo_total(data), 5) - - def test_cargar_no_file(self): - with tempfile.TemporaryDirectory() as d: - fake = os.path.join(d, "nope.json") - ch.ARCHIVO_DATOS = fake - self.assertEqual(ch.cargar_datos(), {}) - - def test_cargar_invalid_json(self): - with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: - f.write("{ invalid json") - fname = f.name - try: - ch.ARCHIVO_DATOS = fname - self.assertEqual(ch.cargar_datos(), {}) - finally: - if os.path.exists(fname): - os.remove(fname) - - def test_guardar_and_load_roundtrip(self): - with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: - fname = f.name - if os.path.exists(fname): - os.remove(fname) - try: - ch.ARCHIVO_DATOS = fname - data = {"2026-01-01":{"horas":8,"minutos":0,"diferencia":15}} - ch.guardar_datos(data) - loaded = ch.cargar_datos() - self.assertEqual(loaded, data) - finally: - if os.path.exists(fname): - os.remove(fname) - - def test_registrar_jornada_overwrite_cancel(self): - with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: - fname = f.name - if os.path.exists(fname): - os.remove(fname) - ch.ARCHIVO_DATOS = fname - datos = {"2026-01-01":{"horas":7,"minutos":0,"diferencia":-45}} - # inputs: date, confirmation 'n' (cancel) - with mock.patch('builtins.input', side_effect=["2026-01-01", "n"]): - ch.registrar_jornada(datos) - # should remain unchanged - self.assertEqual(datos["2026-01-01"]["horas"] , 7) - - def test_registrar_jornada_overwrite_confirm(self): - with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: - fname = f.name - if os.path.exists(fname): - os.remove(fname) - ch.ARCHIVO_DATOS = fname - datos = {"2026-01-01":{"horas":7,"minutos":0,"diferencia":-45}} - # inputs: date, confirmation 's', horas '8', minutos '0' - with mock.patch('builtins.input', side_effect=["2026-01-01", "s", "8", "0"]): - ch.registrar_jornada(datos) - # check updated - self.assertEqual(datos["2026-01-01"]["horas"], 8) - # and file persisted - loaded = ch.cargar_datos() - self.assertEqual(loaded["2026-01-01"]["horas"], 8) - - def test_ver_historial_output(self): - datos = { - "2026-01-01":{"horas":8,"minutos":0,"diferencia":15}, - "2026-01-02":{"horas":7,"minutos":0,"diferencia":-45}, - "2026-01-03":{"horas":9,"minutos":0,"diferencia":75}, - "2026-01-04":{"horas":6,"minutos":30,"diferencia":-75}, - "2026-01-05":{"horas":7,"minutos":45,"diferencia":0}, - "2026-01-06":{"horas":8,"minutos":30,"diferencia":45} - } - buf = io.StringIO() - with mock.patch('sys.stdout', buf): - ch.ver_historial(datos) - out = buf.getvalue() - # Should include header and five most recent dates (sorted reverse) - self.assertIn("--- Últimos 5 registros ---", out) - # most recent should be 2026-01-06 - self.assertIn("2026-01-06", out) - # count lines with dates (simple check) - count_dates = sum(1 for line in out.splitlines() if line.startswith("2026-")) - self.assertEqual(count_dates, 5) - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..26f6f6d --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,20 @@ +import unittest +import time_balance as ch + +class TestCore(unittest.TestCase): + def test_format_time_positive(self): + self.assertEqual(ch.format_time(125), "2h 5m") + + def test_format_time_zero(self): + self.assertEqual(ch.format_time(0), "0h 0m") + + def test_format_time_negative(self): + self.assertEqual(ch.format_time(-75), "-1h 15m") + + def test_calculate_total_balance(self): + # Updated key from 'diferencia' to 'difference' + data = {"a": {"difference": 10}, "b": {"difference": -5}} + self.assertEqual(ch.calculate_total_balance(data), 5) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_import_export.py b/tests/test_import_export.py deleted file mode 100644 index 1ef3a0e..0000000 --- a/tests/test_import_export.py +++ /dev/null @@ -1,99 +0,0 @@ -import unittest -import tempfile -import os -import json -import glob - -import time_balance as ch - -class TestImportExport(unittest.TestCase): - def setUp(self): - # directorio temporal por test - self.tmpdir = tempfile.TemporaryDirectory() - self.orig_archivo = getattr(ch, 'ARCHIVO_DATOS', None) - # Guardar y limpiar la variable de entorno para evitar flakes en CI - self._orig_historial_path = os.environ.get('HISTORIAL_PATH') - os.environ.pop('HISTORIAL_PATH', None) - self.dest_file = os.path.join(self.tmpdir.name, 'historial_horas.json') - ch.ARCHIVO_DATOS = self.dest_file - - def tearDown(self): - # restaurar - if self.orig_archivo is not None: - ch.ARCHIVO_DATOS = self.orig_archivo - # Restaurar variable de entorno - 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_exportar_historial_crea_archivo(self): - # Preparar datos en archivo destino - datos = {"2026-01-01": {"horas": 8, "minutos": 0, "diferencia": 15}} - ch.guardar_datos(datos) - - # Exportar a un archivo concreto - export_path = os.path.join(self.tmpdir.name, 'export.json') - written = ch.exportar_historial(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_importar_merge_prefiere_fuente(self): - # Destino: tiene una fecha A y B - destino = {"2026-01-01": {"horas": 7, "minutos": 0, "diferencia": -45}, - "2026-01-02": {"horas": 6, "minutos": 30, "diferencia": -75}} - ch.guardar_datos(destino) - - # Fuente: tiene fecha A (conflicto) and C (nuevo) - fuente = {"2026-01-01": {"horas": 9, "minutos": 0, "diferencia": 75}, - "2026-01-03": {"horas": 8, "minutos": 15, "diferencia": 30}} - src_path = os.path.join(self.tmpdir.name, 'fuente.json') - with open(src_path, 'w', encoding='utf-8') as f: - json.dump(fuente, f) - - # Importar en modo merge -> la fuente sobrescribe en conflicto - res = ch.importar_historial(src_path, modo='merge') - - # Comprobaciones - self.assertIn('2026-01-03', res) - self.assertEqual(res['2026-01-01']['horas'], 9) # valor de la fuente - # Y archivo destino refleja el merge - loaded = ch.cargar_datos() - self.assertEqual(loaded['2026-01-01']['horas'], 9) - self.assertIn('2026-01-02', loaded) - - def test_importar_overwrite_crea_backup_y_sobreescribe(self): - # Destino inicial - destino = {"2026-01-01": {"horas": 7, "minutos": 0, "diferencia": -45}} - ch.guardar_datos(destino) - - # Fuente nueva - fuente = {"2026-02-01": {"horas": 8, "minutos": 0, "diferencia": 15}} - src_path = os.path.join(self.tmpdir.name, 'fuente2.json') - with open(src_path, 'w', encoding='utf-8') as f: - json.dump(fuente, f) - - # Import overwrite - ch.importar_historial(src_path, modo='overwrite') - - # Now backup should exist in tmpdir (archivo.bak.*) - bak_pattern = self.dest_file + '.bak.*' - bak_files = glob.glob(bak_pattern) - self.assertTrue(len(bak_files) >= 1, f"No se encontró backup con patrón {bak_pattern}") - - # El archivo destino ahora debe ser igual a la fuente - loaded = ch.cargar_datos() - self.assertEqual(loaded, fuente) - - def test_importar_json_invalido_lanza(self): - 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.importar_historial(src_path, modo='overwrite') - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000..4f9c394 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,105 @@ +import unittest +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): + 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}} + } + ch.save_data(datos) + + export_path = os.path.join(self.tmpdir.name, 'export.json') + written = ch.export_history(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"}, + "records": { + "2026-04-01": {"hours": 9, "minutes": 0, "difference": 75}, + "2026-04-03": {"hours": 8, "minutes": 15, "difference": 30} + } + } + 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) + + loaded = ch.load_data() + self.assertEqual(loaded, fuente) + + def test_import_invalid_json_raises(self): + 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) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..b6088ae --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,76 @@ +import unittest +import tempfile +import os +import json +import time_balance as ch + +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'] + + 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"], {}) + + 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_save_and_load_roundtrip(self): + + 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_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)) + + # 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'] + + # 3. Default + self.assertEqual(ch._resolve_file_path(), os.path.abspath(self.data_file)) + +if __name__ == "__main__": + unittest.main() diff --git a/time_balance/__init__.py b/time_balance/__init__.py index 8467e4c..453f9f1 100644 --- a/time_balance/__init__.py +++ b/time_balance/__init__.py @@ -1,413 +1,53 @@ -import os -import json -from datetime import datetime, date -import tempfile -import shutil -import errno - -# --- CONFIGURACIÓN --- -HORAS_BASE = 7 -MINUTOS_BASE = 45 -ARCHIVO_DATOS = "historial_horas.json" -ENV_HISTORIAL = "HISTORIAL_PATH" - - -def _resolver_archivo(archivo_path=None): - """Resuelve la ruta final del archivo de historial. - Prioridad: argumento > VARIABLE DE ENTORNO > ARCHIVO_DATOS (CWD) - """ - if archivo_path: - ruta = archivo_path - elif ENV_HISTORIAL in os.environ and os.environ[ENV_HISTORIAL].strip(): - ruta = os.environ[ENV_HISTORIAL] - else: - ruta = ARCHIVO_DATOS - - ruta = os.path.expanduser(ruta) - return os.path.abspath(ruta) - - -def cargar_datos(archivo_path=None): - """Carga el historial de días desde el archivo JSON.""" - archivo = _resolver_archivo(archivo_path) - if not os.path.exists(archivo): - return {} # Retorna un diccionario vacío si no hay archivo - try: - with open(archivo, "r", encoding="utf-8") as f: - return json.load(f) - except (ValueError, json.JSONDecodeError): - return {} - - -def guardar_datos(datos, archivo_path=None): - """Guarda el historial completo en el archivo usando escritura atómica.""" - archivo = _resolver_archivo(archivo_path) - dir_dest = os.path.dirname(archivo) - if dir_dest and not os.path.exists(dir_dest): - os.makedirs(dir_dest, exist_ok=True) - - # Serializamos y escribimos a fichero temporal antes de reemplazar - contenido = json.dumps(datos, indent=4, ensure_ascii=False) - # Aseguramos que el temporal se cree en el mismo directorio de destino - fd, ruta_temp = tempfile.mkstemp(prefix="historial_", suffix=".json", dir=dir_dest or ".") - try: - # Escribimos y forzamos a disco el temporal antes de intentar el replace - with os.fdopen(fd, 'w', encoding='utf-8') as tmpf: - tmpf.write(contenido) - try: - tmpf.flush() - os.fsync(tmpf.fileno()) - except Exception: - # Si fsync no está soportado en la plataforma, continuamos igualmente - pass - - # Intentamos reemplazo atómico - try: - os.replace(ruta_temp, archivo) - # Intentamos fsync al directorio destino para asegurar persistencia del rename - try: - dirfd = os.open(os.path.dirname(archivo) or '.', os.O_RDONLY) - try: - os.fsync(dirfd) - finally: - os.close(dirfd) - except Exception: - pass - except OSError as e: - # En sistemas con filesystems diferentes, os.replace puede fallar con EXDEV. - # Como fallback hacemos copy + fsync del destino. - if getattr(e, 'errno', None) == errno.EXDEV: - shutil.copy2(ruta_temp, archivo) - try: - # Forzar fsync en el archivo copiado - with open(archivo, 'rb') as f: - try: - os.fsync(f.fileno()) - except Exception: - pass - except Exception: - pass - try: - os.remove(ruta_temp) - except OSError: - pass - else: - raise - finally: - # Limpieza best-effort del temporal si quedara - if 'ruta_temp' in locals() and os.path.exists(ruta_temp): - try: - os.remove(ruta_temp) - except OSError: - pass - - -def formatear_tiempo(minutos_totales): - """Convierte minutos a formato legible +/- Xh Ym.""" - signo = "" - if minutos_totales < 0: - signo = "-" - minutos_totales = abs(minutos_totales) - - horas = minutos_totales // 60 - minutos = minutos_totales % 60 - return f"{signo}{horas}h {minutos}m" - - -def calcular_saldo_total(datos): - """Recorre todo el historial y suma las diferencias.""" - saldo = 0 - for fecha, info in datos.items(): - saldo += info['diferencia'] - return saldo - - -def solicitar_fecha(): - """Pide la fecha o usa la de hoy por defecto.""" - hoy = date.today().strftime("%Y-%m-%d") - print(f"\nFecha del registro (Enter para usar HOY: {hoy})") - fecha_input = input("O introduce fecha (YYYY-MM-DD): ").strip() - - if not fecha_input: - return hoy - - # Validar formato simple - try: - datetime.strptime(fecha_input, "%Y-%m-%d") - return fecha_input - except ValueError: - print("❌ Formato de fecha incorrecto. Usando fecha de hoy.") - return hoy - - -def registrar_jornada(datos, archivo_path=None): - fecha = solicitar_fecha() - - # --- CONTROL DE DUPLICADOS --- - if fecha in datos: - print(f"\n⚠️ ATENCIÓN: Ya existe un registro para el día {fecha}.") - print(f" Registrado anteriormente: {datos[fecha]['horas']}h {datos[fecha]['minutos']}m") - confirmacion = input("¿Quieres SOBREESCRIBIRLO? (s/n): ").lower() - if confirmacion != 's': - print("Operación cancelada.") - return - - print(f"--- Introduciendo datos para: {fecha} ---") - try: - horas = int(input("Horas trabajadas: ") or 0) - minutos = int(input("Minutos trabajados: ") or 0) - except ValueError: - print("❌ Error: Introduce números enteros.") - return - - # Cálculos - minutos_objetivo = (HORAS_BASE * 60) + MINUTOS_BASE - minutos_trabajados = (horas * 60) + minutos - diferencia = minutos_trabajados - minutos_objetivo - - # Guardamos en el diccionario - datos[fecha] = { - "horas": horas, - "minutos": minutos, - "diferencia": diferencia - } - - guardar_datos(datos, archivo_path) - print(f"\n✅ Registro guardado para el {fecha}.") - print(f" Diferencia del día: {formatear_tiempo(diferencia)}") - - -def ver_historial(datos): - """Muestra los últimos registros.""" - print("\n--- Últimos 5 registros ---") - # Ordenamos las fechas de más reciente a más antigua - fechas_ordenadas = sorted(datos.keys(), reverse=True)[:5] - if not fechas_ordenadas: - print("No hay registros.") - - for fecha in fechas_ordenadas: - info = datos[fecha] - diff_fmt = formatear_tiempo(info['diferencia']) - # Añadimos un '+' visual si es positivo para que se vea mejor - if info['diferencia'] > 0: - diff_fmt = "+" + diff_fmt - - print(f"{fecha} | Trab: {info['horas']}h {info['minutos']}m | Saldo: {diff_fmt}") - - -# ----------------- Funciones de export/import ----------------- - -def _validar_historial(datos): - """Valida la estructura básica del historial cargado """ - if not isinstance(datos, dict): - raise ValueError("Historial debe ser un objeto/diccionario JSON") - for fecha, info in datos.items(): - if not isinstance(fecha, str): - raise ValueError("Las claves del historial deben ser strings con formato YYYY-MM-DD") - - try: - datetime.strptime(fecha, "%Y-%m-%d") - - except ValueError: - raise ValueError(f"Clave de fecha inválida: {fecha}. Debe tener formato YYYY-MM-DD") - - if not isinstance(info, dict): - raise ValueError(f"Entrada para {fecha} debe ser un objeto con 'horas','minutos','diferencia'.") - - for key in ('horas', 'minutos', 'diferencia'): - if key not in info: - raise ValueError(f"Entrada para {fecha} falta clave '{key}'") - if not isinstance(info[key], int): - raise ValueError(f"'{key}' en {fecha} debe ser un entero") - - -def exportar_historial(ruta_destino, archivo_path=None): - """Exporta el historial actual a la ruta indicada. - - Args: - ruta_destino (str): Ruta de archivo donde guardar el JSON exportado. - archivo_path (str|None): Ruta del archivo de datos actual (opcional). - - Returns: - str: Ruta absoluta donde se escribió el archivo. - """ - datos = cargar_datos(archivo_path) - destino = os.path.expanduser(ruta_destino) - destino = os.path.abspath(destino) - dir_dest = os.path.dirname(destino) - if dir_dest and not os.path.exists(dir_dest): - os.makedirs(dir_dest, exist_ok=True) - - # Escribimos de forma atómica - contenido = json.dumps(datos, indent=4, ensure_ascii=False) - # Crear el temporal en el mismo directorio de destino para evitar EXDEV - fd, ruta_temp = tempfile.mkstemp(prefix="export_", suffix=".json", dir=dir_dest or ".") - try: - # Escribimos y sincronizamos el temporal antes del rename para mayor robustez - with os.fdopen(fd, 'w', encoding='utf-8') as tmpf: - tmpf.write(contenido) - try: - tmpf.flush() - os.fsync(tmpf.fileno()) - except Exception: - # Si fsync no está soportado en la plataforma, continuamos igualmente - pass - - # Intentamos reemplazo atómico - try: - os.replace(ruta_temp, destino) - # Intentamos fsync al directorio destino para asegurar persistencia del rename - try: - dirfd = os.open(os.path.dirname(destino) or '.', os.O_RDONLY) - try: - os.fsync(dirfd) - finally: - os.close(dirfd) - except Exception: - pass - except OSError as e: - # En sistemas con filesystems diferentes, os.replace puede fallar con EXDEV. - # Como fallback hacemos copy + fsync del destino. - if getattr(e, 'errno', None) == errno.EXDEV: - shutil.copy2(ruta_temp, destino) - try: - # Forzar fsync en el archivo copiado - with open(destino, 'rb') as f: - try: - os.fsync(f.fileno()) - except Exception: - pass - except Exception: - pass - try: - os.remove(ruta_temp) - except OSError: - pass - else: - raise - finally: - # Limpieza best-effort del temporal si quedara - if 'ruta_temp' in locals() and os.path.exists(ruta_temp): - try: - os.remove(ruta_temp) - except OSError: - pass - - return destino - - -def _crear_backup(archivo): - """Crea un backup con timestamp del archivo dado si existe.""" - if not os.path.exists(archivo): - return None - ts = datetime.now().strftime('%Y%m%dT%H%M%S') - backup = f"{archivo}.bak.{ts}" - shutil.copy2(archivo, backup) - # También mantenemos/actualizamos archivo.bak simple - try: - shutil.copy2(archivo, f"{archivo}.bak") - except Exception: - # Error al crear/actualizar la copia secundaria .bak; no es crítico, se ignora. - pass - return backup - - -def importar_historial(ruta_fuente, modo='merge', archivo_path=None): - """Importa un historial desde un archivo externo. - - Args: - ruta_fuente (str): Ruta del archivo JSON a importar. - modo (str): 'merge' (por defecto) o 'overwrite'. En 'merge', las entradas del archivo - importado sobrescriben en caso de conflicto. En 'overwrite' se reemplaza - totalmente el historial (se crea backup antes). - archivo_path (str|None): Ruta del archivo de datos destino (opcional). - - Returns: - dict: El historial resultante cargado y guardado en destino. - """ - fuente = os.path.expanduser(ruta_fuente) - fuente = os.path.abspath(fuente) - if not os.path.exists(fuente): - raise FileNotFoundError(f"Archivo de importación no existe: {fuente}") - - try: - with open(fuente, 'r', encoding='utf-8') as f: - datos_fuente = json.load(f) - except json.JSONDecodeError as e: - raise ValueError(f"JSON inválido en archivo de importación: {e}") - - _validar_historial(datos_fuente) - - destino = _resolver_archivo(archivo_path) - if modo == 'overwrite': - # Backup del destino - _crear_backup(destino) - # Guardamos el archivo importado como el nuevo historial - guardar_datos(datos_fuente, destino) - return datos_fuente - elif modo == 'merge': - # Creamos backup por precaución - _crear_backup(destino) - datos_destino = cargar_datos(destino) - # Merge: la fuente sobrescribe en caso de conflicto - datos_destino.update(datos_fuente) - guardar_datos(datos_destino, destino) - return datos_destino - else: - raise ValueError("Modo desconocido. Usa 'merge' o 'overwrite'.") - - -# ----------------- CLI / entry point ----------------- - -def main(): - # Interfaz interactiva única (sin argparse ni subcomandos). - while True: - datos = cargar_datos() - saldo_total = calcular_saldo_total(datos) - - os.system('cls' if os.name == 'nt' else 'clear') - - print("\n" + "="*50) - print(f" SALDO TOTAL ACUMULADO: {formatear_tiempo(saldo_total)}") - print(f" (Base diaria: {HORAS_BASE}h {MINUTOS_BASE}m)") - print("="*50) - - print("\nOpciones:") - print("1. Registrar jornada (o corregir día)") - print("2. Ver últimos registros") - print("3. Exportar historial a archivo") - print("4. Importar historial desde archivo") - print("5. Salir") - - opcion = input("\nElige opción: ") - - if opcion == "1": - registrar_jornada(datos) - input("\nPresiona ENTER para continuar...") - elif opcion == "2": - ver_historial(datos) - input("\nPresiona ENTER para continuar...") - elif opcion == "3": - ruta = input("Ruta destino (ej: /ruta/mi_export.json): ") - try: - destino = exportar_historial(ruta) - print(f"\n✅ Exportado en: {destino}") - except Exception as e: - print(f"Error al exportar: {e}") - input("\nPresiona ENTER para continuar...") - elif opcion == "4": - ruta = input("Ruta fuente a importar: ") - modo = input("Modo (merge/overwrite) [merge]: ") or 'merge' - try: - res = importar_historial(ruta, modo=modo) - print(f"\n✅ Importación completada. Entradas totales ahora: {len(res)}") - except Exception as e: - print(f"Error al importar: {e}") - input("\nPresiona ENTER para continuar...") - elif opcion == "5": - print("¡Hasta mañana!") - break - - -if __name__ == "__main__": - main() +from .constants import ( + VERSION, + BASE_HOURS, + BASE_MINUTES, + DATA_FILE, + ENV_HISTORIAL, + MODE_MERGE, + MODE_OVERWRITE, + VALID_IMPORT_MODES +) +from .core import ( + format_time, + calculate_total_balance +) +from .storage import ( + load_data, + save_data, + _resolve_file_path, + _create_backup +) +from .io import ( + export_history, + import_history +) +from .cli import ( + request_date, + register_day, + view_history, + main +) + +__version__ = VERSION + +__all__ = [ + 'VERSION', + 'BASE_HOURS', + 'BASE_MINUTES', + 'DATA_FILE', + 'ENV_HISTORIAL', + 'MODE_MERGE', + 'MODE_OVERWRITE', + 'VALID_IMPORT_MODES', + 'format_time', + 'calculate_total_balance', + 'load_data', + 'save_data', + 'export_history', + 'import_history', + 'request_date', + 'register_day', + 'view_history', + 'main' +] diff --git a/time_balance/__main__.py b/time_balance/__main__.py new file mode 100644 index 0000000..9ae637f --- /dev/null +++ b/time_balance/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() diff --git a/time_balance/cli.py b/time_balance/cli.py new file mode 100644 index 0000000..44c9699 --- /dev/null +++ b/time_balance/cli.py @@ -0,0 +1,227 @@ +import os +import argparse +from datetime import date, datetime +from . import constants +from . import core +from . import storage +from . import io +from .i18n import translate, get_system_language + + +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() + + if not date_input: + return today + + try: + datetime.strptime(date_input, "%Y-%m-%d") + return date_input + except ValueError: + print(translate("invalid_date", lang=lang)) + 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.""" + work_date = request_date(lang=lang) + metadata = data["metadata"] + records = data["records"] + + if work_date in records: + 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'])) + confirmation = input(translate("overwrite_confirm", lang=lang)).lower() + if confirmation not in ('s', 'y'): + print(translate("op_cancelled", lang=lang)) + return + + 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) + except ValueError: + print(translate("error_integers", lang=lang)) + return + + base_minutes = (metadata["hours_base"] * 60) + metadata["minutes_base"] + worked_minutes = (hours * 60) + minutes + difference = worked_minutes - base_minutes + + records[work_date] = { + "hours": hours, + "minutes": minutes, + "difference": 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"] + + 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: + print(translate("no_records", lang=lang)) + + 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: + time_fmt = "+" + time_fmt + + print(f"{work_date} | {work_label}: {info['hours']}h {info['minutes']}m | {bal_label}: {time_fmt}") + + +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() + + 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('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("="*50) + + 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)) + + option = input(f"\n{translate('choose_option', lang=lang)}") + + if option == "1": + register_day(data, lang=lang) + input(translate('press_enter', lang=lang)) + elif option == "2": + view_history(data, 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)) + 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)) + 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)) + elif option == "6": + print(translate('exit_msg', lang=lang)) + break + + +def main(): + parser = argparse.ArgumentParser( + description="time-balance: A simple tool to track your hour balance." + ) + 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." + ) + + args = parser.parse_args() + + if args.version: + 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() + + if args.status: + total_balance = core.calculate_total_balance(data["records"]) + print(translate("status_project", lang=lang, name=data['metadata']['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) + return + + interactive_menu() diff --git a/time_balance/constants.py b/time_balance/constants.py new file mode 100644 index 0000000..13b6f4e --- /dev/null +++ b/time_balance/constants.py @@ -0,0 +1,15 @@ +VERSION = "0.2.0" + +# --- TIME CONFIGURATION --- +BASE_HOURS = 7 +BASE_MINUTES = 45 + +# --- PERSISTENCE --- +DATA_FILE = "historial_hours.json" +ENV_HISTORIAL = "HISTORIAL_PATH" + +# --- IMPORT MODES --- +MODE_MERGE = "merge" +MODE_OVERWRITE = "overwrite" + +VALID_IMPORT_MODES = [MODE_MERGE, MODE_OVERWRITE] diff --git a/time_balance/core.py b/time_balance/core.py new file mode 100644 index 0000000..f20df0e --- /dev/null +++ b/time_balance/core.py @@ -0,0 +1,19 @@ +def format_time(total_minutes): + """Converts minutes to readable format +/- Xh Ym.""" + sign = "" + if total_minutes < 0: + sign = "-" + total_minutes = abs(total_minutes) + + hours = total_minutes // 60 + minutes = total_minutes % 60 + return f"{sign}{hours}h {minutes}m" + + +def calculate_total_balance(records): + """Iterates through all records and sums differences.""" + balance = 0 + for _date, info in records.items(): + # Using English key 'difference' + balance += info['difference'] + return balance diff --git a/time_balance/i18n.py b/time_balance/i18n.py new file mode 100644 index 0000000..6b535df --- /dev/null +++ b/time_balance/i18n.py @@ -0,0 +1,123 @@ +import locale + +# --- TRANSLATIONS --- + +STRINGS = { + "en": { + "menu_title": "CONTROL CENTER", + "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. Configure project (name/hours/lang)", + "option_4": "4. Export history to file", + "option_5": "5. Import history from file", + "option_6": "6. Exit", + "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): ", + "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): ", + "op_cancelled": "Operation cancelled.", + "input_header": "--- Entering data for: {date} ---", + "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}", + "recent_records_header": "\n--- Last {limit} records ---", + "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}]: ", + "invalid_language": "❌ Invalid language. Please use 'en', 'es', or 'auto'.", + "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_success": "\n✅ Import completed. Total entries now: {count}", + "import_error": "Error importing: {error}", + "status_project": "Project: {name}", + "status_balance": "Accumulated balance: {balance}", + "work_label": "Work", + "balance_short_label": "Bal" + }, + "es": { + "menu_title": "CENTRO DE CONTROL", + "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. Configurar proyecto (nombre/jornada/idioma)", + "option_4": "4. Exportar historial a archivo", + "option_5": "5. Importar historial desde archivo", + "option_6": "6. Salir", + "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): ", + "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): ", + "op_cancelled": "Operación cancelada.", + "input_header": "--- Introduciendo datos para: {date} ---", + "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}", + "recent_records_header": "\n--- Últimos {limit} registros ---", + "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}]: ", + "invalid_language": "❌ Idioma inválido. Por favor, usa 'en', 'es' o 'auto'.", + "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_success": "\n✅ Importación completada. Entradas totales ahora: {count}", + "import_error": "Error al importar: {error}", + "status_project": "Proyecto: {name}", + "status_balance": "Saldo acumulado: {balance}", + "work_label": "Trabajo", + "balance_short_label": "Bal" + } +} + +def get_system_language(): + """Detects system language using modern locale API, defaults to English.""" + try: + # Preferred modern way to get language code + lang_code, _ = locale.getlocale() + if not lang_code: + # Fallback to preferred encoding logic or environment + lang_code = locale.getdefaultlocale()[0] + + if lang_code and lang_code.startswith("es"): + return "es" + except Exception: + pass + return "en" + +def translate(key, lang="en", **kwargs): + """Returns the translated string for a given key.""" + language_dict = STRINGS.get(lang, STRINGS["en"]) + template = language_dict.get(key, STRINGS["en"].get(key, key)) + return template.format(**kwargs) diff --git a/time_balance/io.py b/time_balance/io.py new file mode 100644 index 0000000..4ed8aff --- /dev/null +++ b/time_balance/io.py @@ -0,0 +1,148 @@ +import os +import json +import tempfile +import shutil +import errno +from datetime import datetime +from . import storage +from . import constants + + +def validate_history(data): + """Validates basic history structure and metadata types.""" + if not isinstance(data, dict): + raise ValueError("History must be a JSON object/dict") + + if "metadata" not in data or "records" not in data: + raise ValueError("History missing required structured keys (metadata, records)") + + # Validate metadata + metadata = data["metadata"] + if not isinstance(metadata, dict): + raise ValueError("Metadata must be an object") + + required_meta = ("project_name", "hours_base", "minutes_base") + for key in required_meta: + if key not in metadata: + raise ValueError(f"Metadata missing required key: {key}") + + # Type and range validation for base time + hours_base = metadata["hours_base"] + minutes_base = metadata["minutes_base"] + + if not isinstance(hours_base, int) or isinstance(hours_base, bool): + raise ValueError("Metadata 'hours_base' must be an integer") + if hours_base < 0: + raise ValueError("Metadata 'hours_base' must be greater than or equal to 0") + + if not isinstance(minutes_base, int) or isinstance(minutes_base, bool): + raise ValueError("Metadata 'minutes_base' must be an integer") + if minutes_base < 0 or minutes_base > 59: + raise ValueError("Metadata 'minutes_base' must be between 0 and 59") + + # Validate records + records = data["records"] + if not isinstance(records, dict): + raise ValueError("Records must be an object") + for date_key, info in records.items(): + _validate_record_entry(date_key, info) + + +def _validate_record_entry(date_key, info): + """Validates a single record entry.""" + if not isinstance(date_key, str): + raise ValueError("History keys must be strings (YYYY-MM-DD)") + + try: + datetime.strptime(date_key, "%Y-%m-%d") + except ValueError: + raise ValueError(f"Invalid date key: {date_key}. Must be YYYY-MM-DD") + + if not isinstance(info, dict): + raise ValueError(f"Entry for {date_key} must be an object with hour/minute data.") + + required_keys = ('hours', 'minutes', 'difference') + for key in required_keys: + if key not in info: + raise ValueError(f"Entry for {date_key} is missing required key: {key}") + if not isinstance(info[key], int) or isinstance(info[key], bool): + 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) + dest = os.path.expanduser(dest_path) + dest = os.path.abspath(dest) + dest_dir = os.path.dirname(dest) 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="export_", 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 + + try: + os.replace(temp_path, dest) + except OSError as error: + if getattr(error, 'errno', None) == errno.EXDEV: + shutil.copy2(temp_path, dest) + os.remove(temp_path) + else: + raise + + # Best-effort directory fsync + try: + dir_fd = os.open(dest_dir, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + except (AttributeError, OSError): + pass + + finally: + if 'temp_path' in locals() and os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + + return dest + + +def import_history(source_path, mode=constants.MODE_MERGE, file_path=None): + """Imports history from external file.""" + 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}") + + 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}") + + 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}.") diff --git a/time_balance/storage.py b/time_balance/storage.py new file mode 100644 index 0000000..db12888 --- /dev/null +++ b/time_balance/storage.py @@ -0,0 +1,133 @@ +import os +import json +import tempfile +import shutil +import errno +from datetime import datetime +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.""" + return { + "metadata": { + "project_name": "General", + "hours_base": constants.BASE_HOURS, + "minutes_base": constants.BASE_MINUTES, + "version": "1.0", + "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 + } + + +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: + pass + return backup