Compare commits
10 Commits
40a33d773b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| a3dcdb8577 | |||
| aedc647fff | |||
| bd48122db2 | |||
| 4212a17e4b | |||
| cc523607d1 | |||
| b4593c69af | |||
| 4d99213d75 | |||
| 2735562b65 | |||
| 4e67062c99 | |||
| b36b60353d |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(python:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
131
.gitignore
vendored
Normal file
131
.gitignore
vendored
Normal 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
21
CLAUDE.md
Normal 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
190
README.md
@@ -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
246
backlog/mvp-1.md
Normal 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:00–11: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
|
||||||
33
examples/demo-project/CHANGELOG.md
Normal file
33
examples/demo-project/CHANGELOG.md
Normal 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
|
||||||
59
examples/demo-project/LOG.md
Normal file
59
examples/demo-project/LOG.md
Normal 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
|
||||||
|
|
||||||
|
---
|
||||||
21
examples/demo-project/README.md
Normal file
21
examples/demo-project/README.md
Normal 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*
|
||||||
31
examples/demo-project/TASKS.md
Normal file
31
examples/demo-project/TASKS.md
Normal 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
|
||||||
0
examples/demo-project/assets/.gitkeep
Normal file
0
examples/demo-project/assets/.gitkeep
Normal file
0
examples/demo-project/docs/.gitkeep
Normal file
0
examples/demo-project/docs/.gitkeep
Normal file
13
examples/demo-project/meta/project.yaml
Normal file
13
examples/demo-project/meta/project.yaml
Normal 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"
|
||||||
53
examples/demo-project/sessions/2026-03-20_1000.md
Normal file
53
examples/demo-project/sessions/2026-03-20_1000.md
Normal 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
|
||||||
75
examples/demo-project/sessions/2026-03-22_1430.md
Normal file
75
examples/demo-project/sessions/2026-03-22_1430.md
Normal 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
|
||||||
4
projects/mi-proyecto/CHANGELOG.md
Normal file
4
projects/mi-proyecto/CHANGELOG.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
_Project changes_
|
||||||
|
|
||||||
50
projects/mi-proyecto/LOG.md
Normal file
50
projects/mi-proyecto/LOG.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Log
|
||||||
|
|
||||||
|
_Project activity log_
|
||||||
|
|
||||||
|
## 2026-03-24 23:40–00: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:09–00: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.
|
||||||
|
|
||||||
50
projects/mi-proyecto/README.md
Normal file
50
projects/mi-proyecto/README.md
Normal 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_
|
||||||
20
projects/mi-proyecto/TASKS.md
Normal file
20
projects/mi-proyecto/TASKS.md
Normal 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
|
||||||
12
projects/mi-proyecto/meta/project.yaml
Normal file
12
projects/mi-proyecto/meta/project.yaml
Normal 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
|
||||||
28
projects/mi-proyecto/sessions/2026-03-24_2340.md
Normal file
28
projects/mi-proyecto/sessions/2026-03-24_2340.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Sesion: 2026-03-24 23:40–00: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
|
||||||
28
projects/mi-proyecto/sessions/2026-03-25_0009.md
Normal file
28
projects/mi-proyecto/sessions/2026-03-25_0009.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Sesion: 2026-03-25 00:09–00: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
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
85
resumen/2026-03-24-1245-resumen.md
Normal file
85
resumen/2026-03-24-1245-resumen.md
Normal 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
102
tests/conftest.py
Normal 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
337
tests/test_flow.py
Normal 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
220
tests/test_models.py
Normal 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
|
||||||
209
tests/test_project_service.py
Normal file
209
tests/test_project_service.py
Normal 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()
|
||||||
241
tests/test_session_service.py
Normal file
241
tests/test_session_service.py
Normal 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
193
tests/test_storage.py
Normal 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 == []
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user