Compare commits

..

10 Commits

Author SHA1 Message Date
a3dcdb8577 prueba de proyecto 2026-03-25 00:29:33 -03:00
aedc647fff Document task commands in README 2026-03-25 00:27:25 -03:00
bd48122db2 Add task management commands to CLI
Implement task add and task move subcommands for managing tasks in TASKS.md.

task add <slug> <title> [--section <section>]:
- Adds a task to the specified section (default: Inbox)
- Valid sections: Inbox, Proximo, En curso, Bloqueado, En espera, Hecho
- Creates section if it doesn't exist

task move <slug> <task_title> <to_section>:
- Searches for task by title across all sections
- Moves task to destination section
- Marks task as completed ([x]) when moved to Hecho
- Updates checkbox state based on destination

Includes parsing and building functions for TASKS.md with:
- Section normalization (English to Spanish names)
- Merge support for duplicate normalized sections
- Standard section ordering

Uses app.add_typer to register task subcommand group.
2026-03-25 00:26:37 -03:00
4212a17e4b Add comprehensive .gitignore for Python project 2026-03-25 00:08:45 -03:00
cc523607d1 Fix pyproject.toml package discovery to avoid flat-layout conflict 2026-03-24 23:34:05 -03:00
b4593c69af Add detailed installation guide to README 2026-03-24 23:29:28 -03:00
4d99213d75 Update README.md with MVP-1 documentation 2026-03-23 17:09:44 -03:00
2735562b65 Add comprehensive test suite for MVP-1 Personal Tracker CLI
Implements 72 tests covering:
- Model tests (Project, Session, Note, Change)
- ProjectService tests (create, get, list, ensure structure)
- SessionService tests (active session management)
- FileStorage tests (read/write operations)
- Complete flow tests (init -> start -> note -> stop -> show)
- Note consolidation tests

Uses pytest with tmp_path fixtures for isolated testing.
2026-03-23 09:40:46 -03:00
4e67062c99 Add demo project for Personal Tracker MVP-1
Create example project structure in examples/demo-project/ with:
- Project metadata in meta/project.yaml
- README, LOG, CHANGELOG and TASKS documentation
- Two detailed session files showing real work examples
- Empty docs/ and assets/ directories

The demo project serves as a reference implementation showing
how to use the Personal Tracker CLI effectively.
2026-03-23 09:05:10 -03:00
b36b60353d Implement complete CLI commands for MVP-1 Personal Tracker
- Refactored CLI commands from nested Typer subapps to direct command functions
- Fixed main.py to use app.command() instead of app.add_typer_command()
- Fixed project_service.py to properly load projects from YAML
- Fixed file_storage.py to save session JSON files alongside markdown
- Added missing methods: write_file, read_file, extract_autogen_section, get_recent_sessions
- Fixed root_path and repo_path to use strings instead of Path objects
2026-03-23 09:02:21 -03:00
35 changed files with 2892 additions and 67 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(python3:*)",
"Bash(python:*)"
]
}
}

131
.gitignore vendored Normal file
View File

