---
name: reading-plan-and-page-pace-planner
description: Planifica tu lectura día a día para que termines cada libro antes de la fecha de su club de lectura, curso o examen - a partir de recuentos de páginas o palabras (o duración de audiolibro), tu velocidad de lectura, los minutos que tienes cada día de la semana, viajes y días ocupados. Distribuye las páginas de manera uniforme, mantiene días de margen antes de cada fecha límite y días de recuperación semanales, se detiene al final de los capítulos, advierte cuando una fecha límite es inalcanzable y replanifica desde donde estás. Incluye un planificador en Python de la biblioteca estándar.
---
# Plan de Lectura y Ritmo de Páginas
Ayudas a los lectores a dejar de empollar 200 páginas la noche antes del club de lectura. La gente sabe aproximadamente cuánto tiempo tiene (media hora entre semana, más el fin de semana) pero no qué significa eso en páginas para una novela de 245 páginas que vence en dos semanas, un libro de trabajo denso y un clásico de 880 páginas en el mismo mes. Conviertes libros, velocidad de lectura y minutos libres en un plan diario concreto ("Mar: p. 1-27"), mantienes margen para la vida real, y dices temprano y claramente cuando una fecha límite no se puede cumplir y qué lo arreglaría.
## Archivos en esta habilidad
- `scripts/reading_plan.py` - lee un JSON de plan (libros con páginas, palabras o minutos de audiolibro, página de inicio, dificultad, fecha límite, inicios de capítulos; velocidad de lectura en palabras por minuto o páginas por hora; minutos por día de la semana; viajes y excepciones; días de recuperación; días de margen); programa primero la fecha límite más temprana, distribuye la carga uniformemente (o lo antes posible), ajusta las paradas diarias a los finales de capítulo, e imprime un resumen del libro, hallazgos RP01-RP06, una tabla día a día con rangos de páginas, puntos de control semanales y consejos de recuperación; `--from` + `--at "Título=página"` replanifica a mitad de camino (solo biblioteca estándar de Python 3.8+)
- `references/reading-speed-and-estimates.md` - medir tu velocidad, palabras por página según el tipo de libro, factores de dificultad, audiolibros
- `references/pacing-and-catch-up.md` - márgenes, días de recuperación, qué hacer cuando te atrasas o una fecha límite es imposible
- `templates/reading-plan.md` - el plan para entregar (compatible con calendario)
- `examples/book-club.json` - Noviembre-Diciembre: una novela para un club de lectura del jueves, un libro de trabajo ya comenzado en la p. 23, Middlemarch para un club de clásicos, un audiolibro sin fecha límite, domingos de recuperación, un viaje de fin de semana y dos días inusuales
- `examples/book-club-plan.md` - la salida real del planificador en ese archivo
## Flujo de trabajo
### 1. Recopilar los datos de entrada
Pregunta por: cada libro con su recuento de páginas (o recuento de palabras; la edición importa), dónde están ahora, la fecha límite y qué es (reunión, examen), si es fácil o denso; su velocidad de lectura (o cronometra una prueba de 10 minutos con `references/reading-speed-and-estimates.md`); minutos realistas por día de la semana, no los ideales; viajes conocidos, turnos tardíos y días libres; qué día de la semana debe quedar libre como día de recuperación; y cuántos días antes de cada reunión quieren estar listos (margen, predeterminado 1-2).
### 2. Ejecutar el planificador
```
python3 scripts/reading_plan.py examples/book-club.json
python3 scripts/reading_plan.py examples/book-club.json --mode asap
python3 scripts/reading_plan.py examples/book-club.json --from 2026-11-20 --at "Thinking in Systems=110"
python3 scripts/reading_plan.py plan.json --json
python3 scripts/reading_plan.py --self-test
```
Salida 0 = cada fecha límite cumplida, 1 = al menos un libro no se puede terminar a tiempo, 2 = entrada incorrecta. Sin shell: minutos necesarios = páginas x palabras por página / palabras por minuto x dificultad; compara con los minutos disponibles hasta cada fecha de finalización (fecha límite menos margen, días de recuperación excluidos), primero la fecha límite más temprana.
### 3. Leer el resultado
- RP01 (alto): un libro no cabe antes de su fecha límite. El mensaje da el déficit en páginas y los minutos extra por día de lectura que lo cerrarían. Cuando el plan es inviable, el planificador lee a ritmo completo antes, para que los libros anteriores terminen temprano y liberen tiempo.
- RP02 / RP04 (medio): el plan solo funciona consumiendo el margen, o usa más del 90% de tu tiempo en su tramo más ocupado - una mala semana lo rompe.
- RP03 (bajo): días más largos que la sesión máxima - divídelos. RP05/RP06 (información): libros terminados, fechas límite cercanas, audiolibros.
- Revisa la tabla de días para los fines de semana pesados y los puntos de control semanales para "dónde debería estar para el domingo".
### 4. Arreglar un plan imposible
Ofrece opciones en este orden: comienza el libro largo ahora en paralelo (mueve su lógica de fecha límite antes), agrega 10-20 minutos en algunos días, usa los días de recuperación, cambia parte a audiolibro para los desplazamientos, hojea o salta secciones claramente marcadas, o acepta llegar a la reunión a medias y dilo. Vuelve a ejecutar después de cada cambio. Consulta `references/pacing-and-catch-up.md`.
### 5. Entregar
Completa `templates/reading-plan.md`: resumen por libro, el plan semana a semana (fechas y rangos de páginas, listo para un calendario o una nota de marcapáginas), la regla de recuperación y el comando de replanificación. Ofrece una replanificación rápida cada vez que informen su página actual.
## Resultado de ejemplo
En `examples/book-club.json` (240 ppm, 20-60 min al día, domingos libres para recuperación, 2 días de margen) *The Remains of the Day* se termina el 12 Nov para el club de lectura del 19 Nov, deteniéndose al final de los capítulos, y *Thinking in Systems* (denso, factor 1.3, desde la p. 23) el 27 Nov para el grupo del 1 Dic. *Middlemarch* (316,000 palabras, unas 22 horas) no se puede terminar para el club de clásicos del 17 Dic: faltan unas 452 páginas, final proyectado 11 Ene (RP01, alto). Solución: unos +17 min en cada uno de los 42 días de lectura, o comenzarlo ahora junto con los otros dos. El sábado tranquilo de 120 minutos se marca como dos sesiones (RP03), y el audiolibro de Piranesi (6 h 40 a 1.25x) sigue después de Middlemarch.
## Reglas
- Planifica con el tiempo que la gente realmente tiene, y mantén al menos un día de margen antes de cada reunión.
- Nunca llenes días en silencio: si no cabe, di qué fecha límite falla, por cuántas páginas, y qué lo arreglaría.
- Usa el recuento de páginas de la edición; cuando solo se conocen recuentos de palabras, di que las páginas son estimaciones.
- Deja los días de recuperación vacíos en el plan - son para atrasarse, no para leer más.
- La lectura debe seguir siendo agradable: sugiere saltar o cambiar de formato antes de sugerir sesiones de 2 horas todos los días.
- Replanifica desde la página actual siempre que el lector esté atrasado o adelantado; no los hagas "ponerse al día" con un horario antiguo.
FILE:references/reading-speed-and-estimates.md
# Velocidad de lectura y estimaciones
## Mide tu velocidad (10 minutos)
1. Elige el libro real (o uno similar), comienza al inicio de un capítulo, pon un temporizador de 10 minutos y lee con normalidad.
2. Cuenta las palabras en tres líneas completas, divide por 3 = palabras por línea; cuenta las líneas en una página completa = líneas por página.
3. palabras por página = palabras por línea x líneas por página; palabras por minuto = páginas leídas x palabras por página / 10.
O simplemente cronometra unas páginas y usa `"pages_per_hour"` en lugar de palabras por minuto.
## Valores típicos
| Lector / material | Palabras por minuto |
|---|---|
| Lectura cuidadosa, no ficción densa, segunda lengua | 150-200 |
| Adulto promedio, ficción | 200-260 |
| Lector rápido de ocio | 300-400 |
Hojear no es leer: no planifiques con velocidades superiores a 450.
| Tipo de libro | Palabras por página |
|---|---|
| Libro de bolsillo de mercado masivo | 300-350 |
| Ficción de bolsillo comercial | 250-300 |
| No ficción de tapa dura con notas | 350-450 |
| Letra grande, libros de capítulos infantiles | 150-200 |
| Libro electrónico | usa el recuento de palabras (página de la tienda o herramientas que lo muestren) y el predeterminado de 275 palabras por página |
## Factor de dificultad
`"difficulty"` multiplica el tiempo de lectura: 1.0 ficción fácil, 1.2-1.4 no ficción densa, clásicos con oraciones largas, libros técnicos con diagramas, 1.5+ libros de texto de los que tomas notas, poesía, lectura en segunda lengua.
## Recuento de páginas vs recuento de palabras
- Usa el recuento de páginas de la edición que tiene el lector; las ediciones del mismo libro pueden diferir en un 30%.
- Con ambos `"pages"` y `"words"`, el planificador deriva las palabras por página de esa edición (Middlemarch 316,000 / 880 = 359).
- Con solo palabras, las páginas son estimaciones (palabras / palabras por página); dilo en el plan.
## Audiolibros
- `"audio_minutes"` es la duración, `"speed"` la velocidad de reproducción: tiempo necesario = duración / velocidad (400 min a 1.25x = 320 min).
- El audio encaja en desplazamientos, tareas domésticas y caminatas: di si esos minutos son extra o ya están contados en los minutos diarios.
- Las posiciones se muestran como h:mm del audio.
FILE:references/pacing-and-catch-up.md
# Ritmo y recuperación
## Cómo programa el planificador
- Primero la fecha límite más temprana: el libro con la fecha de finalización más cercana (fecha límite menos días de margen) se lee primero; los libros sin fecha límite van al final.
- Modo uniforme (predeterminado): para cada tramo hasta la fecha límite más ajustada usa la parte igual más pequeña de tus minutos diarios que aún termine a tiempo, así los días de 30 minutos podrían convertirse en días de 20 minutos cuando hay espacio. Después de esa fecha límite, el siguiente tramo se calcula de nuevo.
- Modo ASAP: usa todos los minutos cada día; los libros terminan lo antes posible.
- Si una fecha límite no se puede cumplir incluso a ritmo completo, cada día antes se planifica a ritmo completo y el libro se marca RP01.
- Las paradas diarias se mueven al final de un capítulo cuando uno está dentro de `chapter_snap_pages` (predeterminado 8) y un cuarto de las páginas del día.
## Márgenes y días de recuperación
- Los días de margen (`buffer_days`, predeterminado 1) se sitúan entre el final planificado y la reunión: tiempo para releer notas o absorber una mala semana.
- Los días de recuperación (`catch_up_days`, p. ej. domingo) no tienen lectura planificada. Úsalos solo si estás atrasado; si vas al día, son tiempo libre.
- Las excepciones (viajes, turnos tardíos, un sábado tranquilo) anulan los minutos del día de la semana para esas fechas.
## Cuando te atrasas
1. Perdiste un día: lee las páginas de ese día en el próximo día de recuperación, o agrega un tercio de ellas a cada uno de los próximos tres días.
2. Atrasado por más de dos días: replanifica desde hoy con `--from <hoy> --at "<Título>=<página actual>"` en lugar de perseguir el plan antiguo.
3. Aún sin tiempo (RP01): elige una - comienza el libro largo en paralelo ahora, agrega 10-20 minutos en algunos días, usa el audiolibro para los desplazamientos, hojea secciones marcadas (prefacios, apéndices, notas), o decide llegar a medias y díselo al grupo.
## Señales de que el plan está demasiado ajustado
- RP04: más del 90% de tu tiempo usado en un tramo; cualquier enfermedad o semana tardía lo rompe.
- RP02: el libro solo cabe usando los días de margen.
- Muchos días marcados "2 sesiones" (RP03): considera distribuir la lectura entre mañana y tarde a propósito.
## Consejos para clubes de lectura
- Anota la página o el capítulo que el grupo discutirá si no es todo el libro, y úsalo como `pages` del libro.
- Para clásicos largos, pregunta al club si la reunión cubre la primera mitad; planifica dos fechas límite.
- Mantén una nota adhesiva en el libro con los puntos de control semanales del plan.
FILE:templates/reading-plan.md
# Plan de lectura: <nombre> (<inicio> a <última fecha límite>)
**Ritmo:** <ppm> palabras/min, unas <páginas> páginas en una sesión de 30 minutos
**Tiempo:** <minutos por día de la semana>; día(s) de recuperación: <día>; margen: <n> día(s) antes de cada reunión
**Veredicto:** <cada fecha límite se cumple | LIBRO no se puede terminar para FECHA - ver opciones>
## Libros
| Libro | Páginas restantes | Tiempo necesario | Reunión / fecha límite | Terminar para | Final planificado |
|---|---|---|---|---|---|
| <título> | <pp> | <h:mm> | <fecha - evento> | <fecha> | <fecha> |
## Semana a semana
### Semana del <lunes fecha>
- Lun: <Libro p. a-b>
- Mar: <...>
- Dom: recuperación / libre
**Punto de control domingo por la noche:** <Libro p. X>
## Si una fecha límite no cabe
- Déficit: <páginas> páginas de <libro> para <fecha>
- Opciones: <+N min en días de lectura | comenzar ahora en paralelo | audiolibro para desplazamientos | hojear capítulos x-y | llegar a medias>
- Elegido: <...>
## Atrasarse
- ¿Perdiste un día? Usa <día de recuperación> o agrega <n> páginas a los próximos tres días.
- Más de dos días atrasado: replanifica desde hoy (`--from <fecha> --at "<título>=<página>"`).
FILE:examples/book-club.json
{
"name": "Plan de lectura: Nov-Dic 2026 (dos clubes de lectura y un libro de trabajo)",
"start": "2026-11-03",
"reader": {"words_per_minute": 240, "words_per_page": 280},
"minutes": {"mon": 30, "tue": 30, "wed": 20, "thu": 30, "fri": 25, "sat": 60, "sun": 45},
"catch_up_days": ["sun"],
"buffer_days": 2,
"max_session_minutes": 60,
"chapter_snap_pages": 8,
"exceptions": [
{"dates": "2026-11-13..2026-11-15", "minutes": 0, "note": "viaje de fin de semana"},
{"dates": "2026-11-26", "minutes": 15, "note": "turno tardío"},
{"dates": "2026-12-05", "minutes": 120, "note": "sábado tranquilo"}
],
"books": [
{"title": "The Remains of the Day", "pages": 245, "words_per_page": 265, "deadline": "2026-11-19", "event": "club de lectura del jueves",
"chapters": [1, 9, 45, 101, 125, 165, 212, 239]},
{"title": "Thinking in Systems", "pages": 218, "start_page": 23, "difficulty": 1.3, "deadline": "2026-12-01", "event": "grupo de lectura del equipo"},
{"title": "Middlemarch", "words": 316000, "pages": 880, "deadline": "2026-12-17", "event": "club de clásicos"},
{"title": "Piranesi (audiolibro)", "audio_minutes": 400, "speed": 1.25}
]
}
FILE:examples/book-club-plan.md
# Plan de lectura: Nov-Dic 2026 (dos clubes de lectura y un libro de trabajo)
Inicio 2026-11-03, modo uniforme, 2 día(s) de margen antes de cada fecha límite, días de recuperación: dom. Lectura planificada: 36 h 44 min en 81 días.
**Veredicto: 1 libro(s) no se pueden terminar para su fecha límite - cambia el plan**
## Libros
| Libro | Restante | Tiempo est. | Fecha límite | Terminar para | Final planificado | Días de sobra | Promedio por día de lectura |
|---|---|---|---|---|---|---|---|
| The Remains of the Day | 245 pp | 4:31 h | 2026-11-19 (club de lectura del jueves) | 2026-11-17 | 2026-11-12 | 7 | 27 pp |
| Thinking in Systems | 196 pp | 4:57 h | 2026-12-01 (grupo de lectura del equipo) | 2026-11-29 | 2026-11-27 | 4 | 16 pp |
| Middlemarch | 880 pp | 21:57 h | 2026-12-17 (club de clásicos) | 2026-12-15 | 2027-01-11 | -25 | 23 pp |
| Piranesi (audiolibro) | 6:40 audio | 5:20 h | - | - | 2027-01-22 | - | 0:36 |
## Hallazgos
- **ALTO RP01** Middlemarch no se puede terminar para 2026-12-17 (club de clásicos): faltan unas 452 páginas a tu ritmo; final proyectado 2027-01-11. Opciones: +17 min en cada uno de los 42 días de lectura hasta entonces, leer más rápido, hojear o saltar unas 452 páginas, o comenzarlo antes
- **BAJO RP03** 1 día(s) planifican más de 60 minutos (p. ej. 2026-12-05: 120 min) - divide en dos sesiones
- **INFO RP06** Piranesi (audiolibro) es un audiolibro: el plan cuenta el tiempo de escucha a velocidad 1.25x; los desplazamientos y las tareas domésticas pueden cubrirlo
## Día a día
| Fecha | Día | Minutos | Leer | Nota |
|---|---|---|---|---|
| 2026-11-03 | Mar | 30 | The Remains of the Day p. 1-27 (27 pp) | |
| 2026-11-04 | Mié | 19 | The Remains of the Day p. 28-44 (17 pp) | |
| 2026-11-05 | Jue | 30 | The Remains of the Day p. 45-71 (27 pp) | |
| 2026-11-06 | Vie | 25 | The Remains of the Day p. 72-94 (23 pp) | |
| 2026-11-07 | Sáb | 60 | The Remains of the Day p. 95-148 (54 pp) | |
| 2026-11-08 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2026-11-09 | Lun | 30 | The Remains of the Day p. 149-175 (27 pp) | |
| 2026-11-10 | Mar | 30 | The Remains of the Day p. 176-202 (27 pp) | |
| 2026-11-11 | Mié | 20 | The Remains of the Day p. 203-221 (19 pp) | |
| 2026-11-12 | Jue | 30 | The Remains of the Day p. 222-245 (24 pp); Thinking in Systems p. 23-24 (2 pp) | |
| 2026-11-13 | Vie | - | - | viaje de fin de semana |
| 2026-11-14 | Sáb | - | - | viaje de fin de semana |
| 2026-11-15 | Dom | - | - | viaje de fin de semana |
| 2026-11-16 | Lun | 30 | Thinking in Systems p. 25-44 (20 pp) | |
| 2026-11-17 | Mar | 30 | Thinking in Systems p. 45-64 (20 pp) | terminar para The Remains of the Day |
| 2026-11-18 | Mié | 20 | Thinking in Systems p. 65-77 (13 pp) | |
| 2026-11-19 | Jue | 30 | Thinking in Systems p. 78-97 (20 pp) | FECHA LÍMITE The Remains of the Day - club de lectura del jueves |
| 2026-11-20 | Vie | 25 | Thinking in Systems p. 98-113 (16 pp) | |
| 2026-11-21 | Sáb | 60 | Thinking in Systems p. 114-153 (40 pp) | |
| 2026-11-22 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2026-11-23 | Lun | 30 | Thinking in Systems p. 154-172 (19 pp) | |
| 2026-11-24 | Mar | 30 | Thinking in Systems p. 173-192 (20 pp) | |
| 2026-11-25 | Mié | 20 | Thinking in Systems p. 193-205 (13 pp) | |
| 2026-11-26 | Jue | 15 | Thinking in Systems p. 206-215 (10 pp) | turno tardío |
| 2026-11-27 | Vie | 25 | Thinking in Systems p. 216-218 (3 pp); Middlemarch p. 1-14 (14 pp) | |
| 2026-11-28 | Sáb | 60 | Middlemarch p. 15-54 (40 pp) | |
| 2026-11-29 | Dom | - | - | recuperación / libre (45 min disponibles); terminar para Thinking in Systems |
| 2026-11-30 | Lun | 30 | Middlemarch p. 55-74 (20 pp) | |
| 2026-12-01 | Mar | 30 | Middlemarch p. 75-94 (20 pp) | FECHA LÍMITE Thinking in Systems - grupo de lectura del equipo |
| 2026-12-02 | Mié | 20 | Middlemarch p. 95-107 (13 pp) | |
| 2026-12-03 | Jue | 30 | Middlemarch p. 108-128 (21 pp) | |
| 2026-12-04 | Vie | 25 | Middlemarch p. 129-144 (16 pp) | |
| 2026-12-05 | Sáb | 120 | Middlemarch p. 145-224 (80 pp) | sábado tranquilo; 2 sesiones |
| 2026-12-06 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2026-12-07 | Lun | 30 | Middlemarch p. 225-244 (20 pp) | |
| 2026-12-08 | Mar | 30 | Middlemarch p. 245-265 (21 pp) | |
| 2026-12-09 | Mié | 20 | Middlemarch p. 266-278 (13 pp) | |
| 2026-12-10 | Jue | 30 | Middlemarch p. 279-298 (20 pp) | |
| 2026-12-11 | Vie | 25 | Middlemarch p. 299-315 (17 pp) | |
| 2026-12-12 | Sáb | 60 | Middlemarch p. 316-355 (40 pp) | |
| 2026-12-13 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2026-12-14 | Lun | 30 | Middlemarch p. 356-375 (20 pp) | |
| 2026-12-15 | Mar | 30 | Middlemarch p. 376-395 (20 pp) | terminar para Middlemarch |
| 2026-12-16 | Mié | 20 | Middlemarch p. 396-408 (13 pp) | |
| 2026-12-17 | Jue | 30 | Middlemarch p. 409-428 (20 pp) | FECHA LÍMITE Middlemarch - club de clásicos |
| 2026-12-18 | Vie | 25 | Middlemarch p. 429-445 (17 pp) | |
| 2026-12-19 | Sáb | 60 | Middlemarch p. 446-485 (40 pp) | |
| 2026-12-20 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2026-12-21 | Lun | 30 | Middlemarch p. 486-505 (20 pp) | |
| 2026-12-22 | Mar | 30 | Middlemarch p. 506-525 (20 pp) | |
| 2026-12-23 | Mié | 20 | Middlemarch p. 526-539 (14 pp) | |
| 2026-12-24 | Jue | 30 | Middlemarch p. 540-559 (20 pp) | |
| 2026-12-25 | Vie | 25 | Middlemarch p. 560-575 (16 pp) | |
| 2026-12-26 | Sáb | 60 | Middlemarch p. 576-615 (40 pp) | |
| 2026-12-27 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2026-12-28 | Lun | 30 | Middlemarch p. 616-635 (20 pp) | |
| 2026-12-29 | Mar | 30 | Middlemarch p. 636-656 (21 pp) | |
| 2026-12-30 | Mié | 20 | Middlemarch p. 657-669 (13 pp) | |
| 2026-12-31 | Jue | 30 | Middlemarch p. 670-689 (20 pp) | |
| 2027-01-01 | Vie | 25 | Middlemarch p. 690-706 (17 pp) | |
| 2027-01-02 | Sáb | 60 | Middlemarch p. 707-746 (40 pp) | |
| 2027-01-03 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2027-01-04 | Lun | 30 | Middlemarch p. 747-766 (20 pp) | |
| 2027-01-05 | Mar | 30 | Middlemarch p. 767-786 (20 pp) | |
| 2027-01-06 | Mié | 20 | Middlemarch p. 787-799 (13 pp) | |
| 2027-01-07 | Jue | 30 | Middlemarch p. 800-819 (20 pp) | |
| 2027-01-08 | Vie | 25 | Middlemarch p. 820-836 (17 pp) | |
| 2027-01-09 | Sáb | 60 | Middlemarch p. 837-876 (40 pp) | |
| 2027-01-10 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2027-01-11 | Lun | 30 | Middlemarch p. 877-880 (4 pp); Piranesi (audiolibro) 0:00-0:30 | |
| 2027-01-12 | Mar | 30 | Piranesi (audiolibro) 0:30-1:08 | |
| 2027-01-13 | Mié | 20 | Piranesi (audiolibro) 1:08-1:33 | |
| 2027-01-14 | Jue | 30 | Piranesi (audiolibro) 1:33-2:10 | |
| 2027-01-15 | Vie | 25 | Piranesi (audiolibro) 2:10-2:41 | |
| 2027-01-16 | Sáb | 60 | Piranesi (audiolibro) 2:41-3:56 | |
| 2027-01-17 | Dom | - | - | recuperación / libre (45 min disponibles) |
| 2027-01-18 | Lun | 30 | Piranesi (audiolibro) 3:56-4:34 | |
| 2027-01-19 | Mar | 30 | Piranesi (audiolibro) 4:34-5:11 | |
| 2027-01-20 | Mié | 20 | Piranesi (audiolibro) 5:11-5:36 | |
| 2027-01-21 | Jue | 30 | Piranesi (audiolibro) 5:36-6:14 | |
| 2027-01-22 | Vie | 21 | Piranesi (audiolibro) 6:14-6:40 | |
## Puntos de control semanales (dónde deberías estar para el domingo por la noche)
- 2026-11-08: The Remains of the Day p. 148
- 2026-11-15: Thinking in Systems p. 24
- 2026-11-22: Thinking in Systems p. 153
- 2026-11-29: Middlemarch p. 54
- 2026-12-06: Middlemarch p. 224
- 2026-12-13: Middlemarch p. 355
- 2026-12-20: Middlemarch p. 485
- 2026-12-27: Middlemarch p. 615
- 2027-01-03: Middlemarch p. 746
- 2027-01-10: Middlemarch p. 876
- 2027-01-17: Piranesi (audiolibro) 3:56
## Si te atrasas
- The Remains of the Day: un día perdido cuesta unas 27 páginas. Recupéralas en el(los) día(s) libre(s) 2026-11-08, o agrega 10 páginas a cada uno de los próximos 3 días de lectura. Replanifica en cualquier momento con `--from <fecha> --at "The Remains of the Day=<página>"`.
- Thinking in Systems: un día perdido cuesta unas 16 páginas. Recupéralas en el(los) día(s) libre(s) 2026-11-22, 2026-11-29, o agrega 6 páginas a cada uno de los próximos 3 días de lectura. Replanifica en cualquier momento con `--from <fecha> --at "Thinking in Systems=<página>"`.
- Middlemarch: un día perdido cuesta unas 23 páginas. Recupéralas en el(los) día(s) libre(s) 2026-11-29, 2026-12-06, 2026-12-13, o agrega 8 páginas a cada uno de los próximos 3 días de lectura. Replanifica en cualquier momento con `--from <fecha> --at "Middlemarch=<página>"`.
FILE:scripts/reading_plan.py
#!/usr/bin/env python3
"""Planificador de plan de lectura y ritmo de páginas (solo biblioteca estándar, Python 3.8+).
Convierte una lista de libros (páginas, recuentos de palabras o duración de audiolibro), tu velocidad de lectura y los
minutos que tienes cada día en un plan día a día con rangos de páginas que cumple cada fecha límite
(club de lectura, curso, examen), mantiene días de margen antes de cada fecha límite y días de recuperación en la
semana, distribuye la carga uniformemente y te dice qué hacer si te atrasas.
Uso:
reading_plan.py PLAN.json [--mode even|asap] [--from YYYY-MM-DD] [--at "Título=página"]... [--json]
reading_plan.py --self-test
Salida: 0 = cada fecha límite se cumple, 1 = al menos un libro no se puede terminar para su fecha límite,
2 = entrada incorrecta.
"""
import argparse, datetime, json, math, re, sys
DAYS = ["lun", "mar", "mié", "jue", "vie", "sáb", "dom"]
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
class InputError(Exception):
pass
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s))
except ValueError:
raise InputError("%s debe ser YYYY-MM-DD, se obtuvo %r" % (what, s))
def expand_dates(spec):
spec = str(spec)
if ".." in spec:
a, b = spec.split("..", 1)
a, b = pdate(a.strip(), "rango de fechas"), pdate(b.strip(), "rango de fechas")
if b < a:
raise InputError("el rango de fechas %s termina antes de comenzar" % spec)
return [a + datetime.timedelta(days=i) for i in range((b - a).days + 1)]
return [pdate(spec.strip(), "fecha")]
# ---------------------------------------------------------------- modelo
def load(cfg, start_override=None, at=None):
if not isinstance(cfg, dict) or not isinstance(cfg.get("books"), list) or not cfg["books"]:
raise InputError("el plan necesita una lista 'books' no vacía")
reader = cfg.get("reader") or {}
wpm = float(reader.get("words_per_minute", 230))
wpp_default = float(reader.get("words_per_page", 275))
pph = reader.get("pages_per_hour")
if wpm <= 0 or wpp_default <= 0:
raise InputError("words_per_minute y words_per_page deben ser positivos")
start = pdate(start_override or cfg.get("start") or datetime.date.today().isoformat(), "inicio")
mins = cfg.get("minutes")
if isinstance(mins, (int, float)):
mins = {d: mins for d in DAYS}
if not isinstance(mins, dict):
raise InputError("'minutes' debe ser un número o {lun: .., mar: ..}")
mins = {k.lower()[:3]: float(v) for k, v in mins.items()}
bad = set(mins) - set(DAYS)
if bad:
raise InputError("clave(s) de día de la semana desconocida(s) en minutes: %s" % ", ".join(sorted(bad)))
catch = {d.lower()[:3] for d in cfg.get("catch_up_days", [])}
exc = {}
for e in cfg.get("exceptions", []):
for d in expand_dates(e.get("dates") or e.get("date")):
exc[d] = (float(e.get("minutes", 0)), e.get("note", ""))
books = []
at = at or {}
for i, b in enumerate(cfg["books"], 1):
t = b.get("title") or "Libro %d" % i
if "audio_minutes" in b:
speed = float(b.get("speed", 1.0))
total = float(b["audio_minutes"])
unit_min = 1.0 / speed # minutos de tu tiempo por minuto de audio
units, kind = total, "audio"
done = float(at.get(t, b.get("start_minute", 0)))
else:
wpp = float(b.get("words_per_page") or (float(b["words"]) / float(b["pages"]) if b.get("words") and b.get("pages") else wpp_default))
pages = float(b["pages"]) if b.get("pages") else (float(b["words"]) / wpp if b.get("words") else None)
if not pages or pages <= 0:
raise InputError("el libro %r necesita pages, words o audio_minutes" % t)
diff = float(b.get("difficulty", 1.0))
if pph and not b.get("words"):
unit_min = 60.0 / float(pph) * diff
else:
unit_min = wpp / wpm * diff
units, kind = round(pages), "pages"
sp = at.get(t, b.get("start_page", 1))
done = max(0.0, float(sp) - 1)
dl = pdate(b["deadline"], "fecha límite de %r" % t) if b.get("deadline") else None
chapters = sorted(int(c) for c in b.get("chapters", []))
books.append({"title": t, "units": float(units), "kind": kind, "unit_min": unit_min, "done": min(done, float(units)),
"deadline": dl, "event": b.get("event", ""), "chapters": chapters, "order": i})
buf = int(cfg.get("buffer_days", 1))
return {"start": start, "mins": mins, "catch": catch, "exc": exc, "books": books, "buffer": buf,
"max_session": float(cfg.get("max_session_minutes", 0) or 0), "snap": int(cfg.get("chapter_snap_pages", 8)),
"name": cfg.get("name", "Plan de lectura"), "wpm": wpm}
def capacity(m, d):
"""(minutos planificados, minutos disponibles incl. recuperación, nota)."""
if d in m["exc"]:
v, note = m["exc"][d]
return v, v, note or ("sin lectura" if v == 0 else "")
wd = DAYS[d.weekday()]
v = m["mins"].get(wd, 0.0)
if wd in m["catch"]:
return 0.0, v, "día de recuperación"
return v, v, ""
def plan(m, mode="even"):
F = []
books = [b for b in m["books"]]
for b in books:
b["need"] = (b["units"] - b["done"]) * b["unit_min"]
if b["need"] <= 0:
F.append(("info", "RP05", "%s ya está terminado - se omitió" % b["title"]))
b["target"] = None
if b["deadline"] and b["deadline"] < m["start"]:
if b["need"] > 0:
F.append(("medium", "RP05", "%s: la fecha límite %s ya pasó - planificado sin fecha límite" % (b["title"], b["deadline"])))
b["deadline"] = None
if b["deadline"]:
b["target"] = b["deadline"] - datetime.timedelta(days=m["buffer"])
if b["target"] < m["start"]:
b["target"] = m["start"]
F.append(("medium", "RP05", "%s: la fecha límite %s está demasiado cerca para %d día(s) de margen - planificando hasta la fecha límite" % (b["title"], b["deadline"], m["buffer"])))
todo = [b for b in books if b["need"] > 0]
dated = sorted([b for b in todo if b["target"]], key=lambda b: (b["target"], b["order"]))
undated = [b for b in todo if not b["target"]]
seq = dated + undated
last = max([b["deadline"] for b in dated] or [m["start"]])
# horizonte: suficientes días para todo a capacidad completa, más margen
weekly = sum(m["mins"].get(d, 0) for d in DAYS if d not in m["catch"]) or 1
total_need = sum(b["need"] for b in seq)
horizon_end = max(last, m["start"]) + datetime.timedelta(days=int(total_need / weekly * 7) + 60)
days, d = [], m["start"]
while d <= horizon_end:
p, a, note = capacity(m, d)
days.append({"date": d, "cap": p, "avail": a, "note": note, "planned": 0.0, "items": []})
d += datetime.timedelta(days=1)
idx = {x["date"]: i for i, x in enumerate(days)}
# factor de carga por segmento (modo uniforme): la parte uniforme más pequeña de la capacidad que cumple cada objetivo
load = [1.0] * len(days)
if mode == "even" and dated:
seg_start, k0 = 0, 0
while k0 < len(dated):
best, bk = 0.0, k0
cum = 0.0
for k in range(k0, len(dated)):
cum += dated[k]["need"]
ti = idx[dated[k]["target"]]
cap = sum(x["cap"] for x in days[seg_start:ti + 1])
r = cum / cap if cap > 0 else float("inf")
if r >= best:
best, bk = r, k
L = min(best * 1.03, 1.0) # 3% de margen para paradas al final de capítulo y redondeo
end = idx[dated[bk]["target"]]
for i in range(seg_start, end + 1):
load[i] = L
seg_start, k0 = end + 1, bk + 1
# después de la última fecha límite: libros sin fecha límite a la carga promedio del plan hasta ahora (al menos 0.5)
tail = max(0.5, max(load[:seg_start] or [1.0]))
for i in range(seg_start, len(days)):
load[i] = tail if undated else 1.0
# asignar (primero la fecha límite más temprana), rastreando la posición de cada libro
qi = 0
pos = {b["title"]: b["done"] for b in seq}
for i, day in enumerate(days):
budget = day["cap"] * load[i]
while budget > 1e-9 and qi < len(seq):
b = seq[qi]
remaining = (b["units"] - pos[b["title"]]) * b["unit_min"]
use = min(budget, remaining)
before = pos[b["title"]]
after = before + use / b["unit_min"]
if after > b["units"] - 1e-6:
after = b["units"]
elif b["kind"] == "pages":
snapped = snap(after, b, m["snap"], use / b["unit_min"])
if snapped != after:
after = snapped
use = (after - before) * b["unit_min"]
budget = use # detenerse al final del capítulo: el día termina aquí
pos[b["title"]] = after
day["items"].append({"title": b["title"], "from": before, "to": after, "kind": b["kind"], "minutes": use})
day["planned"] += use
budget -= use
if after >= b["units"] - 1e-6:
b["finish"] = day["date"]; qi += 1
elif budget <= 1e-9 or use <= 1e-9:
break
if qi >= len(seq):
break
days = [x for x in days if x["date"] <= max([b.get("finish", m["start"]) for b in seq] + [last])]
for b in seq:
b.setdefault("finish", None)
# hallazgos
for b in dated:
f = b["finish"]
if f is None or f > b["deadline"]:
avail = [x for x in days if x["date"] <= b["deadline"]]
share = sum(it["minutes"] for x in avail for it in x["items"] if it["title"] == b["title"])
short_min = b["need"] - share
# también contar el tiempo de recuperación/margen que estaría disponible hasta la fecha límite
reading_days = sum(1 for x in avail if x["avail"] > 0) or 1
short_units = short_min / b["unit_min"]
msg = "%s no se puede terminar para %s%s: faltan unas %d %s a tu ritmo; final proyectado %s. Opciones: +%d min en cada uno de los %d días de lectura hasta entonces, leer más rápido, hojear o saltar unas %d %s, o comenzarlo antes" % (
b["title"], b["deadline"], " (%s)" % b["event"] if b["event"] else "", math.ceil(short_units), "páginas" if b["kind"] == "pages" else "minutos de audio",
f or "más allá del plan", math.ceil(short_min / reading_days), reading_days, math.ceil(short_units), "páginas" if b["kind"] == "pages" else "minutos de audio")
F.append(("high", "RP01", msg))
elif f > b["target"]:
F.append(("medium", "RP02", "%s termina el %s, dentro de los %d día(s) de margen antes de %s - sin espacio para un día perdido" % (b["title"], f, m["buffer"], b["deadline"])))
if mode == "even":
peak = max(load) if load else 0
if 0.9 <= peak <= 1.0 and not any(f[1] == "RP01" for f in F):
F.append(("medium", "RP04", "el plan usa el %d%% de tu tiempo de lectura planificado en su tramo más ocupado - una mala semana te empujará al margen" % round(peak * 100)))
if m["max_session"]:
long = [x for x in days if x["planned"] > m["max_session"] + 0.5]
if long:
F.append(("low", "RP03", "%d día(s) planifican más de %d minutos (p. ej. %s: %d min) - divide en dos sesiones" % (
len(long), m["max_session"], long[0]["date"], round(long[0]["planned"]))))
for b in seq:
if b["kind"] == "audio":
F.append(("info", "RP06", "%s es un audiolibro: el plan cuenta el tiempo de escucha a velocidad %sx; los desplazamientos y las tareas domésticas pueden cubrirlo" % (b["title"], ("%.2f" % (1 / b["unit_min"])).rstrip("0").rstrip("."))))
F.sort(key=lambda f: (SEV[f[0]], f[1]))
return {"days": days, "books": seq, "all_books": books, "findings": F, "load": load}
def snap(after, b, snap_pages, day_units):
"""Mover el punto de parada de un día al final de un capítulo si uno está cerca (dentro de snap_pages y 25% del día)."""
if not b["chapters"] or snap_pages <= 0:
return after
lim = min(snap_pages, max(1.0, day_units * 0.25))
ends = [c - 1 for c in b["chapters"] if c - 1 > 0]
best = None
for e in ends:
if abs(e - after) <= lim and e > after - day_units + 0.5 and (best is None or abs(e - after) < abs(best - after)):
best = e
return float(best) if best is not None else after
# ---------------------------------------------------------------- renderizado
def fmt_item(it):
if it["kind"] == "audio":
return "%s %s-%s" % (it["title"], hm(it["from"]), hm(it["to"]))
a = int(round(it["from"])) + 1
z = int(round(it["to"]))
if z < a:
z = a
return "%s p. %d-%d (%d pp)" % (it["title"], a, z, z - a + 1)
def hm(x):
x = int(round(x))
return "%d:%02d" % (x // 60, x % 60)
def checkpoints(r):
out = []
pos = {}
for x in r["days"]:
for it in x["items"]:
pos[it["title"]] = it
if x["date"].weekday() == 6 and pos: # domingos
out.append((x["date"], "; ".join("%s %s" % (t, ("p. %d" % round(it["to"]) if it["kind"] == "pages" else hm(it["to"]))) for t, it in pos.items() if it["to"] < next(b["units"] for b in r["books"] if b["title"] == t) - 1e-6) or "todos los libros actuales terminados"))
return out
def report(r, m, mode):
hi = [f for f in r["findings"] if f[0] == "high"]
out = ["# %s" % m["name"], ""]
tot_min = sum(x["planned"] for x in r["days"])
out.append("Inicio %s, modo %s, %d día(s) de margen antes de cada fecha límite, días de recuperación: %s. Lectura planificada: %d h %02d min en %d días." % (
m["start"], mode, m["buffer"], ", ".join(sorted(m["catch"], key=DAYS.index)) or "ninguno", tot_min // 60, tot_min % 60, len(r["days"])))
out += ["", "**Veredicto: %s**" % ("%d libro(s) no se pueden terminar para su fecha límite - cambia el plan" % len(hi) if hi else "cada fecha límite se cumple"), ""]
out += ["## Libros", "", "| Libro | Restante | Tiempo est. | Fecha límite | Terminar para | Final planificado | Días de sobra | Promedio por día de lectura |", "|---|---|---|---|---|---|---|---|"]
for b in r["all_books"]:
left = b["units"] - b["done"]
rd = [x for x in r["days"] if any(it["title"] == b["title"] for it in x["items"])]
avg = left / len(rd) if rd else 0
spare = (b["deadline"] - b["finish"]).days if b.get("finish") and b["deadline"] else None
out.append("| %s | %s | %s | %s | %s | %s | %s | %s |" % (
b["title"], ("%d pp" % round(left)) if b["kind"] == "pages" else hm(left) + " audio", hm(b["need"]) + " h",
"%s %s" % (b["deadline"], "(%s)" % b["event"] if b["event"] else "") if b["deadline"] else "-",
b["target"] or "-", b.get("finish") or ("terminado" if b["need"] <= 0 else "NO A TIEMPO"),
"-" if spare is None else spare, ("%.0f pp" % avg) if b["kind"] == "pages" else hm(avg)))
out += ["", "## Hallazgos", ""]
out += ["- **%s %s** %s" % (s.upper(), c, msg) for s, c, msg in r["findings"]] or ["- ninguno"]
out += ["", "## Día a día", "", "| Fecha | Día | Minutos | Leer | Nota |", "|---|---|---|---|---|"]
events = {}
for b in r["all_books"]:
if b["deadline"]:
events.setdefault(b["deadline"], []).append("FECHA LÍMITE %s%s" % (b["title"], " - " + b["event"] if b["event"] else ""))
if b["target"] and b["target"] != b["deadline"]:
events.setdefault(b["target"], []).append("terminar para %s" % b["title"])
for x in r["days"]:
note = [x["note"]] if x["note"] else []
if x["note"] == "día de recuperación" or (x["planned"] < 0.5 and x["avail"] > 0):
note = ["recuperación / libre (%d min disponibles)" % x["avail"]] if x["avail"] > 0 else note
note += events.get(x["date"], [])
if m["max_session"] and x["planned"] > m["max_session"] + 0.5:
note.append("2 sesiones")
out.append("| %s | %s | %s | %s | %s |" % (x["date"], x["date"].strftime("%a"), round(x["planned"]) if x["planned"] >= 0.5 else "-",
"; ".join(fmt_item(it) for it in x["items"]) or "-", "; ".join(note)))
cps = checkpoints(r)
if cps:
out += ["", "## Puntos de control semanales (dónde deberías estar para el domingo por la noche)", ""]
out += ["- %s: %s" % (d, s) for d, s in cps]
out += ["", "## Si te atrasas", ""]
for b in r["books"]:
if b["kind"] != "pages" or not b["deadline"]:
continue
rd = [x for x in r["days"] if any(it["title"] == b["title"] for it in x["items"])]
if not rd:
continue
day_pp = sum(it["to"] - it["from"] for x in rd for it in x["items"] if it["title"] == b["title"]) / len(rd)
spare = [x for x in r["days"] if x["date"] <= b["deadline"] and x["date"] >= rd[0]["date"] and x["avail"] > 0 and x["planned"] < 0.5]
out.append("- %s: un día perdido cuesta unas %d páginas. Recupéralas en %s, o agrega %d páginas a cada uno de los próximos 3 días de lectura. Replanifica en cualquier momento con `--from <fecha> --at \"%s=<página>\"`." % (
b["title"], round(day_pp), ("el(los) día(s) libre(s) " + ", ".join(str(x["date"]) for x in spare[:3])) if spare else "ningún día libre antes de la fecha límite", math.ceil(day_pp / 3), b["title"]))
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------- autoprueba
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def run(cfg, mode="even", **kw):
m = load(cfg, **kw)
return plan(m, mode), m
base = {"start": "2026-11-02", "reader": {"words_per_minute": 250, "words_per_page": 250}, "minutes": 30, "buffer_days": 0}
r, m = run(dict(base, books=[{"title": "A", "pages": 60, "deadline": "2026-11-08"}]), "asap")
t("asap: 30 páginas/día a 1 página/min termina el día 2", r["books"][0]["finish"] == datetime.date(2026, 11, 3))
r, m = run(dict(base, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]))
t("uniforme: distribuido en la semana (~10 min/día + 3% de margen)", r["books"][0]["finish"] >= datetime.date(2026, 11, 7) and 10 <= r["days"][0]["planned"] <= 10.31)
r, m = run(dict(base, books=[{"title": "A", "pages": 300, "deadline": "2026-11-08"}]))
t("inviable -> RP01 alto", any(f[1] == "RP01" and f[0] == "high" for f in r["findings"]))
t("RP01 sugiere minutos extra", any("+13 min" in f[2] for f in r["findings"] if f[1] == "RP01"))
r, m = run(dict(base, buffer_days=2, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]))
t("días de margen: terminar 2 días antes", r["books"][0]["finish"] == datetime.date(2026, 11, 6))
r, m = run(dict(base, catch_up_days=["dom"], books=[{"title": "A", "pages": 60, "deadline": "2026-11-08"}]))
t("el domingo de recuperación no tiene lectura planificada", r["days"][-1]["planned"] == 0 and r["books"][0]["finish"] == datetime.date(2026, 11, 7))
r, m = run(dict(base, exceptions=[{"dates": "2026-11-03..2026-11-04", "minutes": 0, "note": "viaje"}], books=[{"title": "A", "pages": 50, "deadline": "2026-11-08"}]))
t("el rango de excepción no tiene lectura", all(x["planned"] == 0 for x in r["days"][1:3]))
two = [{"title": "Tarde", "pages": 30, "deadline": "2026-11-20"}, {"title": "Pronto", "pages": 60, "deadline": "2026-11-04"}]
r, m = run(dict(base, books=two))
t("primero la fecha límite más temprana", r["days"][0]["items"][0]["title"] == "Pronto")
t("segmento ajustado luego carga más ligera", r["load"][0] > 0.6 and r["load"][5] < 0.2)
r, m = run(dict(base, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]), at={"A": 36})
t("--at reanuda desde la página", abs(sum(x["planned"] for x in r["days"]) - 35) < 0.01)
r, m = run(dict(base, books=[{"title": "W", "words": 25000, "deadline": "2026-11-08"}]))
t("recuento de palabras -> páginas vía words_per_page", r["all_books"][0]["units"] == 100)
r, m = run(dict(base, books=[{"title": "H", "pages": 70, "difficulty": 1.5, "deadline": "2026-11-08"}]))
t("la dificultad escala el tiempo", abs(r["books"][0]["need"] - 105) < 0.01)
r, m = run(dict(base, books=[{"title": "Au", "audio_minutes": 300, "speed": 1.5, "deadline": "2026-11-08"}]))
t("audiolibro a 1.5x = 200 min", abs(r["books"][0]["need"] - 200) < 0.01 and any(f[1] == "RP06" for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "C", "pages": 70, "deadline": "2026-11-08", "chapters": [1, 12, 23, 31, 42, 55]}]))
ends = [round(it["to"]) for x in r["days"] for it in x["items"]]
t("el ajuste de capítulo se detiene al final de los capítulos", 11 in ends and 22 in ends and ends[-1] == 70)
t("un elemento por libro por día", all(len({it["title"] for it in x["items"]}) == len(x["items"]) for x in r["days"]))
t("los rangos de páginas no se superponen", all(fmt_item(it) for x in r["days"] for it in x["items"]) and
[int(round(it["from"])) for x in r["days"] for it in x["items"]][1:] == [int(round(it["to"])) for x in r["days"] for it in x["items"]][:-1])
r, m = run(dict(base, max_session_minutes=20, minutes=60, books=[{"title": "A", "pages": 300, "deadline": "2026-11-08"}]))
t("sesiones largas -> RP03", any(f[1] == "RP03" for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "A", "pages": 200, "deadline": "2026-11-08"}]))
t("plan ajustado -> RP04 o RP01", any(f[1] in ("RP04", "RP01") for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "A", "pages": 30}, {"title": "B", "pages": 60, "deadline": "2026-11-05"}]))
t("libro sin fecha límite después de los fechados", r["books"][-1]["title"] == "A" and r["books"][-1]["finish"])
r, m = run(dict(base, books=[{"title": "A", "pages": 20, "start_page": 21}]))
t("libro terminado -> RP05", any(f[1] == "RP05" for f in r["findings"]))
t("pages_per_hour", abs(load(dict(base, reader={"pages_per_hour": 30}, books=[{"title": "P", "pages": 30}]))["books"][0]["unit_min"] - 2) < 1e-9)
for bad in [dict(base, books=[]), dict(base, books=[{"title": "x"}]), dict(base, minutes={"funday": 3}, books=[{"title": "a", "pages": 3}]),
dict(base, books=[{"title": "a", "pages": 3, "deadline": "2026-13-01"}])]:
try:
run(bad); okk = False
except InputError:
okk = True
t("entrada incorrecta rechazada: %s" % (list(bad.get("minutes", {})) if isinstance(bad.get("minutes"), dict) else (bad["books"][0] if bad["books"] else "sin libros")), okk)
r, m = run(dict(base, books=[{"title": "Viejo", "pages": 30, "deadline": "2026-10-01"}]))
t("fecha límite pasada -> RP05, planificado sin fecha límite", any(f[1] == "RP05" for f in r["findings"]) and r["books"][0]["finish"])
rep = report(*run(dict(base, books=two)), "even")
t("el informe tiene veredicto, tabla de días y consejos de recuperación", "Veredicto" in rep and "| Fecha |" in rep and "Si te atrasas" in rep)
for name, okk in tests:
print(("PASS " if okk else "FAIL ") + name)
n = sum(1 for _, okk in tests if okk)
print("%d/%d aprobadas" % (n, len(tests)))
return 0 if n == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("plan", nargs="?")
ap.add_argument("--mode", choices=["even", "asap"], default="even")
ap.add_argument("--from", dest="start", help="replanificar desde esta fecha (YYYY-MM-DD)")
ap.add_argument("--at", action="append", default=[], help='"Título=página" (o "Título=minuto" para audiolibros): dónde estás ahora')
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.plan:
ap.error("se requiere JSON de plan (o --self-test)")
try:
try:
cfg = json.load(open(a.plan, encoding="utf-8"))
except (OSError, ValueError) as e:
raise InputError(str(e))
at = {}
for s in a.at:
if "=" not in s:
raise InputError("--at necesita Título=página, se obtuvo %r" % s)
k, v = s.rsplit("=", 1)
try:
at[k.strip()] = float(v)
except ValueError:
raise InputError("--at página debe ser un número, se obtuvo %r" % v)
titles = {b.get("title") for b in cfg.get("books", [])} if isinstance(cfg, dict) else set()
for k in at:
if k not in titles:
raise InputError("--at: no hay ningún libro titulado %r" % k)
m = load(cfg, a.start, at)
r = plan(m, a.mode)
except (InputError, KeyError, TypeError, ValueError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
out = {"books": [{"title": b["title"], "deadline": str(b["deadline"]) if b["deadline"] else None, "finish": str(b.get("finish")) if b.get("finish") else None,
"minutes_needed": round(b["need"])} for b in r["all_books"]],
"days": [{"date": str(x["date"]), "minutes": round(x["planned"]), "read": [fmt_item(it) for it in x["items"]]} for x in r["days"]],
"findings": [{"severity": s, "code": c, "message": msg} for s, c, msg in r["findings"]]}
print(json.dumps(out, indent=2))
else:
print(report(r, m, a.mode))
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())
Discusión
0 comentarios