@@ -0,0 +1,131 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Projects data (user-generated)
projects/*.yaml
projects/*.md
!projects/.gitkeep
# Active session file
*.active_session.json
# Temp files
*.tmp
*.bak

21
CLAUDE.md Normal file
View File

@@ -0,0 +1,21 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a personal tracker project. The codebase is currently empty (initial commit only) — no build configuration, tests, or source files exist yet.
## Development Commands
Commands will be added once the project has a build system and test setup.
## Resumen
- Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-HHMM-resumen.md en la carpeta resumen.
- Si no existe crea una carpeta resumen en la raiz del proyecto.
- Crearemos resumenes de forma incremental y el primero debe contener todo lo existente hasta el momento.
- El archivo debe ser creado con el horario local.
## Commit
- evitar agregar lo siguiente: Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

190
README.md
View File

@@ -1,2 +1,190 @@
# tracker # Personal Tracker
Herramienta CLI para seguimiento de proyectos personales con foco en continuidad entre sesiones.
## Características
- **Persistencia simple**: Archivos Markdown y YAML legibles
- **Sin dependencias cloud**: Todo funciona offline
- **Kanban liviano**: TASKS.md con estados (Inbox, Next, Active, Blocked, Waiting, Done)
- **Bitácora por sesiones**: LOG.md y archivos de sesión detallados
- **Changelog**: Registro de cambios relevantes
- **CLI intuitiva**: Comandos para iniciar, pausar y documentar trabajo
## Stack
- Python 3.11+
- [Typer](https://typer.tiangolo.com/) - CLI
- [PyYAML](https://pyyaml.org/) - Metadatos
- [Jinja2](https://jinja.palletsprojects.com/) - Plantillas
- [Pydantic](https://docs.pydantic.dev/) - Modelos
## Instalación
### Requisitos Previos
- Python 3.11 o superior
- pip (gestor de paquetes Python)
### Método 1: Instalación con pip
```bash
# Clonar el repositorio
git clone <url-del-repositorio>
cd tracker
# Instalar en modo desarrollo
pip install -e .
# Verificar instalación
tracker --help
```
### Método 2: Usando uv (recomendado)
```bash
# Instalar con uv
uv pip install -e .
# O desde el código fuente
uv pip install -e .
```
### Verificar Instalación
```bash
tracker --help
```
Deberías ver la lista de comandos disponibles.
### Desinstalación
```bash
pip uninstall tracker
```
## Comandos
| Comando | Descripción |
|---------|-------------|
| `tracker init-project <slug>` | Crear nuevo proyecto |
| `tracker list` | Listar todos los proyectos |
| `tracker show <slug>` | Ver detalles de un proyecto |
| `tracker start <slug>` | Iniciar sesión de trabajo |
| `tracker note <text>` | Agregar nota a la sesión activa |
| `tracker stop <slug>` | Finalizar sesión y generar resumen |
| `tracker change <slug>` | Agregar entrada al changelog |
| `tracker next <slug>` | Sugerir próximos pasos |
| `tracker review` | Vista general de todos los proyectos |
| `tracker task add <slug>` | Agregar tarea a una sección |
| `tracker task move <slug>` | Mover tarea a otra sección |
## Estructura de un Proyecto
```
projects/<slug>/
├── README.md # Documentación principal
├── LOG.md # Bitácora de sesiones (append-only)
├── CHANGELOG.md # Registro de cambios
├── TASKS.md # Kanban liviano
├── sessions/ # Sesiones detalladas
├── docs/ # Documentación técnica
├── assets/ # Recursos
└── meta/
└── project.yaml # Metadatos
```
## Flujo de Uso
```bash
# 1. Crear proyecto
tracker init-project mi-proyecto --name "Mi Proyecto" --type code
# 2. Iniciar sesión
tracker start mi-proyecto --objective "Implementar feature X"
# 3. Agregar notas durante el trabajo
tracker note "Implementé la función base" --type work
tracker note "Decidí usar PostgreSQL en vez de SQLite" --type decision
# 4. Finalizar sesión
tracker stop mi-proyecto
# 5. Ver estado
tracker show mi-proyecto
tracker list
```
## Tipos de Nota
- `work` - Trabajo realizado
- `change` - Cambio realizado
- `blocker` - Bloqueo encontrado
- `decision` - Decisión tomada
- `idea` - Idea o pensamiento
- `reference` - Referencia o link
## Gestión de Tareas
```bash
# Agregar tarea a Inbox (por defecto)
tracker task add mi-proyecto "Revisar documentación de API"
# Agregar tarea a una sección específica
tracker task add mi-proyecto "Bug fix urgente" --section "Bloqueado"
# Mover tarea a otra sección
tracker task move mi-proyecto "Bug fix urgente" "En curso"
# Marcar tarea como completada (mover a Hecho)
tracker task move mi-proyecto "Bug fix urgente" "Hecho"
```
**Secciones disponibles:** Inbox, Próximo, En curso, Bloqueado, En espera, Hecho
## Estados de Proyecto
- `inbox` - Recibido/revisar
- `next` - Próximo a trabajar
- `active` - En desarrollo activo
- `blocked` - Bloqueado
- `waiting` - En espera
- `done` - Completado
- `archived` - Archivado
## Tipos de Proyecto
- `code` - Desarrollo de software
- `homelab` - Infraestructura personal
- `automation` - Automatización
- `agent` - Agentes/IA
- `research` - Investigación
- `misc` - Misceláneo
## Proyecto Demo
Ver `examples/demo-project/` para un proyecto de ejemplo completo.
## Tests
```bash
pytest tests/ -v
```
## Desarrollo
```bash
# Estructura del paquete
tracker/
├── cli/ # Comandos CLI
├── models/ # Modelos de datos
├── services/ # Lógica de negocio
├── storage/ # Persistencia archivos
├── utils/ # Utilidades
└── templates/ # Plantillas
```
## Licencia
MIT

246
backlog/mvp-1.md Normal file
View File

@@ -0,0 +1,246 @@
Construye un MVP v1 de un sistema local de seguimiento de proyectos personales.
Objetivo:
Crear una herramienta CLI que opere sobre archivos Markdown y YAML para ayudar a seguir proyectos personales con foco en continuidad entre sesiones. El sistema combina Kanban liviano, documentación viva, changelog y bitácora técnica por sesiones.
Restricciones obligatorias:
- usar persistencia en archivos legibles
- Markdown como formato principal
- YAML/JSON simple para metadatos
- sin base de datos
- sin interfaz web
- sin dependencias cloud
- todo debe seguir siendo usable manualmente aunque la CLI no exista
- una sola sesión activa a la vez en v1
- LOG.md append-only
- README.md solo puede modificarse en secciones autogeneradas delimitadas por marcadores AUTOGEN
Stack preferido:
- Python 3.11+
- Typer
- Jinja2
- PyYAML
- GitPython opcional
Estructura del repositorio esperada:
- README.md principal
- pyproject.toml
- tracker.yaml
- paquete tracker/ con cli, services, models, storage, utils y templates
- carpeta projects/
- carpeta tests/
- carpeta examples/
Cada proyecto debe generarse así:
projects/<slug>/
README.md
LOG.md
CHANGELOG.md
TASKS.md
sessions/
docs/
assets/
meta/project.yaml
Comandos mínimos a implementar:
- tracker init-project
- tracker list
- tracker show <slug>
- tracker start <slug>
- tracker note <text>
- tracker stop <slug>
- tracker change <slug>
- tracker next <slug>
- tracker review
Comportamiento requerido:
1. init-project
- crea estructura de carpetas
- genera README.md, LOG.md, CHANGELOG.md, TASKS.md y meta/project.yaml desde plantillas
- acepta name, type, tags, repo-path y description
2. list
- muestra nombre, slug, estado, última sesión y próximo paso si existe
3. show <slug>
- muestra estado actual, contexto, último resumen, bloqueos, próximos pasos y última actividad
4. start <slug>
- valida que no exista otra sesión activa
- registra hora de inicio
- crea sesión activa en un archivo tipo .active_session.json
- puede aceptar objective
- muestra contexto reciente
5. note <text>
- agrega nota a la sesión activa
- soporta tipos: work, change, blocker, decision, idea, reference
6. stop <slug>
- registra fin
- calcula duración
- consolida notas
- genera resumen heurístico
- sugiere próximos pasos
- crea archivo detallado en sessions/YYYY-MM-DD_HHMM.md
- actualiza LOG.md
- actualiza README.md en bloques AUTOGEN
- opcionalmente actualiza CHANGELOG.md
- limpia sesión activa
7. change <slug>
- añade una entrada manual al CHANGELOG.md
8. next <slug>
- sugiere próximos pasos por reglas simples, sin IA obligatoria
9. review
- muestra proyectos activos, últimas sesiones, bloqueos abiertos y proyectos sin actividad reciente
Modelo mínimo:
Proyecto:
- id
- name
- slug
- description
- type
- status
- tags
- root_path
- repo_path
- created_at
- updated_at
- last_session_at
Sesión:
- id
- project_slug
- started_at
- ended_at
- duration_minutes
- objective
- summary
- work_done
- changes
- decisions
- blockers
- next_steps
- references
- raw_notes
Cambio:
- date
- type
- title
- impact
- references
Valores sugeridos de type:
- code
- homelab
- automation
- agent
- research
- misc
Valores sugeridos de status:
- inbox
- next
- active
- blocked
- waiting
- done
- archived
README.md del proyecto debe incluir:
- nombre
- descripción
- objetivo
- estado actual
- contexto actual
- stack / herramientas
- arquitectura breve
- decisiones técnicas
- riesgos / bloqueos
- próximos pasos
- últimas sesiones
Usa bloques delimitados, por ejemplo:
<!-- AUTOGEN:STATUS_START -->
...
<!-- AUTOGEN:STATUS_END -->
LOG.md debe ser append-only y usar entradas como:
## 2026-03-23 10:0011:20
**Objetivo**
...
**Trabajo realizado**
- ...
**Cambios relevantes**
- ...
**Bloqueos**
- ...
**Decisiones**
- ...
**Próximos pasos**
- ...
**Resumen**
...
CHANGELOG.md debe registrar solo cambios relevantes:
- code
- infra
- config
- docs
- automation
- decision
TASKS.md debe tener secciones:
- Inbox
- Próximo
- En curso
- Bloqueado
- En espera
- Hecho
Heurísticas mínimas:
- si hay bloqueos abiertos, priorizar destrabar
- si hubo cambios sin validación, sugerir validar
- si hubo trabajo parcial, sugerir cerrar el hilo abierto
- si no hubo avances, sugerir redefinir objetivo
- si hubo commits recientes no documentados, sugerir registrarlos
Integración Git:
- opcional
- leer commits recientes si repo_path existe
- no hacer commit, push ni cambios de ramas
Requisitos de calidad:
- código modular
- mensajes de error claros
- paths multiplataforma
- UTF-8
- tests básicos del flujo principal
Entregables:
- estructura completa del repositorio
- CLI funcional
- plantillas base
- README principal
- ejemplos de uso
- tests básicos
- proyecto demo
Prioriza claridad, mantenibilidad y bajo acoplamiento.
Además:
- implementa primero la estructura y los comandos base
- evita sobreingeniería
- usa funciones pequeñas y testeables
- separa claramente CLI, lógica de dominio y persistencia
- no escondas datos importantes en formatos opacos
- añade ejemplos de salida de comandos
- incluye un proyecto demo ya generado
- incluye tests del flujo init-project → start → note → stop → show

View File

@@ -0,0 +1,33 @@
# Changelog - Demo Project
## [0.1.1] - 2026-03-23
### Agregado
- Documentacion de la API de exportacion en `docs/api-export.md`
- Tests de integracion para el modulo exporter
### Modificado
- Actualizado el formato de metadatos de proyecto para incluir `last_session_at`
- Mejorado el parseo de fechas en sesiones
## [0.1.0] - 2026-03-22
### Agregado
- Modulo de exportacion con soporte para JSON
- Estructura de sesiones con metadatos en YAML front-matter
- Archivo `TASKS.md` con seguimiento de tareas
### Modificado
- Refactorizacion de `project.yaml` para incluir `slug` y `type`
## [0.0.1] - 2026-03-20
### Agregado
- Estructura inicial del proyecto demo
- Metadatos basicos en `meta/project.yaml`
- Primera sesion de planificación

View File

@@ -0,0 +1,59 @@
# Registro de Sesiones - Demo Project
## Sesion: 2026-03-22 14:30
**Duracion:** 2h 15min
**Objetivo:** Implementar funcionalidad de exportacion
### Trabajo realizado
- Analice los requisitos para la exportacion a JSON y CSV
- Diseñe la estructura de datos para los formatos de exportacion
- Implemente la funcion base de exportacion en `exporter.py`
- Escribi pruebas unitarias para los formateadores
### Bloqueos
- None
### Decisiones
- Decidi usar una clase base `BaseExporter` con métodos abstractos para cada formato
- La estructura de directorios sigue el patrón `YYYY-MM-DD_HHMM.md`
### Proximos pasos
- [ ] Implementar exportacion a CSV
- [ ] Agregar soporte para exportacion parcial (por rango de fechas)
- [ ] Documentar la API de exportacion
---
## Sesion: 2026-03-20 10:00
**Duracion:** 1h 30min
**Objetivo:** Iniciar proyecto demo
### Trabajo realizado
- Cree la estructura inicial del proyecto demo
- Defini los metadatos base en `meta/project.yaml`
- Configure el sistema de seguimiento basico
- Revise la documentacion del tracker CLI
### Bloqueos
- Ninguno
### Decisiones
- El proyecto usara el tipo "misc" para mayor flexibilidad
- Las sesiones se almacenaran en formato Markdown con metadatos YAML
### Proximos pasos
- [ ] Definir las primeras tareas del proyecto
- [ ] Crear la estructura de documentacion
- [ ] Establecer el flujo de trabajo regular
---

View File

@@ -0,0 +1,21 @@
# Demo Project
## Descripcion
Proyecto de demostración del tracker personal para mostrar las capacidades del sistema de seguimiento de tareas y sesiones.
## Estado
- **Estado:** Activo
- **Tipo:** Misc
- **Etiquetas:** demo, example
## Secciones
- [Tareas](./TASKS.md) - Lista de tareas del proyecto
- [Registro de sesiones](./LOG.md) - Historial de sesiones de trabajo
- [Cambios](./CHANGELOG.md) - Registro de cambios realizados
---
*Ultima actualizacion: 2026-03-23T12:00:00*

View File

@@ -0,0 +1,31 @@
# Tareas - Demo Project
## Inbox
- [ ] Revisar feedback del usuario sobre la interfaz de exportacion
- [ ] Investigar opciones para generar reportes en PDF
## Next
- [ ] Implementar exportacion a CSV
- [ ] Agregar filtros por fecha en el listado de tareas
- [ ] Crear plantilla para reportes semanales
## Active
- [ ] Implementar exportacion a CSV (iniciado 2026-03-22)
## Blocked
- [ ] Integracion con API de terceros (pendiente credenciales)
## Waiting
- [ ] Revision de codigo por parte del equipo (esperando feedback)
## Done
- [x] Crear estructura inicial del proyecto - 2026-03-20
- [x] Implementar exportacion a JSON - 2026-03-22
- [x] Escribir pruebas unitarias - 2026-03-22
- [x] Documentar formato de sesiones - 2026-03-21

View File

View File

View File

@@ -0,0 +1,13 @@
id: demo-001
name: Demo Project
slug: demo-project
description: Proyecto de demostración del tracker personal
type: misc
status: active
tags:
- demo
- example
repo_path: null
created_at: "2026-03-20T10:00:00"
updated_at: "2026-03-23T12:00:00"
last_session_at: "2026-03-22T14:30:00"

View File

@@ -0,0 +1,53 @@
---
date: "2026-03-20T10:00:00"
duration: 90
objective: "Iniciar proyecto demo"
project: demo-001
---
# Sesion: 2026-03-20 10:00
## Objetivo
Iniciar el proyecto demo para demostrar las capacidades del tracker personal.
## Notas
### planeacion
Necesito crear un proyecto que sirva como ejemplo real de uso del sistema.
Debe incluir:
- Estructura de directorios completa
- Metadatos realistas
- Sesiones de trabajo con contenido genuino
- Tareas en diferentes estados
### observaciones
El sistema de tracking funciona bien para proyectos personales.
La estructura de sesiones con front-matter YAML es flexible y util.
Me gusta poder vincular sesiones a proyectos especificos.
## Trabajo realizado
- [x] Cree la estructura inicial del proyecto demo
- [x] Defini los metadatos base en `meta/project.yaml`
- [x] Configure el sistema de seguimiento basico
- [x] Revise la documentacion del tracker CLI
## Bloqueos
Ninguno.
## Decisiones
- El proyecto usara el tipo "misc" para mayor flexibilidad
- Las sesiones se almacenaran en formato Markdown con metadatos YAML
- El ID del proyecto sera "demo-001" para facilitar la referencia
## Proximos pasos
- [ ] Definir las primeras tareas del proyecto
- [ ] Crear la estructura de documentacion
- [ ] Establecer el flujo de trabajo regular

View File

@@ -0,0 +1,75 @@
---
date: "2026-03-22T14:30:00"
duration: 135
objective: "Implementar funcionalidad de exportacion"
project: demo-001
---
# Sesion: 2026-03-22 14:30
## Objetivo
Implementar la funcionalidad de exportacion de datos del proyecto a diferentes formatos.
## Notas
### investigacion
Analice los requisitos para la exportacion:
**JSON:**
- Estructura jerarquica con metadatos completos
- Incluye timestamps en formato ISO
- Representacion de tareas por estado
**CSV:**
- Formato plano para importacion a spreadsheets
- Headers: id, titulo, estado, fecha_creacion, fecha_completado
- UTF-8 encoding
### diseno
Decidi usar una clase base `BaseExporter` con:
```
BaseExporter
├── export(data) -> str
├── format_metadata(meta) -> dict
└── validate_data(data) -> bool
JsonExporter(BaseExporter)
CsvExporter(BaseExporter)
```
### codigo
Escribi la implementacion inicial del exporter:
```python
class BaseExporter:
def export(self, data: dict) -> str:
raise NotImplementedError
```
## Trabajo realizado
- [x] Analice los requisitos para la exportacion a JSON y CSV
- [x] Diseñe la estructura de datos para los formatos de exportacion
- [x] Implemente la funcion base de exportacion en `exporter.py`
- [x] Escribi pruebas unitarias para los formateadores
## Bloqueos
Ninguno.
## Decisiones
- Decidi usar una clase base `BaseExporter` con metodos abstractos para cada formato
- La estructura de directorios sigue el patron `YYYY-MM-DD_HHMM.md`
- Los archivos de sesion incluyen front-matter con metadatos estructurados
## Proximos pasos
- [ ] Implementar exportacion a CSV
- [ ] Agregar soporte para exportacion parcial (por rango de fechas)
- [ ] Documentar la API de exportacion

View File

@@ -0,0 +1,4 @@
# Changelog
_Project changes_

View File

@@ -0,0 +1,50 @@
# Log
_Project activity log_
## 2026-03-24 23:4000:07
**Objetivo**
No especificado
**Trabajo realizado**
- Sin trabajo registrado
**Cambios relevantes**
- Sin cambios
**Bloqueos**
- Sin bloqueos
**Decisiones**
- Sin decisiones
**Próximos pasos**
- Definir próximos pasos
**Resumen**
Session de 27 minutos sin progreso registrado.
## 2026-03-25 00:0900:13
**Objetivo**
No especificado
**Trabajo realizado**
- Sin trabajo registrado
**Cambios relevantes**
- Sin cambios
**Bloqueos**
- Sin bloqueos
**Decisiones**
- Sin decisiones
**Próximos pasos**
- Definir próximos pasos
**Resumen**
Session de 3 minutos sin progreso registrado.

View File

@@ -0,0 +1,50 @@
# mi-proyecto
_No description_
## Objective
_TODO: Define objective_
## Status
**Current Status:** inbox
<!-- AUTOGEN:STATUS_START -->
Status: inbox
<!-- AUTOGEN:STATUS_END -->
## Context
_Current context and background_
## Stack / Tools
- _Tool 1_
- _Tool 2_
## Architecture
_Brief architecture description_
## Technical Decisions
_No decisions recorded yet_
## Risks / Blockers
_No blockers_
<!-- AUTOGEN:NEXT_STEPS_START -->
- Definir próximos pasos
- Definir próximos pasos
<!-- AUTOGEN:NEXT_STEPS_END -->
## Recent Sessions
<!-- AUTOGEN:SESSIONS_START -->
- 2026-03-24 23:40 (27 min): Session de 27 minutos sin progreso registrado....
- 2026-03-25 00:09 (3 min): Session de 3 minutos sin progreso registrado....
<!-- AUTOGEN:SESSIONS_END -->
_Last updated: 2026-03-24_

View File

@@ -0,0 +1,20 @@
# Tasks
## Inbox
-
## Próximo
- [ ] Test task 1
## En curso
-
## Bloqueado
-
## En espera
-
## Hecho
- [x] Test task 2
- [x] New test task

View File

@@ -0,0 +1,12 @@
id: d7443ae2-9d23-4dc9-85c2-50437d6ab993
name: mi-proyecto
slug: mi-proyecto
description: ''
type: misc
status: inbox
tags: []
root_path: projects/mi-proyecto
repo_path: null
created_at: '2026-03-24T23:39:35.125241'
updated_at: '2026-03-24T23:39:35.125246'
last_session_at: null

View File

@@ -0,0 +1,28 @@
# Sesion: 2026-03-24 23:4000:07
## Objetivo
No especificado
## Notas
- Sin notas
## Trabajo realizado
- Sin trabajo realizado
## Cambios
- Sin cambios
## Decisiones
- Sin decisiones
## Bloqueos
- Sin bloqueos
## Proximos pasos
- Definir próximos pasos
## Referencias
- Sin referencias
## Duracion
27 minutos

View File

@@ -0,0 +1,28 @@
# Sesion: 2026-03-25 00:0900:13
## Objetivo
No especificado
## Notas
- [idea] prueba de idea
## Trabajo realizado
- Sin trabajo realizado
## Cambios
- Sin cambios
## Decisiones
- Sin decisiones
## Bloqueos
- Sin bloqueos
## Proximos pasos
- Definir próximos pasos
## Referencias
- Sin referencias
## Duracion
3 minutos

View File

@@ -0,0 +1,18 @@
{
"id": "5f6b1f86-7ce1-4079-b886-9885cff9cca5",
"project_slug": "mi-proyecto",
"started_at": "2026-03-24T23:40:18.747800",
"ended_at": "2026-03-25T00:07:23.886040",
"duration_minutes": 27,
"objective": "",
"summary": "Session de 27 minutos sin progreso registrado.",
"work_done": [],
"changes": [],
"decisions": [],
"blockers": [],
"next_steps": [
"Definir próximos pasos"
],
"references": [],
"raw_notes": []
}

View File

@@ -0,0 +1,24 @@
{
"id": "6103e2ec-e40c-4a0b-8672-cd4db18ca487",
"project_slug": "mi-proyecto",
"started_at": "2026-03-25T00:09:38.396081",
"ended_at": "2026-03-25T00:13:11.888662",
"duration_minutes": 3,
"objective": "",
"summary": "Session de 3 minutos sin progreso registrado.",
"work_done": [],
"changes": [],
"decisions": [],
"blockers": [],
"next_steps": [
"Definir próximos pasos"
],
"references": [],
"raw_notes": [
{
"type": "idea",
"text": "prueba de idea",
"created_at": "2026-03-25T00:11:32.741023"
}
]
}

View File

@@ -17,6 +17,11 @@ git = ["gitpython"]
[project.scripts] [project.scripts]
tracker = "tracker.cli.main:app" tracker = "tracker.cli.main:app"
[tool.setuptools.packages.find]
where = ["."]
include = ["tracker*"]
exclude = ["tests*", "examples*", "docs*", "backlog*", "resumen*"]
[build-system] [build-system]
requires = ["setuptools>=61.0"] requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"

View File

@@ -0,0 +1,85 @@
# Resumen del Proyecto: Personal Tracker CLI
## Fecha: 2026-03-24 12:45
## Descripción General
**Personal Tracker** es una herramienta CLI para seguimiento de proyectos personales, enfocada en la continuidad entre sesiones de trabajo. Utiliza archivos Markdown y YAML como persistencia, sin base de datos ni dependencias cloud.
## Rama Actual
- **develop**
## Stack Tecnológico
- Python 3.11+
- Typer (CLI)
- PyYAML (metadatos)
- Jinja2 (plantillas)
- Pydantic (modelos)
- GitPython (opcional)
## Estructura del Proyecto
```
tracker/
├── cli/ # Comandos CLI (Typer)
├── models/ # Modelos de datos (Pydantic)
├── services/ # Lógica de negocio
├── storage/ # Persistencia archivos
├── utils/ # Utilidades
└── templates/ # Plantillas Jinja2
projects/ # Proyectos creados
tests/ # 72 tests
examples/demo-project/ # Proyecto demo
backlog/ # Requisitos (MVP-1.md)
```
## Comandos Implementados
| Comando | Descripción |
|---------|-------------|
| `init-project` | Crear nuevo proyecto |
| `list` | Listar proyectos |
| `show` | Ver detalles |
| `start` | Iniciar sesión |
| `note` | Agregar nota |
| `stop` | Finalizar sesión |
| `change` | Agregar al changelog |
| `next` | Sugerir próximos pasos |
| `review` | Vista general |
## Commits en Rama develop
1. `525996f` - Initial commit
2. `4547c49` - Implement storage layer
3. `b0c65a0` - Implement core services
4. `88a474a` - Implement CLI commands
5. `40a33d7` - Implement project templates
6. `b36b603` - Complete CLI commands
7. `4e67062` - Add demo project
8. `2735562` - Add comprehensive tests
9. `4d99213` - Update README with MVP-1 documentation
10. `b4593c6` - Add detailed installation guide
11. `cc52360` - Fix pyproject.toml package discovery
## Instalación
```bash
pip install -e .
```
## Tests
```bash
pytest tests/ -v
# 72 tests implementados
```
## Estado Actual
- MVP-1 implementado y funcional
- CLI operativa con todos los comandos
- Tests cubiertos
- Documentación completa en README.md

102
tests/conftest.py Normal file
View File

@@ -0,0 +1,102 @@
"""Pytest fixtures for tracker tests."""
import json
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
import pytest
from tracker.models import Project, Session, Note, NoteType, Change
@pytest.fixture
def tmp_project_dir(tmp_path):
"""Create a temporary directory for project tests."""
projects_root = tmp_path / "projects"
projects_root.mkdir()
return projects_root
@pytest.fixture
def sample_project_data():
"""Sample project data for testing."""
return {
"id": "test-project-id-123",
"name": "Test Project",
"slug": "test-project",
"description": "A test project for unit testing",
"type": "code",
"status": "active",
"tags": ["python", "testing"],
"root_path": "/path/to/projects/test-project",
"repo_path": "/path/to/repos/test-project",
"created_at": datetime(2024, 1, 15, 10, 0, 0),
"updated_at": datetime(2024, 1, 15, 10, 0, 0),
"last_session_at": None,
}
@pytest.fixture
def sample_project(sample_project_data):
"""Create a sample Project instance."""
return Project(**sample_project_data)
@pytest.fixture
def mock_session(sample_project):
"""Create a mock session for testing."""
return Session(
id="session-123",
project_slug=sample_project.slug,
started_at=datetime(2024, 1, 15, 10, 0, 0),
ended_at=datetime(2024, 1, 15, 11, 30, 0),
duration_minutes=90,
objective="Complete initial implementation",
summary="Worked on core features",
work_done=["Implemented feature A", "Fixed bug B"],
changes=["Added new endpoint"],
decisions=["Use JSON for storage"],
blockers=[],
next_steps=["Add tests", "Write documentation"],
references=["https://example.com/docs"],
raw_notes=[
{"type": "work", "text": "Working on feature A", "timestamp": "2024-01-15T10:15:00"},
{"type": "idea", "text": "Consider using caching", "timestamp": "2024-01-15T10:30:00"},
],
)
@pytest.fixture
def sample_note():
"""Create a sample note for testing."""
return Note(
type=NoteType.WORK,
text="Completed the implementation of feature X",
created_at=datetime(2024, 1, 15, 10, 30, 0),
)
@pytest.fixture
def sample_change():
"""Create a sample change for testing."""
return Change(
date=datetime(2024, 1, 15).date(),
type="code",
title="Added user authentication",
impact="Improved security",
references=["#123"],
)
@pytest.fixture
def tmp_project_with_structure(tmp_project_dir, sample_project_data):
"""Create a temporary project with directory structure."""
slug = sample_project_data["slug"]
project_root = tmp_project_dir / slug
(project_root / "sessions").mkdir(parents=True)
(project_root / "docs").mkdir(parents=True)
(project_root / "assets").mkdir(parents=True)
(project_root / "meta").mkdir(parents=True)
return project_root

337
tests/test_flow.py Normal file
View File

@@ -0,0 +1,337 @@
"""Tests for complete flow: init -> start -> note -> stop -> show."""
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from tracker.models import Session, Note, NoteType
from tracker.services import (
create_project,
get_project,
ensure_project_structure,
set_active_session,
get_active_session,
clear_active_session,
validate_no_other_active_session,
add_note,
consolidate_notes,
get_projects_root,
)
from tracker.storage import FileStorage
class TestInitProjectFlow:
"""Tests for project initialization flow."""
def test_init_project_creates_structure(self, tmp_path, sample_project_data):
"""Test that project initialization creates required directory structure."""
slug = sample_project_data["slug"]
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = create_project(
name=sample_project_data["name"],
slug=slug,
description=sample_project_data["description"],
type=sample_project_data["type"],
)
ensure_project_structure(slug)
project_root = tmp_path / slug
assert (project_root / "sessions").is_dir()
assert (project_root / "docs").is_dir()
assert (project_root / "assets").is_dir()
assert (project_root / "meta").is_dir()
def test_init_project_creates_meta_file(self, tmp_path, sample_project_data):
"""Test that project initialization creates meta/project.yaml."""
slug = sample_project_data["slug"]
storage = FileStorage(tmp_path)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = create_project(
name=sample_project_data["name"],
slug=slug,
description=sample_project_data["description"],
type=sample_project_data["type"],
)
# Write the project meta (simulating what storage would do)
meta_data = {
"id": project.id,
"name": project.name,
"slug": project.slug,
"description": project.description,
"type": project.type,
"status": project.status,
"tags": project.tags,
"created_at": project.created_at.isoformat(),
"updated_at": project.updated_at.isoformat(),
}
storage.write_project_meta(slug, meta_data)
meta_path = tmp_path / slug / "meta" / "project.yaml"
assert meta_path.exists()
with open(meta_path, "r") as f:
data = yaml.safe_load(f)
assert data["name"] == sample_project_data["name"]
assert data["slug"] == slug
class TestStartSessionFlow:
"""Tests for starting a session."""
def test_start_session(self, tmp_path, sample_project_data, monkeypatch):
"""Test starting a session creates active session."""
slug = sample_project_data["slug"]
# Create a fake active session path
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = create_project(
name=sample_project_data["name"],
slug=slug,
)
ensure_project_structure(slug)
session = Session(
id="session-1",
project_slug=slug,
started_at=datetime.now(),
objective="Test objective",
)
set_active_session(session)
active = get_active_session()
assert active is not None
assert active.id == "session-1"
assert active.project_slug == slug
def test_start_session_fails_if_other_active(self, tmp_path, sample_project_data, monkeypatch):
"""Test that starting a session fails if another project has an active session."""
slug1 = "project-1"
slug2 = "project-2"
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
session1 = Session(
id="session-1",
project_slug=slug1,
started_at=datetime.now(),
)
set_active_session(session1)
is_valid = True
if not validate_no_other_active_session(slug2):
is_valid = False
assert is_valid is False
class TestAddNoteFlow:
"""Tests for adding notes during a session."""
def test_add_note(self, tmp_path, sample_project_data, monkeypatch):
"""Test adding a note during a session."""
slug = sample_project_data["slug"]
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
session = Session(
id="session-1",
project_slug=slug,
started_at=datetime.now(),
)
set_active_session(session)
add_note(session, "work", "Test work note")
assert len(session.raw_notes) == 1
assert session.raw_notes[0]["type"] == "work"
assert session.raw_notes[0]["text"] == "Test work note"
def test_add_multiple_notes(self, tmp_path, sample_project_data, monkeypatch):
"""Test adding multiple notes during a session."""
slug = sample_project_data["slug"]
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
session = Session(
id="session-1",
project_slug=slug,
started_at=datetime.now(),
)
add_note(session, "work", "Work note")
add_note(session, "idea", "Idea note")
add_note(session, "blocker", "Blocker note")
assert len(session.raw_notes) == 3
assert session.raw_notes[0]["type"] == "work"
assert session.raw_notes[1]["type"] == "idea"
assert session.raw_notes[2]["type"] == "blocker"
class TestStopSessionFlow:
"""Tests for stopping a session."""
def test_stop_session(self, tmp_path, sample_project_data, monkeypatch):
"""Test stopping a session clears active session."""
slug = sample_project_data["slug"]
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
session = Session(
id="session-1",
project_slug=slug,
started_at=datetime.now(),
)
set_active_session(session)
session.ended_at = datetime.now()
session.duration_minutes = 30
clear_active_session()
active = get_active_session()
assert active is None
class TestShowProjectFlow:
"""Tests for showing project information."""
def test_show_project(self, tmp_path, sample_project_data):
"""Test showing project information."""
slug = sample_project_data["slug"]
storage = FileStorage(tmp_path)
meta_data = {
"id": "test-id",
"name": sample_project_data["name"],
"slug": slug,
"description": sample_project_data["description"],
"type": sample_project_data["type"],
"status": sample_project_data["status"],
"tags": sample_project_data["tags"],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
storage.write_project_meta(slug, meta_data)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = get_project(slug)
assert project is not None
assert project.name == sample_project_data["name"]
assert project.slug == slug
class TestConsolidateNotes:
"""Tests for consolidating notes after session."""
def test_consolidate_notes_categorizes_work(self):
"""Test that consolidate_notes categorizes work notes."""
raw_notes = [
{"type": "work", "text": "Implemented feature A", "timestamp": "2024-01-15T10:00:00"},
{"type": "work", "text": "Wrote tests", "timestamp": "2024-01-15T10:30:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["work_done"]) == 2
assert "Implemented feature A" in consolidated["work_done"]
assert "Wrote tests" in consolidated["work_done"]
def test_consolidate_notes_categorizes_blockers(self):
"""Test that consolidate_notes categorizes blockers."""
raw_notes = [
{"type": "blocker", "text": "Waiting for API access", "timestamp": "2024-01-15T10:00:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["blockers"]) == 1
assert "Waiting for API access" in consolidated["blockers"]
def test_consolidate_notes_categorizes_decisions(self):
"""Test that consolidate_notes categorizes decisions."""
raw_notes = [
{"type": "decision", "text": "Use PostgreSQL for storage", "timestamp": "2024-01-15T10:00:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["decisions"]) == 1
assert "Use PostgreSQL for storage" in consolidated["decisions"]
def test_consolidate_notes_categorizes_references(self):
"""Test that consolidate_notes categorizes references."""
raw_notes = [
{"type": "reference", "text": "https://docs.example.com", "timestamp": "2024-01-15T10:00:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["references"]) == 1
assert "https://docs.example.com" in consolidated["references"]
def test_consolidate_notes_categorizes_changes(self):
"""Test that consolidate_notes categorizes changes."""
raw_notes = [
{"type": "change", "text": "Refactored authentication module", "timestamp": "2024-01-15T10:00:00"},
]
consolidated = consolidate_notes(raw_notes)
assert len(consolidated["changes"]) == 1
assert "Refactored authentication module" in consolidated["changes"]

220
tests/test_models.py Normal file
View File

@@ -0,0 +1,220 @@
"""Tests for data models."""
from datetime import datetime, date
import pytest
from tracker.models import Project, Session, Note, NoteType, Change
class TestProjectCreation:
"""Tests for Project model creation."""
def test_project_creation_with_all_fields(self, sample_project_data):
"""Test creating a project with all fields specified."""
project = Project(**sample_project_data)
assert project.id == sample_project_data["id"]
assert project.name == sample_project_data["name"]
assert project.slug == sample_project_data["slug"]
assert project.description == sample_project_data["description"]
assert project.type == sample_project_data["type"]
assert project.status == sample_project_data["status"]
assert project.tags == sample_project_data["tags"]
assert project.root_path == sample_project_data["root_path"]
assert project.repo_path == sample_project_data["repo_path"]
assert project.created_at == sample_project_data["created_at"]
assert project.updated_at == sample_project_data["updated_at"]
assert project.last_session_at is None
def test_project_creation_with_defaults(self):
"""Test creating a project with default values."""
now = datetime.now()
project = Project(
id="test-id",
name="Test",
slug="test",
created_at=now,
updated_at=now,
)
assert project.description == ""
assert project.type == "misc"
assert project.status == "inbox"
assert project.tags == []
assert project.root_path == ""
assert project.repo_path is None
assert project.last_session_at is None
def test_project_status_validation(self):
"""Test that project status can be set to valid values."""
valid_statuses = ["inbox", "next", "active", "blocked", "waiting", "done", "archived"]
for status in valid_statuses:
project = Project(
id="test-id",
name="Test",
slug="test",
created_at=datetime.now(),
updated_at=datetime.now(),
status=status,
)
assert project.status == status
def test_project_type_validation(self):
"""Test that project type can be set to valid values."""
valid_types = ["code", "homelab", "automation", "agent", "research", "misc"]
for project_type in valid_types:
project = Project(
id="test-id",
name="Test",
slug="test",
created_at=datetime.now(),
updated_at=datetime.now(),
type=project_type,
)
assert project.type == project_type
class TestSessionCreation:
"""Tests for Session model creation."""
def test_session_creation_with_all_fields(self, mock_session):
"""Test creating a session with all fields specified."""
assert mock_session.id == "session-123"
assert mock_session.project_slug == "test-project"
assert mock_session.started_at == datetime(2024, 1, 15, 10, 0, 0)
assert mock_session.ended_at == datetime(2024, 1, 15, 11, 30, 0)
assert mock_session.duration_minutes == 90
assert mock_session.objective == "Complete initial implementation"
assert mock_session.summary == "Worked on core features"
assert len(mock_session.work_done) == 2
assert len(mock_session.changes) == 1
assert len(mock_session.decisions) == 1
assert len(mock_session.blockers) == 0
assert len(mock_session.next_steps) == 2
assert len(mock_session.references) == 1
assert len(mock_session.raw_notes) == 2
def test_session_creation_with_defaults(self):
"""Test creating a session with required fields only."""
session = Session(
id="session-1",
project_slug="test-project",
started_at=datetime.now(),
)
assert session.ended_at is None
assert session.duration_minutes is None
assert session.objective == ""
assert session.summary == ""
assert session.work_done == []
assert session.changes == []
assert session.decisions == []
assert session.blockers == []
assert session.next_steps == []
assert session.references == []
assert session.raw_notes == []
def test_session_with_optional_datetime_fields(self):
"""Test session with ended_at but no duration_minutes."""
session = Session(
id="session-1",
project_slug="test-project",
started_at=datetime(2024, 1, 15, 10, 0, 0),
ended_at=datetime(2024, 1, 15, 11, 0, 0),
)
assert session.ended_at is not None
assert session.duration_minutes is None
class TestNoteTypes:
"""Tests for Note and NoteType models."""
def test_note_creation(self, sample_note):
"""Test creating a note."""
assert sample_note.type == NoteType.WORK
assert sample_note.text == "Completed the implementation of feature X"
assert sample_note.created_at == datetime(2024, 1, 15, 10, 30, 0)
def test_note_type_enum_values(self):
"""Test that NoteType enum has expected values."""
assert NoteType.WORK.value == "work"
assert NoteType.CHANGE.value == "change"
assert NoteType.BLOCKER.value == "blocker"
assert NoteType.DECISION.value == "decision"
assert NoteType.IDEA.value == "idea"
assert NoteType.REFERENCE.value == "reference"
def test_note_type_assignment(self):
"""Test assigning different note types to a note."""
for note_type in NoteType:
note = Note(type=note_type, text="Test note")
assert note.type == note_type
def test_note_default_created_at(self):
"""Test that note has default created_at timestamp."""
note = Note(type=NoteType.WORK, text="Test note")
assert note.created_at is not None
assert isinstance(note.created_at, datetime)
def test_note_raw_notes_structure(self, mock_session):
"""Test that raw_notes in session have expected structure."""
assert len(mock_session.raw_notes) == 2
assert mock_session.raw_notes[0]["type"] == "work"
assert mock_session.raw_notes[0]["text"] == "Working on feature A"
assert "timestamp" in mock_session.raw_notes[0]
class TestChangeValidation:
"""Tests for Change model validation."""
def test_change_creation(self, sample_change):
"""Test creating a change."""
assert sample_change.date == date(2024, 1, 15)
assert sample_change.type == "code"
assert sample_change.title == "Added user authentication"
assert sample_change.impact == "Improved security"
assert sample_change.references == ["#123"]
def test_change_creation_with_defaults(self):
"""Test creating a change with default values."""
change = Change(
date=date(2024, 1, 15),
type="docs",
title="Updated documentation",
)
assert change.impact == ""
assert change.references == []
def test_change_date_as_date_not_datetime(self, sample_change):
"""Test that change date is stored as date object."""
assert isinstance(sample_change.date, date)
assert not isinstance(sample_change.date, datetime)
def test_change_types(self):
"""Test valid change types."""
valid_types = ["code", "infra", "config", "docs", "automation", "decision"]
for change_type in valid_types:
change = Change(
date=date.today(),
type=change_type,
title=f"Test {change_type}",
)
assert change.type == change_type
def test_change_with_multiple_references(self):
"""Test change with multiple references."""
change = Change(
date=date.today(),
type="code",
title="Major refactor",
references=["#100", "#101", "#102"],
)
assert len(change.references) == 3
assert "#100" in change.references
assert "#102" in change.references

View File

@@ -0,0 +1,209 @@
"""Tests for ProjectService."""
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from tracker.models import Project
from tracker.services import (
create_project,
get_project,
update_project,
list_projects,
get_projects_root,
ensure_project_structure,
)
class TestCreateProject:
"""Tests for create_project function."""
def test_create_project_returns_project_instance(self):
"""Test that create_project returns a Project instance."""
project = create_project(
name="Test Project",
slug="test-project",
description="A test project",
type="code",
)
assert isinstance(project, Project)
assert project.name == "Test Project"
assert project.slug == "test-project"
assert project.description == "A test project"
assert project.type == "code"
def test_create_project_generates_id(self):
"""Test that create_project generates a UUID."""
project = create_project(name="Test", slug="test")
assert project.id is not None
assert len(project.id) > 0
def test_create_project_sets_default_status(self):
"""Test that create_project sets default status to inbox."""
project = create_project(name="Test", slug="test")
assert project.status == "inbox"
def test_create_project_sets_timestamps(self):
"""Test that create_project sets created_at and updated_at."""
before = datetime.now()
project = create_project(name="Test", slug="test")
after = datetime.now()
assert before <= project.created_at <= after
assert before <= project.updated_at <= after
# created_at and updated_at should be very close (within 1 second)
time_diff = abs((project.updated_at - project.created_at).total_seconds())
assert time_diff < 1
def test_create_project_with_tags(self):
"""Test creating a project with tags."""
tags = ["python", "testing", "cli"]
project = create_project(name="Test", slug="test", tags=tags)
assert project.tags == tags
def test_create_project_with_repo_path(self):
"""Test creating a project with a repo path."""
repo_path = Path("/path/to/repo")
project = create_project(name="Test", slug="test", repo_path=repo_path)
assert project.repo_path == str(repo_path)
def test_create_project_without_repo_path(self):
"""Test creating a project without repo path."""
project = create_project(name="Test", slug="test")
assert project.repo_path is None
class TestGetProject:
"""Tests for get_project function."""
def test_get_project_returns_none_for_nonexistent(self, tmp_path):
"""Test that get_project returns None for nonexistent project."""
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
result = get_project("nonexistent")
assert result is None
def test_get_project_returns_project_when_exists(self, tmp_path, sample_project_data):
"""Test that get_project returns Project when it exists."""
slug = sample_project_data["slug"]
project_dir = tmp_path / slug / "meta"
project_dir.mkdir(parents=True)
meta_path = project_dir / "project.yaml"
with open(meta_path, "w") as f:
yaml.safe_dump(sample_project_data, f)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
project = get_project(slug)
assert project is not None
assert project.slug == slug
assert project.name == sample_project_data["name"]
def test_get_project_handles_invalid_yaml(self, tmp_path):
"""Test that get_project handles invalid YAML gracefully."""
slug = "test-project"
project_dir = tmp_path / slug / "meta"
project_dir.mkdir(parents=True)
meta_path = project_dir / "project.yaml"
with open(meta_path, "w") as f:
f.write("invalid: yaml: content:")
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
result = get_project(slug)
assert result is None
class TestListProjects:
"""Tests for list_projects function."""
def test_list_projects_returns_empty_list_when_no_projects(self, tmp_path):
"""Test that list_projects returns empty list when no projects exist."""
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
projects = list_projects()
assert projects == []
def test_list_projects_returns_all_projects(self, tmp_path, sample_project_data):
"""Test that list_projects returns all existing projects."""
project1_data = sample_project_data.copy()
project1_data["slug"] = "project-1"
project1_data["name"] = "Project 1"
project2_data = sample_project_data.copy()
project2_data["slug"] = "project-2"
project2_data["name"] = "Project 2"
for project_data in [project1_data, project2_data]:
slug = project_data["slug"]
project_dir = tmp_path / slug / "meta"
project_dir.mkdir(parents=True)
meta_path = project_dir / "project.yaml"
with open(meta_path, "w") as f:
yaml.safe_dump(project_data, f)
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
projects = list_projects()
assert len(projects) == 2
slugs = [p.slug for p in projects]
assert "project-1" in slugs
assert "project-2" in slugs
def test_list_projects_ignores_hidden_directories(self, tmp_path, sample_project_data):
"""Test that list_projects ignores hidden directories."""
slug = sample_project_data["slug"]
project_dir = tmp_path / slug / "meta"
project_dir.mkdir(parents=True)
meta_path = project_dir / "project.yaml"
with open(meta_path, "w") as f:
yaml.safe_dump(sample_project_data, f)
hidden_dir = tmp_path / ".hidden"
hidden_dir.mkdir()
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
projects = list_projects()
assert len(projects) == 1
assert projects[0].slug == slug
class TestEnsureProjectStructure:
"""Tests for ensure_project_structure function."""
def test_ensure_project_structure_creates_directories(self, tmp_path):
"""Test that ensure_project_structure creates required directories."""
slug = "test-project"
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
ensure_project_structure(slug)
project_root = tmp_path / slug
assert (project_root / "sessions").exists()
assert (project_root / "docs").exists()
assert (project_root / "assets").exists()
assert (project_root / "meta").exists()
def test_ensure_project_structure_is_idempotent(self, tmp_path):
"""Test that ensure_project_structure can be called multiple times safely."""
slug = "test-project"
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
ensure_project_structure(slug)
ensure_project_structure(slug)
project_root = tmp_path / slug
assert (project_root / "sessions").exists()
assert (project_root / "docs").exists()
def test_ensure_project_structure_creates_nested_directories(self, tmp_path):
"""Test that ensure_project_structure creates parent directories if needed."""
slug = "nested/project"
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
ensure_project_structure(slug)
project_root = tmp_path / slug
assert (project_root / "sessions").exists()

View File

@@ -0,0 +1,241 @@
"""Tests for SessionService."""
import json
from datetime import datetime
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from tracker.models import Session
from tracker.services import (
get_active_session,
set_active_session,
clear_active_session,
get_active_session_path,
validate_no_other_active_session,
)
@pytest.fixture
def mock_active_session_path(tmp_path, monkeypatch):
"""Mock the active session path to use tmp_path."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
return fake_path
class TestSetAndGetActiveSession:
"""Tests for set and get active session functions."""
def test_set_and_get_active_session(self, tmp_path, mock_session, monkeypatch):
"""Test setting and getting an active session."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
retrieved = get_active_session()
assert retrieved is not None
assert retrieved.id == mock_session.id
assert retrieved.project_slug == mock_session.project_slug
assert retrieved.started_at == mock_session.started_at
def test_get_active_session_returns_none_when_no_session(self, tmp_path, monkeypatch):
"""Test that get_active_session returns None when no session exists."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
result = get_active_session()
assert result is None
def test_set_active_session_creates_parent_directories(self, tmp_path, mock_session, monkeypatch):
"""Test that set_active_session creates parent directories if needed."""
fake_path = tmp_path / "subdir" / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
assert fake_path.exists()
def test_active_session_contains_all_fields(self, tmp_path, mock_session, monkeypatch):
"""Test that active session file contains all session fields."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
with open(fake_path, "r") as f:
data = json.load(f)
assert data["id"] == mock_session.id
assert data["project_slug"] == mock_session.project_slug
assert "started_at" in data
assert "work_done" in data
assert data["work_done"] == mock_session.work_done
class TestClearActiveSession:
"""Tests for clear_active_session function."""
def test_clear_active_session_removes_file(self, tmp_path, mock_session, monkeypatch):
"""Test that clear_active_session removes the active session file."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
assert fake_path.exists()
clear_active_session()
assert not fake_path.exists()
def test_clear_active_session_when_no_session(self, tmp_path, monkeypatch):
"""Test that clear_active_session does not error when no session exists."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
clear_active_session()
assert not fake_path.exists()
def test_get_active_session_after_clear(self, tmp_path, mock_session, monkeypatch):
"""Test that get_active_session returns None after clearing."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
clear_active_session()
result = get_active_session()
assert result is None
class TestValidateNoOtherActiveSession:
"""Tests for validate_no_other_active_session function."""
def test_validate_no_other_active_session_returns_true_when_none(self, tmp_path, monkeypatch):
"""Test validation returns True when no active session exists."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
result = validate_no_other_active_session("any-project")
assert result is True
def test_validate_no_other_active_session_returns_true_for_same_project(
self, tmp_path, mock_session, monkeypatch
):
"""Test validation returns True for same project's session."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
result = validate_no_other_active_session(mock_session.project_slug)
assert result is True
def test_validate_no_other_active_session_returns_false_for_different_project(
self, tmp_path, mock_session, monkeypatch
):
"""Test validation returns False for different project's session."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
result = validate_no_other_active_session("different-project")
assert result is False
def test_validate_no_other_active_session_with_no_active_after_clear(
self, tmp_path, mock_session, monkeypatch
):
"""Test validation returns True after clearing session."""
fake_path = tmp_path / ".active_session.json"
def mock_get_active_session_path():
return fake_path
monkeypatch.setattr(
"tracker.services.session_service.get_active_session_path",
mock_get_active_session_path
)
set_active_session(mock_session)
clear_active_session()
result = validate_no_other_active_session("any-project")
assert result is True

193
tests/test_storage.py Normal file
View File

@@ -0,0 +1,193 @@
"""Tests for FileStorage."""
import json
from datetime import datetime
from pathlib import Path
import pytest
import yaml
from tracker.models import Session
from tracker.storage import FileStorage
class TestReadWriteProjectMeta:
"""Tests for read/write project meta operations."""
def test_write_and_read_project_meta(self, tmp_project_dir):
"""Test writing and reading project meta."""
storage = FileStorage(tmp_project_dir)
slug = "test-project"
meta_data = {
"id": "test-id",
"name": "Test Project",
"slug": slug,
"description": "A test project",
"type": "code",
"status": "active",
"tags": ["python"],
"created_at": datetime(2024, 1, 15, 10, 0, 0).isoformat(),
"updated_at": datetime(2024, 1, 15, 10, 0, 0).isoformat(),
}
storage.write_project_meta(slug, meta_data)
result = storage.read_project_meta(slug)
assert result["name"] == "Test Project"
assert result["slug"] == slug
assert result["type"] == "code"
def test_read_project_meta_creates_parent_directories(self, tmp_project_dir):
"""Test that write_project_meta creates parent directories."""
storage = FileStorage(tmp_project_dir)
slug = "new-project"
meta_data = {"id": "test-id", "name": "Test", "slug": slug}
storage.write_project_meta(slug, meta_data)
meta_path = tmp_project_dir / slug / "meta" / "project.yaml"
assert meta_path.exists()
def test_read_project_meta_raises_for_nonexistent(self, tmp_project_dir):
"""Test that reading nonexistent project raises error."""
storage = FileStorage(tmp_project_dir)
with pytest.raises(FileNotFoundError):
storage.read_project_meta("nonexistent")
class TestAppendToLog:
"""Tests for append_to_log operations."""
def test_append_to_log_creates_log_file(self, tmp_project_dir):
"""Test that append_to_log creates LOG.md if it doesn't exist."""
storage = FileStorage(tmp_project_dir)
slug = "test-project"
# Create project directory first
(tmp_project_dir / slug).mkdir(parents=True)
storage.append_to_log(slug, "# Test Log Entry\n")
log_path = tmp_project_dir / slug / "LOG.md"
assert log_path.exists()
def test_append_to_log_appends_content(self, tmp_project_dir):
"""Test that append_to_log appends content to LOG.md."""
storage = FileStorage(tmp_project_dir)
slug = "test-project"
# Create project directory first
(tmp_project_dir / slug).mkdir(parents=True)
storage.append_to_log(slug, "# First Entry\n")
storage.append_to_log(slug, "# Second Entry\n")
content = storage.read_log(slug)
assert "# First Entry" in content
assert "# Second Entry" in content
def test_read_log_returns_empty_string_for_nonexistent(self, tmp_project_dir):
"""Test that read_log returns empty string for nonexistent log."""
storage = FileStorage(tmp_project_dir)
result = storage.read_log("nonexistent")
assert result == ""
class TestActiveSessionStorage:
"""Tests for active session storage operations."""
def test_write_and_read_active_session(self, tmp_project_dir, mock_session):
"""Test writing and reading active session."""
storage = FileStorage(tmp_project_dir)
session_data = mock_session.model_dump(mode="json")
session_data["started_at"] = mock_session.started_at.isoformat()
if mock_session.ended_at:
session_data["ended_at"] = mock_session.ended_at.isoformat()
storage.write_active_session(session_data)
result = storage.read_active_session()
assert result is not None
assert result["id"] == mock_session.id
def test_read_active_session_returns_none_when_not_exists(self, tmp_project_dir):
"""Test that read_active_session returns None when file doesn't exist."""
storage = FileStorage(tmp_project_dir)
result = storage.read_active_session()
assert result is None
def test_delete_active_session(self, tmp_project_dir, mock_session):
"""Test deleting active session."""
storage = FileStorage(tmp_project_dir)
session_data = mock_session.model_dump(mode="json")
session_data["started_at"] = mock_session.started_at.isoformat()
storage.write_active_session(session_data)
storage.delete_active_session()
result = storage.read_active_session()
assert result is None
def test_delete_active_session_when_not_exists(self, tmp_project_dir):
"""Test deleting active session when it doesn't exist doesn't error."""
storage = FileStorage(tmp_project_dir)
storage.delete_active_session()
class TestProjectExistence:
"""Tests for project existence checks."""
def test_project_exists_returns_true_for_existing_project(self, tmp_project_dir):
"""Test that project_exists returns True for existing project."""
storage = FileStorage(tmp_project_dir)
slug = "test-project"
(tmp_project_dir / slug).mkdir()
assert storage.project_exists(slug) is True
def test_project_exists_returns_false_for_nonexistent(self, tmp_project_dir):
"""Test that project_exists returns False for nonexistent project."""
storage = FileStorage(tmp_project_dir)
assert storage.project_exists("nonexistent") is False
class TestListProjects:
"""Tests for listing projects."""
def test_list_projects_returns_all_projects(self, tmp_project_dir):
"""Test that list_projects returns all project slugs."""
storage = FileStorage(tmp_project_dir)
(tmp_project_dir / "project-1").mkdir()
(tmp_project_dir / "project-2").mkdir()
projects = storage.list_projects()
assert "project-1" in projects
assert "project-2" in projects
def test_list_projects_excludes_hidden_directories(self, tmp_project_dir):
"""Test that list_projects excludes hidden directories."""
storage = FileStorage(tmp_project_dir)
(tmp_project_dir / "project-1").mkdir()
(tmp_project_dir / ".hidden").mkdir()
projects = storage.list_projects()
assert "project-1" in projects
assert ".hidden" not in projects
def test_list_projects_returns_empty_list_when_no_projects(self, tmp_project_dir):
"""Test that list_projects returns empty list when no projects exist."""
storage = FileStorage(tmp_project_dir)
projects = storage.list_projects()
assert projects == []

View File

@@ -35,11 +35,7 @@ markdown_writer = MarkdownWriter()
# ============================================================================= # =============================================================================
# init-project command # init-project command
# ============================================================================= # =============================================================================
init_project = typer.Typer(help="Create a new project with standard structure.") def init_project(
@init_project.command("init-project")
def cmd_init_project(
name: str = typer.Argument(..., help="Project name"), name: str = typer.Argument(..., help="Project name"),
type: str = typer.Option("misc", help="Project type (code, homelab, automation, agent, research, misc)"), type: str = typer.Option("misc", help="Project type (code, homelab, automation, agent, research, misc)"),
tags: str = typer.Option("", help="Comma-separated tags"), tags: str = typer.Option("", help="Comma-separated tags"),
@@ -86,11 +82,7 @@ def cmd_init_project(
# ============================================================================= # =============================================================================
# list command # list command
# ============================================================================= # =============================================================================
list_projects = typer.Typer(help="List all projects.") def list_projects_cmd() -> None:
@list_projects.command("list")
def cmd_list_projects() -> None:
"""Show all projects with their status, last session, and next steps.""" """Show all projects with their status, last session, and next steps."""
projects = list_projects() projects = list_projects()
@@ -125,11 +117,7 @@ def cmd_list_projects() -> None:
# ============================================================================= # =============================================================================
# show command # show command
# ============================================================================= # =============================================================================
show_project = typer.Typer(help="Show project details.") def show_project(slug: str = typer.Argument(..., help="Project slug")) -> None:
@show_project.command("show")
def cmd_show_project(slug: str = typer.Argument(..., help="Project slug")) -> None:
"""Show detailed project information including status, context, last summary, blockers, and next steps.""" """Show detailed project information including status, context, last summary, blockers, and next steps."""
# Load project # Load project
project_dict = storage.read_project_meta(slug) project_dict = storage.read_project_meta(slug)
@@ -185,11 +173,7 @@ def cmd_show_project(slug: str = typer.Argument(..., help="Project slug")) -> No
# ============================================================================= # =============================================================================
# start command # start command
# ============================================================================= # =============================================================================
start_session = typer.Typer(help="Start a work session.") def start_session(
@start_session.command("start")
def cmd_start_session(
slug: str = typer.Argument(..., help="Project slug"), slug: str = typer.Argument(..., help="Project slug"),
objective: Optional[str] = typer.Option(None, help="Session objective"), objective: Optional[str] = typer.Option(None, help="Session objective"),
) -> None: ) -> None:
@@ -248,11 +232,7 @@ def cmd_start_session(
# ============================================================================= # =============================================================================
# note command # note command
# ============================================================================= # =============================================================================
add_note_cmd = typer.Typer(help="Add a note to the active session.") def add_note_cmd(
@add_note_cmd.command("note")
def cmd_add_note(
text: str = typer.Argument(..., help="Note text"), text: str = typer.Argument(..., help="Note text"),
type: str = typer.Option("work", help="Note type (work, change, blocker, decision, idea, reference)"), type: str = typer.Option("work", help="Note type (work, change, blocker, decision, idea, reference)"),
) -> None: ) -> None:
@@ -291,11 +271,7 @@ def cmd_add_note(
# ============================================================================= # =============================================================================
# stop command # stop command
# ============================================================================= # =============================================================================
stop_session = typer.Typer(help="Stop the current session.") def stop_session(
@stop_session.command("stop")
def cmd_stop_session(
slug: str = typer.Argument(..., help="Project slug"), slug: str = typer.Argument(..., help="Project slug"),
add_to_changelog: bool = typer.Option(False, "--changelog", help="Add session summary to CHANGELOG.md"), add_to_changelog: bool = typer.Option(False, "--changelog", help="Add session summary to CHANGELOG.md"),
) -> None: ) -> None:
@@ -386,11 +362,7 @@ def cmd_stop_session(
# ============================================================================= # =============================================================================
# change command # change command
# ============================================================================= # =============================================================================
add_change = typer.Typer(help="Add a change entry to CHANGELOG.md.") def add_change(
@add_change.command("change")
def cmd_add_change(
slug: str = typer.Argument(..., help="Project slug"), slug: str = typer.Argument(..., help="Project slug"),
type: str = typer.Option("code", help="Change type (code, infra, config, docs, automation, decision)"), type: str = typer.Option("code", help="Change type (code, infra, config, docs, automation, decision)"),
title: str = typer.Option(..., help="Change title"), title: str = typer.Option(..., help="Change title"),
@@ -418,11 +390,7 @@ def cmd_add_change(
# ============================================================================= # =============================================================================
# next command # next command
# ============================================================================= # =============================================================================
suggest_next = typer.Typer(help="Suggest next steps for a project.") def suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None:
@suggest_next.command("next")
def cmd_suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None:
"""Suggest next steps based on project history and heuristics. """Suggest next steps based on project history and heuristics.
Uses simple rules: Uses simple rules:
@@ -487,11 +455,7 @@ def cmd_suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> No
# ============================================================================= # =============================================================================
# review command # review command
# ============================================================================= # =============================================================================
review = typer.Typer(help="Review all projects.") def review() -> None:
@review.command("review")
def cmd_review() -> None:
"""Show an overview of all projects. """Show an overview of all projects.
Displays: Displays:
@@ -695,10 +659,233 @@ def _update_readme_autogen(slug: str, session: Session) -> None:
storage.update_readme_autogen(slug, "NEXT_STEPS", next_steps_content.strip()) storage.update_readme_autogen(slug, "NEXT_STEPS", next_steps_content.strip())
# =============================================================================
# task add command
# =============================================================================
def task_add(
slug: str = typer.Argument(..., help="Project slug"),
title: str = typer.Argument(..., help="Task title"),
section: str = typer.Option("Inbox", help="Section name (Inbox, Próximo, En curso, Bloqueado, En espera, Hecho)"),
) -> None:
"""Add a task to the project's TASKS.md.
Valid sections: Inbox, Próximo, En curso, Bloqueado, En espera, Hecho.
If the section doesn't exist, it will be created.
"""
# Validate project exists
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
# Valid sections (Spanish names as per spec)
valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"}
if section not in valid_sections:
typer.echo(
f"Error: Invalid section '{section}'. Valid sections are: {', '.join(sorted(valid_sections))}",
err=True,
)
raise typer.Exit(code=1)
# Read current TASKS.md
tasks_content = storage.read_tasks(slug)
# Parse sections and tasks
sections = _parse_tasks_sections(tasks_content)
# Add task to the specified section
new_task = f"- [ ] {title}"
if section in sections:
sections[section].append(new_task)
else:
# Create new section with the task
sections[section] = [new_task]
# Rebuild TASKS.md content
new_content = _build_tasks_content(sections)
storage.write_tasks(slug, new_content)
typer.echo(f"Added task to '{section}' section: {title}")
# =============================================================================
# task move command
# =============================================================================
def task_move(
slug: str = typer.Argument(..., help="Project slug"),
task_title: str = typer.Argument(..., help="Task title to search for"),
to_section: str = typer.Argument(..., help="Destination section"),
) -> None:
"""Move a task to a different section in TASKS.md.
Searches for the task by title in any section and moves it to the destination.
Tasks moved to 'Hecho' will be marked as completed ([x]).
"""
# Validate project exists
if not storage._project_path(slug).exists():
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
raise typer.Exit(code=1)
# Valid sections
valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"}
if to_section not in valid_sections:
typer.echo(
f"Error: Invalid section '{to_section}'. Valid sections are: {', '.join(sorted(valid_sections))}",
err=True,
)
raise typer.Exit(code=1)
# Read current TASKS.md
tasks_content = storage.read_tasks(slug)
# Parse sections and tasks
sections = _parse_tasks_sections(tasks_content)
# Find the task
found_task = None
found_in_section = None
task_pattern = f"- [ ] {task_title}"
task_pattern_done = f"- [x] {task_title}"
for section_name, tasks in sections.items():
for task in tasks:
if task == task_pattern or task == task_pattern_done:
found_task = task
found_in_section = section_name
break
if found_task:
break
if not found_task:
typer.echo(f"Error: Task '{task_title}' not found in any section.", err=True)
raise typer.Exit(code=1)
if found_in_section == to_section:
typer.echo(f"Task '{task_title}' is already in '{to_section}' section.")
return
# Remove from original section
sections[found_in_section].remove(found_task)
if not sections[found_in_section]:
del sections[found_in_section]
# Determine checkbox state based on destination section
if to_section == "Hecho":
new_task = f"- [x] {task_title}"
else:
new_task = f"- [ ] {task_title}"
# Add to destination section
if to_section in sections:
sections[to_section].append(new_task)
else:
sections[to_section] = [new_task]
# Rebuild TASKS.md content
new_content = _build_tasks_content(sections)
storage.write_tasks(slug, new_content)
checkbox = "[x]" if to_section == "Hecho" else "[ ]"
typer.echo(f"Moved task '{task_title}' from '{found_in_section}' to '{to_section}' ({checkbox})")
# =============================================================================
# Helper functions for TASKS.md parsing
# =============================================================================
def _parse_tasks_sections(content: str) -> dict:
"""Parse TASKS.md content into sections and tasks.
Returns a dict mapping section names to lists of task strings.
Normalizes English section names to Spanish and merges duplicate sections.
"""
import re
# Mapping from English to Spanish section names
section_mapping = {
"Next": "Próximo",
"In Progress": "En curso",
"Blocked": "Bloqueado",
"Waiting": "En espera",
"Done": "Hecho",
"Inbox": "Inbox", # Same in both
}
sections = {}
current_section = None
current_tasks = []
# Match section headers (## Section Name)
section_pattern = re.compile(r"^##\s+(.+)$")
# Match task items (- [ ] task or - [x] task)
task_pattern = re.compile(r"^(- \[[ x]\]) (.+)$")
def save_current_section():
"""Save current section tasks, merging if normalized name already exists."""
if current_section is not None and current_tasks:
if current_section in sections:
sections[current_section].extend(current_tasks)
else:
sections[current_section] = current_tasks
for line in content.split("\n"):
section_match = section_pattern.match(line)
if section_match:
# Save previous section if exists
save_current_section()
raw_section = section_match.group(1)
# Normalize section name
current_section = section_mapping.get(raw_section, raw_section)
current_tasks = []
else:
task_match = task_pattern.match(line)
if task_match and current_section is not None:
checkbox = task_match.group(1)
title = task_match.group(2)
current_tasks.append(f"{checkbox} {title}")
# Save last section
save_current_section()
return sections
def _build_tasks_content(sections: dict) -> str:
"""Build TASKS.md content from sections dict.
Maintains the order of sections as specified.
"""
section_order = ["Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"]
lines = ["# Tasks", ""]
for section_name in section_order:
lines.append(f"## {section_name}")
tasks = sections.get(section_name, [])
if tasks:
for task in tasks:
lines.append(task)
else:
lines.append("-")
lines.append("")
# Add any sections not in the standard order
for section_name in sections:
if section_name not in section_order:
lines.append(f"## {section_name}")
tasks = sections[section_name]
if tasks:
for task in tasks:
lines.append(task)
else:
lines.append("-")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
# Register all commands at module level for direct access # Register all commands at module level for direct access
__all__ = [ __all__ = [
"init_project", "init_project",
"list_projects", "list_projects_cmd",
"show_project", "show_project",
"start_session", "start_session",
"add_note_cmd", "add_note_cmd",
@@ -706,4 +893,6 @@ __all__ = [
"add_change", "add_change",
"suggest_next", "suggest_next",
"review", "review",
"task_add",
"task_move",
] ]

View File

@@ -4,7 +4,7 @@ import typer
from tracker.cli.commands import ( from tracker.cli.commands import (
init_project, init_project,
list_projects, list_projects_cmd,
show_project, show_project,
start_session, start_session,
add_note_cmd, add_note_cmd,
@@ -12,6 +12,8 @@ from tracker.cli.commands import (
add_change, add_change,
suggest_next, suggest_next,
review, review,
task_add,
task_move,
) )
app = typer.Typer( app = typer.Typer(
@@ -19,17 +21,26 @@ app = typer.Typer(
help="Personal Project Tracker CLI - Track your projects with Markdown and YAML", help="Personal Project Tracker CLI - Track your projects with Markdown and YAML",
) )
# Sub-command group for task management
task_app = typer.Typer(help="Task management commands")
# Register all subcommands
app.add_typer_command(init_project, name="init-project") # Register task subcommands
app.add_typer_command(list_projects, name="list") task_app.command("add")(task_add)
app.add_typer_command(show_project, name="show") task_app.command("move")(task_move)
app.add_typer_command(start_session, name="start")
app.add_typer_command(add_note_cmd, name="note")
app.add_typer_command(stop_session, name="stop") # Register all commands
app.add_typer_command(add_change, name="change") app.command("init-project")(init_project)
app.add_typer_command(suggest_next, name="next") app.command("list")(list_projects_cmd)
app.add_typer_command(review, name="review") app.command("show")(show_project)
app.command("start")(start_session)
app.command("note")(add_note_cmd)
app.command("stop")(stop_session)
app.command("change")(add_change)
app.command("next")(suggest_next)
app.command("review")(review)
app.add_typer(task_app, name="task")
@app.callback() @app.callback()

View File

@@ -5,6 +5,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import yaml
from ..models import Project from ..models import Project
@@ -49,8 +51,8 @@ def create_project(
type=type, type=type,
status="inbox", status="inbox",
tags=tags, tags=tags,
root_path=_PROJECTS_ROOT / slug, root_path=str(_PROJECTS_ROOT / slug),
repo_path=repo_path, repo_path=str(repo_path) if repo_path else None,
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
) )
@@ -60,12 +62,20 @@ def create_project(
def get_project(slug: str) -> Optional[Project]: def get_project(slug: str) -> Optional[Project]:
""" """
Get a project by slug. Get a project by slug.
Note: This reads from file system - placeholder for storage integration. Reads from meta/project.yaml in the project directory.
""" """
meta_path = _get_project_meta_path(slug) meta_path = _get_project_meta_path(slug)
if not meta_path.exists(): if not meta_path.exists():
return None return None
# TODO: Load from storage (YAML)
try:
with open(meta_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data:
return Project(**data)
except (yaml.YAMLError, TypeError):
pass
return None return None

View File

@@ -1,6 +1,7 @@
"""Storage layer for file-based persistence.""" """Storage layer for file-based persistence."""
import json import json
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -117,22 +118,34 @@ class FileStorage:
f.write(new_content) f.write(new_content)
def write_session_file(self, session: Session) -> None: def write_session_file(self, session: Session) -> None:
"""Crea projects/<slug>/sessions/YYYY-MM-DD_HHMM.md""" """Crea projects/<slug>/sessions/YYYY-MM-DD_HHMM.md y el JSON correspondiente."""
from tracker.storage.markdown_writer import MarkdownWriter from tracker.storage.markdown_writer import MarkdownWriter
sessions_path = self._sessions_path(session.project_slug) sessions_path = self._sessions_path(session.project_slug)
sessions_path.mkdir(parents=True, exist_ok=True) sessions_path.mkdir(parents=True, exist_ok=True)
started = session.started_at started = session.started_at
filename = started.strftime("%Y-%m-%d_%H%M.md") md_filename = started.strftime("%Y-%m-%d_%H%M.md")
session_path = sessions_path / filename json_filename = f"{session.id}.json"
# Write markdown file
writer = MarkdownWriter() writer = MarkdownWriter()
content = writer.format_session_file(session) content = writer.format_session_file(session)
md_path = sessions_path / md_filename
with open(session_path, "w", encoding="utf-8") as f: with open(md_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
# Write JSON file for tracking
json_path = sessions_path / json_filename
session_data = session.model_dump(mode="json")
# Serialize datetime objects to ISO format
if isinstance(session_data.get("started_at"), datetime):
session_data["started_at"] = session_data["started_at"].isoformat()
if isinstance(session_data.get("ended_at"), datetime):
session_data["ended_at"] = session_data["ended_at"].isoformat()
with open(json_path, "w", encoding="utf-8") as f:
json.dump(session_data, f, indent=2, ensure_ascii=False, default=str)
def active_session_path(self) -> Path: def active_session_path(self) -> Path:
"""Returns Path to projects/.active_session.json""" """Returns Path to projects/.active_session.json"""
return self.projects_root / ".active_session.json" return self.projects_root / ".active_session.json"
@@ -156,3 +169,98 @@ class FileStorage:
path = self.active_session_path() path = self.active_session_path()
if path.exists(): if path.exists():
path.unlink() path.unlink()
def write_file(self, slug: str, relative_path: str, content: str) -> None:
"""Escribe contenido a un archivo en el proyecto.
Args:
slug: Project slug.
relative_path: Relative path within the project.
content: Content to write.
"""
file_path = self._project_path(slug) / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
def read_file(self, slug: str, relative_path: str) -> str:
"""Lee contenido de un archivo en el proyecto.
Args:
slug: Project slug.
relative_path: Relative path within the project.
Returns:
File content or empty string if not found.
"""
file_path = self._project_path(slug) / relative_path
if not file_path.exists():
return ""
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
def extract_autogen_section(self, slug: str, section: str) -> str:
"""Extrae contenido de una seccion AUTOGEN del README.md.
Args:
slug: Project slug.
section: Section name (e.g., "SESSIONS", "NEXT_STEPS").
Returns:
Content between AUTOGEN markers, or empty string if not found.
"""
from tracker.storage.markdown_reader import MarkdownReader
reader = MarkdownReader()
content = self.read_readme(slug)
return reader.extract_autogen_section(content, section)
def get_recent_sessions(self, slug: str, limit: int = 5) -> list:
"""Obtiene las sesiones mas recientes de un proyecto.
Args:
slug: Project slug.
limit: Maximum number of sessions to return.
Returns:
List of Session objects sorted by date (most recent first).
"""
from tracker.models.session import Session
sessions_path = self._sessions_path(slug)
if not sessions_path.exists():
return []
sessions = []
for json_file in sessions_path.glob("*.json"):
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
session = Session(**data)
sessions.append(session)
except (json.JSONDecodeError, TypeError):
continue
# Sort by started_at descending
sessions.sort(key=lambda s: s.started_at, reverse=True)
return sessions[:limit]
def list_projects(self) -> list[str]:
"""Lista todos los slugs de proyectos.
Returns:
List of project slugs.
"""
if not self.projects_root.exists():
return []
return [d.name for d in self.projects_root.iterdir() if d.is_dir() and not d.name.startswith(".")]
def project_exists(self, slug: str) -> bool:
"""Verifica si un proyecto existe.
Args:
slug: Project slug.
Returns:
True if project exists.
"""
return self._project_path(slug).exists()