Especificación inicial y prototipo HTML de Mis 8 horas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
da68ca0617
6 files changed
+1614
No files matched your search
+12
@@ -0,0 +1,12 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Contiene la API key de ClickUp
|
||||
prototipo-html/config.js
|
||||
|
||||
# Datos locales del backend
|
||||
backend/data/
|
||||
@@ -0,0 +1,51 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guía para trabajar en **Mis 8 horas**: dashboard personal de ClickUp (NestJS + Angular) para cumplir 8 horas diarias de tareas completadas. La especificación completa está en `README.md`; léela antes de implementar algo nuevo. Este archivo solo recoge lo que no debe romperse.
|
||||
|
||||
## Estado del proyecto
|
||||
|
||||
Fase de planificación: el prototipo `prototipo-html/` es la referencia de comportamiento. `backend/`, `frontend/` y `contracts/` se crean siguiendo el plan de la sección 10 del README (empezar por `contracts/` y `MetricsService` con sus pruebas).
|
||||
|
||||
## Estructura
|
||||
|
||||
- `backend/`: NestJS. Único lugar con la API key y con las reglas de negocio.
|
||||
- `frontend/`: Angular 18+, standalone, signals, `@if`/`@for`. Solo muestra y envía acciones.
|
||||
- `contracts/`: tipos TypeScript compartidos, importados con `@contracts/*`.
|
||||
- `prototipo-html/`: respaldo autónomo. No tocar salvo que se pida.
|
||||
|
||||
## Reglas que no se negocian
|
||||
|
||||
- **La API key de ClickUp vive solo en `backend/.env`.** Nunca en Angular, en respuestas de `/api/*`, en logs ni en git. `prototipo-html/config.js` también la contiene: no subirlo ni mostrarlo.
|
||||
- **Las métricas se calculan solo en el backend** (`MetricsService`). Angular no recalcula HPC, deuda ni rendimiento.
|
||||
- **`MetricsService` es puro:** sin HTTP, sin leer el reloj. "Hoy" entra como parámetro para que las pruebas sean deterministas.
|
||||
- **Fechas con Luxon y zona `America/Lima`** (`TZ`). Nunca `new Date()` sin zona para agrupar por día.
|
||||
- **El día de una tarea es su `due_date`.** `start_date` no se usa. La tarea se cuenta completa en ese día.
|
||||
- **Día libre:** meta, hueco y deuda en 0, y no cuenta como día transcurrido en el rendimiento semanal. Feriado y vacaciones se tratan igual.
|
||||
- **Sprint de un día:** se saca del **nombre** de la lista (`Sprint 19 (10/5 - 10/19)`), nunca de `start_date`/`due_date` de la lista. En un día límite gana el Sprint que empieza más tarde.
|
||||
- **Al mover o crear una tarea,** fijar las 12:00 hora local con `due_date_time: false`.
|
||||
- **Crear tarea:** estado `asignado`, estimado > 0, y Clientes, Jefatura, Sprint y asignado obligatorios. Los avisos (día libre, pasarse de la meta) no bloquean.
|
||||
- **Custom Field "Jefatura" es de tipo `users`:** se escribe como `{ add: [id], rem: [] }`. Una lista simple da `400 Invalid Value`.
|
||||
- Sin "deshacer" en las acciones sobre tareas; los errores se corrigen en ClickUp.
|
||||
|
||||
## Convenciones
|
||||
|
||||
- **Idioma:** textos de interfaz, mensajes de error, commits y documentación en español. Identificadores de código en inglés. Textos en minúsculas tipo oración, sin mayúsculas sostenidas.
|
||||
- **Horas:** siempre `H:MM` en pantalla; los campos aceptan `2:30`, `2.5` o `2`. Internamente, milisegundos (`...Ms`).
|
||||
- **Frontend:** los componentes leen signals de `PlannerStore` y llaman a sus acciones; no usan `ApiService` directamente. Respetar los tokens de color del README (sección 9) y `prefers-color-scheme`.
|
||||
- **Backend:** validar entradas con `class-validator`; errores con formato Nest (`{ statusCode, message }`) y los códigos 401/400/502/429 del README. Caché con `@nestjs/cache-manager`, invalidada al crear, mover, estimar o cerrar.
|
||||
- Los ajustes y días libres se guardan en `backend/data/settings.json` y `backend/data/days-off.json`.
|
||||
|
||||
## Pruebas
|
||||
|
||||
- Jest. `MetricsService` debe tener todas las pruebas de la sección 5.4 del README antes de pasar al resto del backend.
|
||||
- `SprintsService` y `ClickupClient` (paginación, mapeo de Clientes, envío de Jefatura) también tienen pruebas obligatorias.
|
||||
- Probar contra ClickUp real solo con tareas de prueba que se borren después.
|
||||
|
||||
## Pendiente de confirmar (no asumir)
|
||||
|
||||
Estas decisiones dependen de quien mantiene el dashboard de la empresa; no cambiar el comportamiento sin confirmarlo (README, 2.2):
|
||||
|
||||
- Qué estados cuentan como completados (hoy `closed` o `done`, lo que incluye `cancelado`).
|
||||
- Si las subtareas suman por separado o se descuentan del padre.
|
||||
- Si las horas cerradas en un día libre suman como extra o se excluyen.
|
||||
- Día por fecha de vencimiento o de inicio.
|
||||
@@ -0,0 +1,674 @@
|
||||
# Mis 8 horas
|
||||
|
||||
Dashboard personal conectado a ClickUp para cumplir una meta de **8 horas diarias** de tareas completadas. Muestra qué tareas ya suman horas, cuáles faltan cerrar y cuánto falta planificar, sugiere tareas para llenar el hueco del día y permite crear, mover, estimar y cerrar tareas sin salir de la página.
|
||||
|
||||
- **Backend:** NestJS. Habla con ClickUp, guarda la API key, calcula todas las métricas y expone una API propia.
|
||||
- **Frontend:** Angular. Solo muestra los datos que calcula el backend y envía acciones.
|
||||
|
||||
Es una herramienta **de un solo usuario**: solo muestra las tareas asignadas al dueño de la API key.
|
||||
|
||||
Existe un prototipo funcional en `prototipo-html/` (sección 13), que es también el **respaldo si no se puede levantar NestJS o Angular**. Úsalo como referencia visual y de lógica.
|
||||
|
||||
## Contenido
|
||||
|
||||
1. [Objetivo](#1-objetivo)
|
||||
2. [Reglas de negocio](#2-reglas-de-negocio)
|
||||
3. [Arquitectura](#3-arquitectura)
|
||||
4. [API propia](#4-api-propia)
|
||||
5. [Backend](#5-backend-nestjs)
|
||||
6. [Frontend](#6-frontend-angular)
|
||||
7. [Interfaz](#7-funcionalidades-de-la-interfaz)
|
||||
8. [Textos](#8-textos)
|
||||
9. [Diseño visual](#9-diseño-visual)
|
||||
10. [Plan de desarrollo](#10-plan-de-desarrollo)
|
||||
11. [Criterios de aceptación](#11-criterios-de-aceptación)
|
||||
12. [Ideas para después](#12-ideas-para-después)
|
||||
13. [Prototipo HTML](#13-prototipo-html-respaldo)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
Que el usuario vea de un vistazo, para cada día:
|
||||
|
||||
1. Cuántas horas ya completó (HPC).
|
||||
2. Cuántas horas tiene planificadas y pendientes de cerrar.
|
||||
3. Cuántas horas le faltan planificar para llegar a la meta.
|
||||
4. Qué tareas puede traer a ese día para completar las 8 horas.
|
||||
5. En qué Sprint está ese día y si el día es feriado o de vacaciones.
|
||||
|
||||
Y que pueda, desde la misma página: crear una tarea para el día, editar su estimado, moverla de día y marcarla como `terminado`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reglas de negocio
|
||||
|
||||
Replican el dashboard "Cumplimiento diario" de la empresa. Viven **solo en el backend**, en `MetricsService`.
|
||||
|
||||
| Concepto | Definición |
|
||||
|---|---|
|
||||
| **Día de una tarea** | Fecha de vencimiento (`due_date`) convertida a la zona horaria configurada (America/Lima). |
|
||||
| **Tarea completada** | `status.type` es `closed` o `done`, o la tarea tiene `date_closed`. Ver "Pendiente de confirmar". |
|
||||
| **HPL** (horas planificadas) | Suma de `time_estimate` de las tareas con fecha ese día. |
|
||||
| **HPC** (planificadas completadas) | Suma de `time_estimate` de las tareas con fecha ese día **y** completadas. |
|
||||
| **Por cerrar** | Tareas con fecha ese día, con estimación y no completadas. |
|
||||
| **No suman** | Tareas con fecha ese día sin estimación (`time_estimate` nulo o 0). Se muestran con aviso. |
|
||||
| **Meta** | Configurable, 8 h por defecto. En un día libre (feriado o vacaciones) es **0**. |
|
||||
| **Falta planificar** | `max(0, meta − HPC − por cerrar)` |
|
||||
| **Deuda del día** | `max(0, meta − HPC)` |
|
||||
| **Día libre** | Fecha de la lista de días libres. Meta 0, sin deuda ni "falta planificar", y no cuenta como día transcurrido. |
|
||||
| **Rendimiento semanal** | `HPC acumulado ÷ (meta × días hábiles transcurridos hasta hoy)`. **No** cuenta días futuros ni días libres. |
|
||||
| **Sprint de un día** | El Sprint cuyo rango (`desde - hasta`, tomado del nombre de la lista) contiene el día. Si un día cae en dos Sprints (el `hasta` de uno es el `desde` del siguiente), gana el que empieza más tarde. |
|
||||
| **Sprint abierto** | Sprint cuya fecha final no ha pasado. |
|
||||
|
||||
Colores del rendimiento: verde ≥ 90 %, amarillo 70–89 %, rojo < 70 %.
|
||||
|
||||
### 2.1 Decisiones tomadas
|
||||
|
||||
**Decisión (2026-10-15): tareas de varios días.** Una tarea que abarca varios días se cuenta completa en el día de su `due_date`. `start_date` no se usa (se evaluó un interruptor para elegir entre ambas y se descartó el 2026-10-19). Para repartir trabajo largo, dividir la tarea en subtareas, una por día. Repartir la estimación por rango queda como idea futura.
|
||||
|
||||
**Decisión (2026-10-15): crear tarea.**
|
||||
|
||||
- Se crea siempre con estado `asignado`, así que **no suma al HPC** hasta que se marca como `terminado`. Mientras tanto aparece en "Por cerrar" del día elegido.
|
||||
- Obligatorios: asunto, fecha, Sprint, tiempo estimado **mayor que 0**, Clientes, Jefatura y al menos un asignado.
|
||||
- Si el día queda por encima de la meta, solo se avisa; la tarea se crea igual.
|
||||
- Si el día es libre, se avisa que no debería tener tareas; la tarea se crea igual.
|
||||
- La lista (Sprint) se elige en cada creación, entre los Sprints abiertos ordenados por número. Por defecto, el Sprint al que pertenece el día seleccionado.
|
||||
- El tiempo estimado se propone con el hueco del día (`gapMs`), o 1:00 si el día ya llegó a la meta.
|
||||
|
||||
**Decisión (2026-10-15): días libres.**
|
||||
|
||||
- Feriado y vacaciones tienen **el mismo efecto**. Solo cambia la etiqueta.
|
||||
- ClickUp **no expone sus feriados por API**, y el portal de la empresa los guarda por usuario en el navegador. Por eso son una lista que mantiene el usuario a mano.
|
||||
- En el prototipo se cargaron los 16 feriados nacionales de Chile 2026, según prensa. Confirmar contra el calendario oficial.
|
||||
|
||||
**Decisión (2026-10-15): acciones sobre tareas existentes.** Desde la vista del día se puede editar el estimado, mover la tarea a otra fecha y marcarla como `terminado`. No hay "deshacer": un error se corrige en ClickUp.
|
||||
|
||||
### 2.2 Pendiente de confirmar
|
||||
|
||||
**Con quien mantiene el dashboard de la empresa:**
|
||||
|
||||
- Si el día se define por fecha de vencimiento o de inicio (se usa `due_date` de forma provisional).
|
||||
- **Qué estados cuentan como completados.** En las listas de Sprint los estados son: `to do` (open), `asignado` y `devuelto a desarrollo` (unstarted), `en progreso` y `en pausa` (custom), `cancelado`, `revision consultoria`, `revision cliente` y `listo para deploy` (done), y `terminado` (closed). La regla actual (`closed` o `done`) cuenta como completadas las cuatro de tipo `done`, incluido `cancelado`, que probablemente no debería sumar horas.
|
||||
- Si las subtareas suman por separado o se descuentan del padre.
|
||||
- **Horas cerradas en un día libre:** si suman como extra al rendimiento semanal (comportamiento actual del prototipo) o se excluyen del todo. Recomendado: excluirlas, para que un día libre no altere el porcentaje.
|
||||
- **Diferencia con el portal:** el 6 de octubre el portal mostraba +1:30 de horas adicionales mientras ClickUp sumaba exactamente 8:00 (2:00 + 6:00). Sin explicar; posible dato desactualizado en el portal o una regla distinta.
|
||||
|
||||
---
|
||||
|
||||
## 3. Arquitectura
|
||||
|
||||
```
|
||||
┌──────────────┐ /api/* ┌──────────────────────┐ HTTPS + API key ┌─────────┐
|
||||
│ Angular │ ────────► │ NestJS │ ────────────────► │ ClickUp │
|
||||
│ (localhost: │ ◄──────── │ (localhost:3000) │ ◄──────────────── │ API v2 │
|
||||
│ 4200) │ JSON │ métricas + caché │ └─────────┘
|
||||
└──────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
- La **API key solo existe en el backend** (`.env`). El navegador nunca la ve.
|
||||
- El backend entrega los datos **ya calculados y agrupados por día**. Angular no recalcula HPC ni deuda.
|
||||
- En desarrollo, Angular usa `proxy.conf.json` para mandar `/api` al backend, así no hay problemas de CORS.
|
||||
|
||||
Estructura del repositorio:
|
||||
|
||||
```
|
||||
mis-8-horas/
|
||||
prototipo-html/ # respaldo autónomo en un solo HTML (sección 13)
|
||||
backend/ # nest new backend
|
||||
frontend/ # ng new frontend --standalone --routing=false --style=scss
|
||||
contracts/ # tipos TypeScript compartidos (sección 4.1)
|
||||
README.md
|
||||
CLAUDE.md
|
||||
```
|
||||
|
||||
`contracts/` se importa en ambos proyectos mediante `paths` en sus `tsconfig.json` (`"@contracts/*": ["../contracts/*"]`).
|
||||
|
||||
---
|
||||
|
||||
## 4. API propia
|
||||
|
||||
### 4.1 Tipos compartidos (`contracts/index.ts`)
|
||||
|
||||
```ts
|
||||
export interface ClientTagDto {
|
||||
name: string;
|
||||
color: string | null; // color de la opción en ClickUp, si tiene
|
||||
}
|
||||
|
||||
export interface TaskDto {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
closed: boolean;
|
||||
status: string;
|
||||
color: string;
|
||||
estimateMs: number; // 0 si no tiene
|
||||
dueDate: string | null; // "YYYY-MM-DD" en zona local, o null
|
||||
path: string; // "Espacio / Carpeta / Lista"
|
||||
clients: ClientTagDto[]; // valor del Custom Field "Clientes"; vacío si no tiene
|
||||
daysLate?: number; // solo en backlog, si está vencida
|
||||
fits?: boolean; // solo en sugerencias: cabe en el hueco del día pedido
|
||||
}
|
||||
|
||||
export interface SprintDto {
|
||||
id: string; // id de la lista en ClickUp
|
||||
name: string; // "Sprint 19 (10/5 - 10/19)"
|
||||
number: number; // 19
|
||||
start: string; // "YYYY-MM-DD", tomado del nombre
|
||||
end: string; // "YYYY-MM-DD", tomado del nombre
|
||||
open: boolean; // end >= hoy
|
||||
}
|
||||
|
||||
export interface DayOffDto {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
label: string; // "Feriado", "Vacaciones"…
|
||||
}
|
||||
|
||||
export interface DayDto {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
weekday: number; // 0 = domingo … 6 = sábado
|
||||
isToday: boolean;
|
||||
isPast: boolean;
|
||||
dayOff: string | null; // etiqueta del día libre, o null si es día normal
|
||||
sprint: SprintDto | null;
|
||||
metaMs: number; // 0 si es día libre
|
||||
doneMs: number; // HPC
|
||||
pendingMs: number; // por cerrar
|
||||
gapMs: number; // falta planificar
|
||||
debtMs: number; // deuda
|
||||
done: TaskDto[];
|
||||
pending: TaskDto[]; // ordenadas por estimación desc.
|
||||
noEstimate: TaskDto[];
|
||||
}
|
||||
|
||||
export interface WeekDto {
|
||||
weekStart: string; // lunes, "YYYY-MM-DD"
|
||||
days: DayDto[]; // 5 o 7 según ajustes
|
||||
weekly: {
|
||||
doneMs: number;
|
||||
metaMs: number;
|
||||
elapsedDays: number; // excluye días libres
|
||||
performance: number; // 0–100, redondeado
|
||||
level: 'green' | 'yellow' | 'red';
|
||||
};
|
||||
fetchedAt: string; // ISO, para mostrar "actualizado hace X min"
|
||||
}
|
||||
|
||||
export interface SuggestionsDto {
|
||||
date: string;
|
||||
gapMs: number;
|
||||
items: TaskDto[]; // ya ordenadas (sección 7.4)
|
||||
counts: { all: number; overdue: number; noDate: number };
|
||||
}
|
||||
|
||||
export interface UserRefDto {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NewTaskMetaDto {
|
||||
sprints: SprintDto[]; // solo abiertos, ordenados por número
|
||||
clients: { id: string; name: string }[]; // opciones del campo "Clientes"
|
||||
users: UserRefDto[]; // miembros del espacio de trabajo (Jefatura y Assignees)
|
||||
me: UserRefDto;
|
||||
}
|
||||
|
||||
export interface CreateTaskDto {
|
||||
name: string;
|
||||
date: string; // "YYYY-MM-DD"
|
||||
listId: string; // id del Sprint elegido
|
||||
estimateMs: number; // > 0
|
||||
clientId: string; // id de la opción de "Clientes"
|
||||
bossUserId: string; // usuario de "Jefatura"
|
||||
assigneeIds: string[]; // al menos uno
|
||||
}
|
||||
|
||||
export interface TaskActionResultDto {
|
||||
week: WeekDto;
|
||||
suggestions: SuggestionsDto;
|
||||
}
|
||||
|
||||
export interface SettingsDto {
|
||||
metaHours: number; // 1–16, pasos de 0.5
|
||||
includeWeekends: boolean;
|
||||
teamId: string | null;
|
||||
autoRefreshMin: 0 | 5 | 10 | 15;
|
||||
alertEnabled: boolean; // aviso de media tarde (opcional)
|
||||
alertTime: string; // "16:00"
|
||||
}
|
||||
|
||||
export interface MeDto {
|
||||
username: string;
|
||||
email: string;
|
||||
teams: { id: string; name: string }[];
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Endpoints
|
||||
|
||||
| Método | Ruta | Descripción |
|
||||
|---|---|---|
|
||||
| GET | `/api/me` | Usuario de ClickUp y sus espacios de trabajo. |
|
||||
| GET | `/api/week?start=YYYY-MM-DD&refresh=true` | Semana calculada. `start` se normaliza al lunes. `refresh=true` ignora la caché. |
|
||||
| GET | `/api/suggestions?date=YYYY-MM-DD&filter=all\|overdue\|noDate` | Tareas atrasadas o sin fecha, ordenadas para llenar el hueco de ese día. |
|
||||
| GET | `/api/sprints` | Todos los Sprints de la carpeta, con `open`. |
|
||||
| GET | `/api/tasks/new-meta` | `NewTaskMetaDto`: Sprints abiertos, opciones de Clientes, usuarios y el usuario actual. |
|
||||
| POST | `/api/tasks` | Body `CreateTaskDto`. Crea la tarea con estado `asignado`, invalida la caché y devuelve `TaskActionResultDto`. |
|
||||
| PATCH | `/api/tasks/:id/due` | Body `{ "date": "YYYY-MM-DD" }`. Cambia el vencimiento (también para mover). Devuelve `TaskActionResultDto`. |
|
||||
| PATCH | `/api/tasks/:id/estimate` | Body `{ "estimateMs": number }`, mayor que 0. Cambia `time_estimate`. Devuelve `TaskActionResultDto`. |
|
||||
| PATCH | `/api/tasks/:id/status` | Body `{ "status": "terminado" }`. Cambia el estado. Devuelve `TaskActionResultDto`. |
|
||||
| GET | `/api/days-off` | Lista de días libres (`DayOffDto[]`). |
|
||||
| PUT | `/api/days-off` | Reemplaza la lista de días libres. Valida el formato de las fechas. |
|
||||
| GET | `/api/settings` | Ajustes actuales. |
|
||||
| PUT | `/api/settings` | Guarda los ajustes. Valida con `class-validator`. |
|
||||
| GET | `/api/health` | Comprueba que la API key funciona (`/user` de ClickUp). |
|
||||
|
||||
Errores con el formato estándar de Nest (`{ statusCode, message }`):
|
||||
|
||||
- **401** si la key de ClickUp es inválida: "La API key no es válida o expiró. Revisa el archivo .env del backend."
|
||||
- **400** si ClickUp rechaza un valor (por ejemplo `Invalid Value` en un Custom Field). Pasar el mensaje al usuario.
|
||||
- **502** si ClickUp no responde.
|
||||
- **429** si se alcanza el límite de ClickUp.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend (NestJS)
|
||||
|
||||
### 5.1 Configuración
|
||||
|
||||
```bash
|
||||
# backend/.env (en .gitignore)
|
||||
CLICKUP_API_KEY=pk_xxxxxxxx
|
||||
CLICKUP_TEAM_ID= # opcional; si está vacío se usa el de settings o el primero
|
||||
CLICKUP_SPRINTS_FOLDER_ID=90117541378 # carpeta "Sprints 2026" con las listas "Sprint NN (M/D - M/D)"
|
||||
CLICKUP_CLIENT_FIELD=Clientes # nombre del Custom Field de cliente (desplegable)
|
||||
CLICKUP_BOSS_FIELD=Jefatura # nombre del Custom Field de jefatura (personas)
|
||||
CLICKUP_NEW_STATUS=asignado # estado con el que se crean las tareas
|
||||
CLICKUP_DONE_STATUS=terminado # estado con el que se cierran
|
||||
TZ=America/Lima
|
||||
PORT=3000
|
||||
CACHE_TTL_SECONDS=60
|
||||
```
|
||||
|
||||
- Usar `@nestjs/config` con validación del esquema (Joi o `class-validator`). La app no debe arrancar sin `CLICKUP_API_KEY`.
|
||||
- Manejar las fechas con **Luxon**, usando la zona de `TZ`. Nunca usar `new Date()` sin zona para agrupar por día.
|
||||
- Los ajustes se guardan en `backend/data/settings.json`. `SettingsService` lee, valida y escribe ese archivo, con valores por defecto si no existe.
|
||||
- Los **días libres** se guardan en `backend/data/days-off.json`, con la forma `[{ "date": "YYYY-MM-DD", "label": "Feriado" }]`. `DaysOffService` devuelve lista vacía si no existe el archivo.
|
||||
|
||||
### 5.2 Módulos
|
||||
|
||||
```
|
||||
backend/src/
|
||||
app.module.ts
|
||||
config/env.validation.ts
|
||||
clickup/
|
||||
clickup.module.ts
|
||||
clickup.client.ts // HttpService (@nestjs/axios), header Authorization
|
||||
clickup.mapper.ts // respuesta de ClickUp → Task interno (incluye clients)
|
||||
clickup.types.ts // tipos crudos de la API de ClickUp
|
||||
settings/ // GET/PUT /api/settings, lee/escribe data/settings.json
|
||||
days-off/ // GET/PUT /api/days-off, lee/escribe data/days-off.json
|
||||
sprints/
|
||||
sprints.service.ts // lista Sprints, parsea el nombre, sprintOf(date), abiertos
|
||||
metrics/
|
||||
metrics.service.ts // FUNCIONES PURAS: buildDay, buildWeek, weeklyPerformance, sortSuggestions
|
||||
metrics.service.spec.ts
|
||||
planning/
|
||||
planning.controller.ts // /api/me, /api/week, /api/suggestions, /api/tasks/*, /api/health
|
||||
planning.service.ts // orquesta ClickupClient + MetricsService + caché
|
||||
dto/create-task.dto.ts
|
||||
notifications/ // opcional, fase 2
|
||||
afternoon-check.job.ts // @Cron con @nestjs/schedule
|
||||
common/
|
||||
clickup-exception.filter.ts
|
||||
dates.ts // weekStart, toLocalDateKey, range de semana en ms
|
||||
```
|
||||
|
||||
### 5.3 Responsabilidades
|
||||
|
||||
**`ClickupClient`**
|
||||
|
||||
| Uso | Endpoint de ClickUp |
|
||||
|---|---|
|
||||
| Usuario actual | `GET /user` |
|
||||
| Espacios de trabajo y miembros | `GET /team` (cada equipo trae `members[].user`) |
|
||||
| Tareas de la semana | `GET /team/{team_id}/task?assignees[]={user_id}&subtasks=true&include_closed=true&due_date_gt={inicio-1}&due_date_lt={fin}&page={n}` |
|
||||
| Tareas abiertas | `GET /team/{team_id}/task?assignees[]={user_id}&subtasks=true&page={n}` |
|
||||
| Listas de Sprint | `GET /folder/{folder_id}/list?archived=false` |
|
||||
| Campos personalizados | `GET /list/{list_id}/field` |
|
||||
| Crear tarea | `POST /list/{list_id}/task` |
|
||||
| Cambiar fecha, estimado o estado | `PUT /task/{task_id}` |
|
||||
|
||||
- **Paginación:** 100 tareas por página. Seguir hasta `last_page === true` o hasta recibir menos de 100. Límite de seguridad: 10 páginas.
|
||||
- Las fechas llegan como milisegundos en string: convertirlas a número.
|
||||
- Al mover o crear una tarea, fijar las **12:00 hora local** del día destino, con `due_date_time: false`, para evitar saltos de día. ClickUp normaliza las fechas sin hora y puede devolverlas, por ejemplo, a las 04:00 de Lima; el día sigue siendo el correcto.
|
||||
- Guardar en memoria el `user.id` después de la primera llamada a `/user`.
|
||||
- **Lectura del Custom Field "Clientes"** (`custom_fields[]`, buscando por nombre sin distinguir mayúsculas):
|
||||
- `drop_down`: `value` es el `orderindex` de la opción; buscarla en `type_config.options`.
|
||||
- `labels`: `value` es una lista de ids de opción.
|
||||
- Otros tipos: usar el valor como texto.
|
||||
- **Escritura al crear** (formatos verificados contra la API real):
|
||||
- `Clientes` (`drop_down`): `{ "id": "<field-id>", "value": "<option-uuid>" }`.
|
||||
- `Jefatura` (**tipo `users`, no desplegable**): `{ "id": "<field-id>", "value": { "add": [<user-id>], "rem": [] } }`. Una lista simple `[<user-id>]` devuelve `400 Invalid Value`.
|
||||
- `status: "asignado"`, `assignees: [<user-id>…]` y `time_estimate` en milisegundos.
|
||||
- Los dos campos tienen el mismo id en todas las listas de Sprint revisadas. Obtener sus ids y opciones desde la lista del primer Sprint abierto y guardarlos en caché (TTL largo).
|
||||
- Los estados `asignado` y `terminado` existen en las listas de Sprint. Si una lista no tiene alguno, ClickUp rechaza el cambio y el error se devuelve al usuario.
|
||||
|
||||
**`SprintsService`**
|
||||
|
||||
- Lee las listas de la carpeta de Sprints y parsea el nombre con `/^Sprint\s+(\d+)\s*\((\d+)\/(\d+)\s*-\s*(\d+)\/(\d+)\)/`.
|
||||
- **No usar** `start_date` ni `due_date` de la lista: varios Sprints los tienen desactualizados (por ejemplo, de 2025).
|
||||
- El año no viene en el nombre: se elige el año (actual ± 1) que deje la fecha final más cercana a hoy.
|
||||
- `sprintOf(date)`: de los Sprints cuyo rango contiene el día, devuelve el de inicio más tardío.
|
||||
- `open`: fecha final ≥ hoy. Los abiertos se ordenan por número.
|
||||
- Caché con TTL largo (por ejemplo, 1 hora).
|
||||
|
||||
**`MetricsService`**
|
||||
|
||||
- Sin dependencias de HTTP ni de Nest más allá de `@Injectable()`. Recibe tareas normalizadas, ajustes, días libres, Sprints y la fecha de "hoy", y devuelve `DayDto`, `WeekDto` y `SuggestionsDto`.
|
||||
- Recibir "hoy" como parámetro (no leer el reloj adentro) para que las pruebas sean deterministas.
|
||||
- Un día libre produce `metaMs = 0`, `gapMs = 0` y `debtMs = 0`, y no cuenta en `elapsedDays`.
|
||||
|
||||
**`PlanningService`**
|
||||
|
||||
- Pide a ClickUp las tareas de la semana y las abiertas **en paralelo**.
|
||||
- Caché con `@nestjs/cache-manager`: claves `week:{teamId}:{weekStart}` y `open:{teamId}`, TTL `CACHE_TTL_SECONDS`.
|
||||
- Invalida la caché al crear, mover, estimar o cerrar una tarea, o con `refresh=true`.
|
||||
- Valida `CreateTaskDto` antes de llamar a ClickUp: estimado > 0, cliente y jefatura presentes, al menos un asignado, y Sprint abierto.
|
||||
|
||||
**`NotificationsModule` (fase 2)**
|
||||
|
||||
- Cron a la hora `alertTime` en días hábiles no libres: si HPC + por cerrar está por debajo del 50 % de la meta, envía un aviso. Canal por definir: correo, Slack o notificación del sistema.
|
||||
|
||||
### 5.4 Pruebas (Jest)
|
||||
|
||||
Obligatorias en `metrics.service.spec.ts`:
|
||||
|
||||
- Una tarea cerrada sin estimación no suma a HPC y aparece en `noEstimate`.
|
||||
- Una tarea cerrada con fecha de otro día no suma al día evaluado.
|
||||
- El rendimiento semanal de un miércoles usa 3 días de meta, no 5.
|
||||
- `gapMs` y `debtMs` nunca son negativos.
|
||||
- Una tarea con `due_date` a las 23:30 de Lima cae en ese día y no en el siguiente.
|
||||
- Las sugerencias quedan ordenadas: primero las que encajan, luego las que tienen estimación, luego atrasadas antes que sin fecha, y de mayor a menor estimación.
|
||||
- Con fines de semana desactivados, sábado y domingo no cuentan como días transcurridos.
|
||||
- Un día libre tiene `metaMs`, `gapMs` y `debtMs` en 0 y no cuenta como día transcurrido: un miércoles con el lunes libre usa 2 días de meta.
|
||||
- Un día libre sin tareas no produce división por cero ni `NaN`.
|
||||
Para `SprintsService`:
|
||||
|
||||
- Parsea `"Sprint 19 (10/5 - 10/19)"` a número 19 y fechas de inicio y fin.
|
||||
- El 5 de octubre pertenece al Sprint 19 y no al 18, aunque aparezca como fecha final del 18.
|
||||
- Un día de un Sprint cerrado no aparece entre los abiertos, pero `sprintOf` lo devuelve.
|
||||
- Los Sprints abiertos salen ordenados por número.
|
||||
- Ignora listas cuyo nombre no sigue el patrón.
|
||||
|
||||
Para `ClickupClient`: paginación con un `HttpService` simulado (3 páginas, la última con `last_page: true`), mapeo de `Clientes` (`drop_down` por `orderindex` y `labels` por ids) y que `Jefatura` se envíe como `{ add: [id], rem: [] }`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend (Angular)
|
||||
|
||||
### 6.1 Configuración
|
||||
|
||||
- Angular 18 o superior, con **componentes standalone**, **signals** y control de flujo nuevo (`@if`, `@for`).
|
||||
- `provideHttpClient(withInterceptors([errorInterceptor]))`.
|
||||
- Configuración regional en español: `registerLocaleData(localeEsPE)` y `{ provide: LOCALE_ID, useValue: 'es-PE' }`.
|
||||
- Proxy de desarrollo:
|
||||
|
||||
```json
|
||||
// frontend/proxy.conf.json
|
||||
{ "/api": { "target": "http://localhost:3000", "secure": false } }
|
||||
```
|
||||
|
||||
```bash
|
||||
ng serve --proxy-config proxy.conf.json
|
||||
```
|
||||
|
||||
### 6.2 Estructura
|
||||
|
||||
```
|
||||
frontend/src/app/
|
||||
app.component.ts
|
||||
core/
|
||||
api.service.ts // llamadas a /api/*, tipadas con @contracts
|
||||
error.interceptor.ts // traduce errores a mensajes para el usuario
|
||||
state/planner.store.ts // estado con signals
|
||||
shared/
|
||||
hours.pipe.ts // ms → "H:MM"
|
||||
day-label.pipe.ts // "YYYY-MM-DD" → "Jueves 15 de octubre"
|
||||
hours-input.ts // parsea "2:30", "2.5" o "2" a milisegundos
|
||||
toast.service.ts
|
||||
features/
|
||||
top-bar/
|
||||
week-strip/ // week-strip + day-card
|
||||
day-view/ // day-view, hour-ruler, stat-tiles, task-group, task-row, client-chip
|
||||
task-dialogs/ // new-task, estimate, move
|
||||
suggestions/ // suggestion-panel, suggestion-row, weekly-score
|
||||
settings/
|
||||
styles/_tokens.scss // colores claro/oscuro (sección 9)
|
||||
```
|
||||
|
||||
### 6.3 Estado (`PlannerStore`)
|
||||
|
||||
Servicio `@Injectable({ providedIn: 'root' })` con:
|
||||
|
||||
- **Signals:** `weekStart`, `selectedDate`, `week`, `suggestions`, `filter`, `settings`, `loading`, `error`, `newTaskMeta`.
|
||||
- **Computed:** `selectedDay`, `selectedSprint` y `lastUpdatedLabel` ("actualizado hace 2 min").
|
||||
- **Acciones:** `loadWeek(refresh?)`, `selectDay(date)` (también recarga sugerencias), `prevWeek()`, `nextWeek()`, `goToday()`, `moveTask(id, date)`, `setEstimate(id, estimateMs)`, `markDone(id)`, `createTask(dto)`, `loadNewTaskMeta()` (carga una vez) y `saveSettings(s)`. Las acciones sobre tareas reemplazan `week` y `suggestions` con la respuesta del PATCH/POST y muestran el aviso de confirmación (sección 8).
|
||||
- **Actualización automática:** con RxJS, `timer` según `autoRefreshMin`, combinado con `fromEvent(document, 'visibilitychange')`. Se pausa con la pestaña oculta y recarga al volver si pasó el intervalo. Se cancela con `takeUntilDestroyed`.
|
||||
|
||||
Los componentes no llaman a `ApiService` directamente: solo leen signals del store y llaman a sus acciones.
|
||||
|
||||
### 6.4 Formato de fechas
|
||||
|
||||
- `DatePipe` en `es-PE` devuelve el día en minúsculas ("jueves"), así que `day-label.pipe.ts` debe capitalizar la primera letra.
|
||||
- Tarjeta semanal: número grande `d`, debajo el día (`EEEE`, capitalizado) y el mes (`MMMM`).
|
||||
- Encabezado del día: `EEEE d 'de' MMMM 'de' y` → "Jueves 15 de octubre de 2026".
|
||||
- Botones: "Traer al 15".
|
||||
- Horas siempre como `H:MM` (`5:30`, `0:45`). Los campos de tiempo aceptan `2:30`, `2.5` o `2`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Funcionalidades de la interfaz
|
||||
|
||||
### 7.1 Barra superior
|
||||
|
||||
- Título, usuario conectado, **Sprint del día seleccionado** (por ejemplo "Sprint 19 (10/5 - 10/19)") y "actualizado hace X min".
|
||||
- El Sprint **sigue al día seleccionado**: cambia con las flechas de semana, con **Hoy** y al elegir otro día. Si el día no cae en ningún Sprint, no se muestra nada.
|
||||
- Navegación de semanas: anterior, **Hoy** y siguiente.
|
||||
- Botones **Actualizar** (`refresh=true`) y **Ajustes**.
|
||||
|
||||
### 7.2 Franja semanal
|
||||
|
||||
- Una tarjeta por día, de lunes a viernes, o los 7 días si se incluyen fines de semana.
|
||||
- Cada tarjeta muestra el número del día en grande, el nombre del día y el mes.
|
||||
- El día actual lleva una etiqueta "hoy" y el día seleccionado un borde de acento.
|
||||
- Mini barra con el verde (completado) y el amarillo (por cerrar) sobre la meta.
|
||||
- Texto: "`5:30` de `8:00` completadas · `2:00` por cerrar".
|
||||
- Días pasados bajo la meta: horas en rojo. Días que cumplieron: en verde.
|
||||
- **Día libre:** fondo `--gap-soft`, número del día y etiqueta en rojo, y texto "**Feriado**" o "**Vacaciones**" en lugar de "X de 8:00". Si hubo trabajo, "`2:00` trabajadas". Conserva el borde de acento si está seleccionado.
|
||||
|
||||
### 7.3 Vista del día
|
||||
|
||||
- **Encabezado:** número grande y fecha completa. Botón **Nueva tarea** junto al veredicto.
|
||||
- **Veredicto a la derecha**, según el caso:
|
||||
- Día libre: "sin meta este día", o "`2:00` trabajadas como extra".
|
||||
- "Meta cumplida".
|
||||
- "Cierra X:XX y llegas a la meta" (o "Quedaron X:XX" si el día ya pasó).
|
||||
- "Faltan X:XX por planificar".
|
||||
- **Regla de horas (`HourRuler`)**, el elemento protagonista:
|
||||
- Cada tarea es un bloque proporcional, con su nombre y un tooltip con las horas.
|
||||
- Orden: completadas (verde sólido), por cerrar (amarillo con borde) y el hueco (rayado rojo con "faltan X:XX").
|
||||
- Marcas de hora debajo. Si lo planificado supera la meta, una línea punteada marca dónde están las 8 h. En un día libre no se dibuja esa línea y la regla tiene un mínimo de 1 h de ancho para no dividir por cero.
|
||||
- **Tres indicadores:** "Ya suman (HPC)", "Sumarán si las cierras" y "Falta planificar".
|
||||
- **Grupos de tareas:** "Sumando", "Por cerrar" y "No suman" (solo si hay tareas en el grupo).
|
||||
- Cada fila: **chip del cliente antes del nombre**, nombre con enlace a ClickUp, ruta, estado con su color y horas.
|
||||
- **Acciones por fila:** **Editar** (o **Poner estimado** si no tiene), **Mover**, y **Marcar terminado** (solo si el estado no es `terminado`).
|
||||
|
||||
### 7.4 Panel de sugerencias
|
||||
|
||||
- **Rendimiento semanal arriba del panel**, en porcentaje con su color y el detalle "X:XX completadas de Y:YY en N días transcurridos". Debajo, una línea separadora.
|
||||
- **Título:** "Para completar el 15 de octubre", con el mensaje del hueco. En un día libre: "Este día es feriado. No hay meta, pero puedes adelantar trabajo."
|
||||
- **Filtros:** Todas, Atrasadas y Sin fecha, con sus contadores.
|
||||
- **Orden** (lo calcula el backend):
|
||||
1. Con estimación y que **caben** en el hueco (etiqueta "encaja").
|
||||
2. Con estimación, aunque no quepan.
|
||||
3. Sin estimación, con el aviso "no sumará sin estimación".
|
||||
4. Dentro de cada grupo, atrasadas antes que sin fecha, y de mayor a menor estimación.
|
||||
- Botón **"Traer al N"**: se deshabilita mientras se procesa y la vista se actualiza con la respuesta del PATCH.
|
||||
- Si el día ya pasó, avisa que mover tareas ahí no recupera lo que no se cerró.
|
||||
|
||||
### 7.5 Ajustes
|
||||
|
||||
- Espacio de trabajo (lista desde `/api/me`), meta diaria, fines de semana y actualización automática (apagada, 5, 10 o 15 min).- **Cancelar** cierra el diálogo sin guardar; los campos se rellenan de nuevo al reabrirlo.
|
||||
- Fase 2: aviso de media tarde y su hora.
|
||||
- La API key **no** se edita desde la interfaz; se cambia en el `.env` del backend.
|
||||
|
||||
### 7.6 Estados vacíos y errores
|
||||
|
||||
- **Backend caído:** "No se puede conectar con el servidor local. ¿Está corriendo el backend en el puerto 3000?"
|
||||
- **401 de ClickUp:** mensaje del backend con la indicación de revisar el `.env`.
|
||||
- **Día sin tareas:** la regla muestra "Sin tareas con horas este día".
|
||||
- **Sin backlog:** "No tienes tareas atrasadas ni sin fecha. Crea tareas en ClickUp y pulsa Actualizar."
|
||||
- **Sin Sprints abiertos:** "No hay Sprints abiertos en la carpeta de Sprints." al abrir **Nueva tarea**.
|
||||
- **Cargando:** contenido atenuado, sin parpadeos al actualizar automáticamente (mantener los datos anteriores hasta que lleguen los nuevos).
|
||||
|
||||
### 7.7 Nueva tarea
|
||||
|
||||
| Campo | Regla |
|
||||
|---|---|
|
||||
| **Asunto** | Texto obligatorio. |
|
||||
| **Fecha** | El día seleccionado, editable. Al cambiarla se actualizan el Sprint y la propuesta de tiempo. |
|
||||
| **Sprint** | Sprints abiertos ordenados por número. Por defecto, el que contiene la fecha. Si ninguno la contiene: "Ningún Sprint abierto incluye esta fecha. Elige otro de la lista." |
|
||||
| **Tiempo estimado** | `H:MM`, obligatorio y mayor que 0:00. Se propone el hueco del día, o 1:00 si ya llegó a la meta. Atajos: `Hueco X:XX`, 0:30, 1:00, 2:00, 4:00. |
|
||||
| **Clientes** | Obligatorio. Campo con búsqueda (más de 100 opciones). |
|
||||
| **Jefatura** | Obligatorio. Campo con búsqueda entre los miembros del espacio de trabajo. |
|
||||
| **Assignees** | Obligatorio, al menos uno. Por defecto, el usuario actual; se pueden agregar y quitar. |
|
||||
|
||||
**Avisos (no bloquean):**
|
||||
|
||||
- Día por encima de la meta: "Con esto el día queda en X:XX y pasas de la meta de 8:00. Igual se creará."
|
||||
- Día libre: "**Ese día es feriado** y no debería tener tareas. Puedes crearla igual; las horas contarán como extra."
|
||||
- Bajo el tiempo: "Con esto llegas a la meta" o "Con esto llevas X:XX de 8:00; faltarían Y:YY".
|
||||
|
||||
### 7.8 Editar estimado, mover y marcar terminado
|
||||
|
||||
- **Editar estimado:** campo `H:MM` obligatorio y mayor que 0:00. Muestra cómo queda el día con el nuevo valor (reemplaza el actual) y avisa si pasa de la meta. Escribe `time_estimate`.
|
||||
- **Mover:** campo de fecha, por defecto el día siguiente al actual de la tarea. Si el destino es un día libre, avisa pero permite mover. Si es de otra semana, la tarea sale de la vista actual.
|
||||
- **Marcar terminado:** cambia el estado a `terminado`; la tarea pasa a "Sumando" si tiene estimado.
|
||||
- Ninguna acción tiene "deshacer".
|
||||
|
||||
---
|
||||
|
||||
## 8. Textos
|
||||
|
||||
- Español, en minúsculas tipo oración, sin mayúsculas sostenidas en etiquetas.
|
||||
- Botones con verbos claros: "Actualizar", "Traer al 15", "Guardar ajustes", "Nueva tarea", "Marcar terminado", "Mover", "Guardar estimado".
|
||||
- Los avisos de confirmación usan el mismo verbo que el botón: "Movida al jueves 15 de octubre", "Creada el jueves 15 de octubre", "Estimado guardado: 2:00", "Marcada como terminado: {nombre}".
|
||||
|
||||
---
|
||||
|
||||
## 9. Diseño visual
|
||||
|
||||
| Token | Claro | Oscuro | Uso |
|
||||
|---|---|---|---|
|
||||
| `--bg` | `#E9EDF2` | `#10151F` | Página |
|
||||
| `--surface` | `#FFFFFF` | `#18202D` | Paneles y tarjetas |
|
||||
| `--ink` | `#18223A` | `#E6EBF3` | Texto principal |
|
||||
| `--muted` | `#5F6B80` | `#93A0B5` | Texto secundario |
|
||||
| `--done` | `#0E8A68` | `#2FBE91` | HPC, meta cumplida |
|
||||
| `--pend` | `#D99A16` | `#F0B43A` | Planificado pendiente |
|
||||
| `--gap` | `#C9472F` | `#F0705A` | Falta planificar, atrasos, día libre |
|
||||
| `--gap-soft` | `#F7DDD7` | `#3D1F1A` | Fondo de la tarjeta de un día libre y avisos |
|
||||
| `--accent` | `#2F49B8` | `#8A9EFF` | Selección, botones principales, chip de cliente |
|
||||
|
||||
- Tipografía: **Schibsted Grotesk** (Google Fonts) con `font-variant-numeric: tabular-nums` y *fallback* a `system-ui`.
|
||||
- La regla de horas es lo protagonista. El resto, sobrio.
|
||||
- El chip de cliente usa el color de la opción en ClickUp, con texto blanco, o `--accent` sobre `--accent-soft` si la opción no tiene color.
|
||||
- Responsive: bajo 960 px, el panel de sugerencias baja debajo del día. Bajo 700 px, la franja semanal tiene desplazamiento horizontal.
|
||||
- Respetar `prefers-color-scheme` y `prefers-reduced-motion`, con foco visible con teclado.
|
||||
|
||||
---
|
||||
|
||||
## 10. Plan de desarrollo
|
||||
|
||||
1. **`contracts/`** con los tipos de la sección 4.1.
|
||||
2. **Backend: `MetricsService` con todas sus pruebas**, con datos falsos. Es la parte crítica. Incluir días libres.
|
||||
3. Backend: `SprintsService` y `DaysOffService` con sus pruebas.
|
||||
4. Backend: `ClickupClient`, el mapper (con `clients`) y su prueba de paginación.
|
||||
5. Backend: `PlanningController`, la caché y `SettingsModule`. Probar con curl contra ClickUp real, incluida una tarea de prueba que se borre después.
|
||||
6. Frontend: `ApiService`, `PlannerStore` y los pipes.
|
||||
7. Frontend: franja semanal y vista del día, con Sprint, día libre y chip de cliente.
|
||||
8. Frontend: panel de sugerencias y ajustes.
|
||||
9. Frontend: diálogos de nueva tarea, estimado y mover; marcar terminado.
|
||||
10. Actualización automática y estados de error.
|
||||
11. Fase 2: notificaciones de media tarde.
|
||||
|
||||
---
|
||||
|
||||
## 11. Criterios de aceptación
|
||||
|
||||
- [ ] Al crear en ClickUp una tarea asignada a mí, con fecha y estimación, y pulsar Actualizar, aparece en el día correcto en "Por cerrar".
|
||||
- [ ] Al cerrarla en ClickUp y actualizar, pasa a "Sumando" y la regla se pinta de verde.
|
||||
- [ ] Una tarea sin estimación aparece en "No suman" con su aviso, y con **Poner estimado** pasa a contar.
|
||||
- [ ] Una tarea vencida o sin fecha aparece en sugerencias, y "Traer al N" cambia su fecha en ClickUp.
|
||||
- [ ] Las tarjetas muestran número del día, nombre del día y mes.
|
||||
- [ ] El rendimiento semanal no penaliza días futuros ni días libres.
|
||||
- [ ] La API key no aparece en el código de Angular ni en ninguna respuesta de `/api/*`.
|
||||
- [ ] La actualización automática funciona y se pausa con la pestaña oculta.
|
||||
- [ ] `MetricsService` tiene todas las pruebas de la sección 5.4 en verde.
|
||||
- [ ] Cada tarea muestra el chip del cliente antes del nombre.
|
||||
- [ ] La barra superior muestra el Sprint del día seleccionado y cambia al navegar. El 5 de octubre muestra el Sprint 19, no el 18.
|
||||
- [ ] Un día de `days-off` se ve con fondo rojo, meta 0 y la etiqueta correspondiente, y no baja el rendimiento semanal.
|
||||
- [ ] **Nueva tarea** exige asunto, Sprint, estimado > 0, Clientes, Jefatura y asignado; crea la tarea con estado `asignado`, que aparece en "Por cerrar" y pasa a "Sumando" al marcarla como terminada; avisa si el día es libre o se pasa de la meta, y la crea igual.
|
||||
- [ ] **Nueva tarea** propone el Sprint del día y el hueco del día como estimado.
|
||||
- [ ] **Editar estimado**, **Mover** y **Marcar terminado** cambian la tarea en ClickUp y actualizan la vista sin recargarla.
|
||||
- [ ] El rendimiento semanal aparece en la parte superior del panel de sugerencias.
|
||||
|
||||
---
|
||||
|
||||
## 12. Ideas para después
|
||||
|
||||
- Vista de mes con un mapa de calor de horas completadas.
|
||||
- Arrastrar tareas desde sugerencias a un día de la franja semanal (Angular CDK Drag and Drop).
|
||||
- Comparar el tiempo registrado (time tracking) contra el estimado.
|
||||
- Historial semanal guardado en SQLite para ver la tendencia del rendimiento.
|
||||
- **Horas por cliente** de la semana, con una barra simple.
|
||||
- **Aviso de tareas cerradas sin estimación** en la semana, con acceso directo a "Poner estimado".
|
||||
- **Rendimiento por Sprint**: horas completadas ÷ meta de los días hábiles transcurridos del Sprint, descontando días libres.
|
||||
- Acciones de estimar, mover y marcar terminado también en el panel de sugerencias.
|
||||
- Abrir en la última semana vista y no siempre en la actual.
|
||||
- Duplicar una tarea frecuente, como las reuniones, al crear una nueva.
|
||||
- Calendarios de feriados por país, para equipos mixtos.
|
||||
|
||||
---
|
||||
|
||||
## 13. Prototipo HTML (respaldo)
|
||||
|
||||
Carpeta `prototipo-html/`. Funciona **sin backend ni Angular**: se abre `mis-8-horas.html` con doble clic. Es el respaldo si no se puede levantar NestJS o Angular, y la referencia de comportamiento para el proyecto nuevo.
|
||||
|
||||
| Archivo | Contenido |
|
||||
|---|---|
|
||||
| `mis-8-horas.html` | Toda la aplicación: estilos, lógica, llamadas a ClickUp y vistas. |
|
||||
| `config.js` | `window.M8_CONFIG = { apiKey: "pk_…" }`. Opcionales: `clientField` (por defecto `"Clientes"`) y `sprintsFolderId`. **Contiene la API key: no compartir ni subir a git.** |
|
||||
| `config.example.js` | Plantilla de `config.js` sin la key. |
|
||||
| `days-off.js` | `window.M8_DAYS_OFF = [{ "date": "YYYY-MM-DD", "label": "Feriado" }]`. Lista de días libres que el usuario edita a mano. Hoy trae los 16 feriados nacionales de Chile 2026. |
|
||||
|
||||
**Por qué los archivos son `.js` y no `.json`:** una página abierta con doble clic no puede leer un `.json` con `fetch` (el navegador lo bloquea), pero sí un `.js` con `<script>`.
|
||||
|
||||
**Lo que incluye el prototipo**, y que el proyecto nuevo debe igualar:
|
||||
|
||||
- Todas las reglas de la sección 2, con la zona horaria del navegador (el proyecto nuevo debe usar `America/Lima` con Luxon).
|
||||
- Chip del campo "Clientes" antes del nombre de cada tarea.
|
||||
- Sprint del día seleccionado en la barra superior, calculado desde los nombres de las listas de la carpeta de Sprints.
|
||||
- Días libres desde `days-off.js`, con tarjeta roja y meta 0.
|
||||
- **Nueva tarea**, **Editar estimado**, **Mover** y **Marcar terminado**.
|
||||
- Rendimiento semanal arriba del panel de sugerencias.
|
||||
- Ajustes con **Cancelar** y una casilla para usar la key de `config.js` o una escrita a mano.
|
||||
|
||||
**Limitaciones conocidas:**
|
||||
|
||||
- La API key queda en texto plano en `config.js`, y las llamadas a ClickUp salen desde el navegador.
|
||||
- El rendimiento semanal suma las horas cerradas en días libres como extra (ver "Pendiente de confirmar").
|
||||
- No hay actualización automática, ni caché, ni pruebas automáticas.
|
||||
- Si el navegador bloquea la conexión al abrir con doble clic, servir la carpeta con `python3 -m http.server`.
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copia este archivo como config.js y pon tu API key de ClickUp.
|
||||
window.M8_CONFIG = {
|
||||
apiKey: "pk_xxxxxxxx"
|
||||
// Opcionales:
|
||||
// , clientField: "Clientes" // nombre del Custom Field de cliente
|
||||
// , sprintsFolderId: "90117541378" // carpeta de ClickUp con las listas "Sprint NN"
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Días libres (feriados, vacaciones…): una línea por día, con fecha "YYYY-MM-DD" y una etiqueta.
|
||||
// Un día aquí tiene meta 0: no exige horas y no cuenta como día transcurrido en el rendimiento semanal.
|
||||
// Lo que cierres esos días suma como horas extra.
|
||||
window.M8_DAYS_OFF = [
|
||||
// Feriados nacionales de Chile 2026 (según prensa; confirma contra el calendario oficial del Gobierno)
|
||||
{ "date": "2026-01-01", "label": "Feriado" }, // Año Nuevo
|
||||
{ "date": "2026-04-03", "label": "Feriado" }, // Viernes Santo
|
||||
{ "date": "2026-04-04", "label": "Feriado" }, // Sábado Santo (sábado)
|
||||
{ "date": "2026-05-01", "label": "Feriado" }, // Día del Trabajo
|
||||
{ "date": "2026-05-21", "label": "Feriado" }, // Día de las Glorias Navales
|
||||
{ "date": "2026-06-21", "label": "Feriado" }, // Día Nacional de los Pueblos Indígenas (domingo)
|
||||
{ "date": "2026-06-29", "label": "Feriado" }, // San Pedro y San Pablo
|
||||
{ "date": "2026-07-16", "label": "Feriado" }, // Virgen del Carmen
|
||||
{ "date": "2026-08-15", "label": "Feriado" }, // Asunción de la Virgen (sábado)
|
||||
{ "date": "2026-09-18", "label": "Feriado" }, // Independencia Nacional
|
||||
{ "date": "2026-09-19", "label": "Feriado" }, // Glorias del Ejército (sábado)
|
||||
{ "date": "2026-10-12", "label": "Feriado" }, // Encuentro de Dos Mundos
|
||||
{ "date": "2026-10-31", "label": "Feriado" }, // Iglesias Evangélicas y Protestantes (sábado)
|
||||
{ "date": "2026-11-01", "label": "Feriado" }, // Todos los Santos (domingo)
|
||||
{ "date": "2026-12-08", "label": "Feriado" }, // Inmaculada Concepción
|
||||
{ "date": "2026-12-25", "label": "Feriado" } // Navidad
|
||||
|
||||
// Días libres propios, por ejemplo:
|
||||
// , { "date": "2026-12-24", "label": "Vacaciones" }
|
||||
];
|
||||
@@ -0,0 +1,845 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mis 8 horas · ClickUp</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#E9EDF2; --surface:#FFFFFF; --ink:#18223A; --muted:#5F6B80; --line:#D3DAE4;
|
||||
--done:#0E8A68; --done-soft:#D5F0E6; --pend:#D99A16; --pend-soft:#FBEBC8;
|
||||
--gap:#C9472F; --gap-soft:#F7DDD7; --accent:#2F49B8; --accent-soft:#E1E6FA;
|
||||
--hatch:rgba(201,71,47,.35);
|
||||
color-scheme:light;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root{
|
||||
--bg:#10151F; --surface:#18202D; --ink:#E6EBF3; --muted:#93A0B5; --line:#2A3445;
|
||||
--done:#2FBE91; --done-soft:#123A30; --pend:#F0B43A; --pend-soft:#3B2F14;
|
||||
--gap:#F0705A; --gap-soft:#3D1F1A; --accent:#8A9EFF; --accent-soft:#222C52;
|
||||
--hatch:rgba(240,112,90,.35);
|
||||
color-scheme:dark;
|
||||
}
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{background:var(--bg);color:var(--ink);font-family:"Schibsted Grotesk",system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
font-size:15px;line-height:1.45;font-variant-numeric:tabular-nums}
|
||||
button,input,select{font:inherit;color:inherit}
|
||||
button{cursor:pointer}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.wrap{max-width:1240px;margin:0 auto;padding:20px 20px 60px}
|
||||
|
||||
/* Barra superior */
|
||||
.top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px}
|
||||
.top h1{font-size:20px;font-weight:800;margin:0;letter-spacing:-.01em}
|
||||
.top .who{color:var(--muted);font-size:14px}
|
||||
.top .sp{flex:1}
|
||||
.btn{border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:7px 12px;font-weight:600;font-size:14px}
|
||||
.btn:hover{border-color:var(--accent)}
|
||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
.btn.small{padding:4px 10px;font-size:13px}
|
||||
.demo-tag{background:var(--pend-soft);color:var(--ink);border-radius:6px;padding:3px 8px;font-size:12px;font-weight:600}
|
||||
|
||||
/* Semana */
|
||||
.week{display:grid;grid-template-columns:repeat(var(--cols,5),1fr);gap:8px;margin-bottom:20px}
|
||||
.day{background:var(--surface);border:2px solid transparent;border-radius:12px;padding:12px 12px 10px;text-align:left;
|
||||
display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.day:hover{border-color:var(--line)}
|
||||
.day.sel{border-color:var(--accent)}
|
||||
.day .dhead{display:flex;align-items:baseline;gap:8px}
|
||||
.day .num{font-size:34px;font-weight:800;line-height:1;letter-spacing:-.03em}
|
||||
.day .wd{font-weight:600;font-size:14px}
|
||||
.day .mo{color:var(--muted);font-size:13px}
|
||||
.day .today{margin-left:auto;background:var(--accent);color:#fff;font-size:11px;font-weight:700;border-radius:999px;padding:2px 8px}
|
||||
.mini{height:8px;border-radius:4px;background:var(--line);overflow:hidden;display:flex}
|
||||
.mini i{display:block;height:100%}
|
||||
.day .dstat{font-size:13px;color:var(--muted)}
|
||||
.day .dstat b{color:var(--ink)}
|
||||
.day.short .dstat b{color:var(--gap)}
|
||||
.day.ok .dstat b{color:var(--done)}
|
||||
.day.hol{background:var(--gap-soft)}
|
||||
.day.hol:hover{border-color:var(--gap)}
|
||||
.day.hol .dstat b,.day.hol .num{color:var(--gap)}
|
||||
.day.hol .dstat{color:var(--ink)}
|
||||
.day.hol .mini{background:var(--surface)}
|
||||
|
||||
/* Cuerpo */
|
||||
.main{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:20px;align-items:start}
|
||||
@media (max-width:960px){.main{grid-template-columns:1fr}}
|
||||
.panel{background:var(--surface);border-radius:14px;padding:22px}
|
||||
.dayhero{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:18px}
|
||||
.dayhero .big{font-size:84px;font-weight:800;line-height:.85;letter-spacing:-.05em}
|
||||
.dayhero .lbl{font-size:24px;font-weight:700;line-height:1.1}
|
||||
.dayhero .lbl small{display:block;font-size:15px;font-weight:500;color:var(--muted);margin-top:4px}
|
||||
.verdict{margin-left:auto;text-align:right}
|
||||
.verdict .v{font-size:30px;font-weight:800;line-height:1}
|
||||
.verdict .vs{color:var(--muted);font-size:13px}
|
||||
|
||||
/* Regla de 8 horas */
|
||||
.ruler{position:relative;margin:6px 0 34px}
|
||||
.track{position:relative;height:56px;border-radius:10px;overflow:hidden;display:flex;background:var(--bg)}
|
||||
.seg{height:100%;border-right:2px solid var(--surface);display:flex;align-items:center;padding:0 8px;
|
||||
font-size:12px;font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;min-width:0}
|
||||
.seg.d{background:var(--done);color:#fff}
|
||||
.seg.p{background:var(--pend-soft);color:var(--ink);box-shadow:inset 0 0 0 2px var(--pend)}
|
||||
.seg.g{background:repeating-linear-gradient(135deg,transparent 0 7px,var(--hatch) 7px 9px);color:var(--gap);font-weight:700;justify-content:center}
|
||||
.ticks{position:absolute;left:0;right:0;top:56px;height:22px}
|
||||
.ticks span{position:absolute;transform:translateX(-50%);font-size:11px;color:var(--muted);top:6px}
|
||||
.ticks span::before{content:"";position:absolute;left:50%;top:-6px;width:1px;height:5px;background:var(--muted)}
|
||||
.metaline{position:absolute;top:-6px;bottom:16px;width:0;border-left:2px dashed var(--ink)}
|
||||
.metaline em{position:absolute;top:-18px;left:4px;font-style:normal;font-size:11px;font-weight:700;white-space:nowrap}
|
||||
|
||||
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:22px}
|
||||
.stat{border-radius:10px;padding:10px 12px}
|
||||
.stat .n{font-size:22px;font-weight:800}
|
||||
.stat .t{font-size:13px}
|
||||
.stat.d{background:var(--done-soft)} .stat.d .n{color:var(--done)}
|
||||
.stat.p{background:var(--pend-soft)} .stat.p .n{color:var(--pend)}
|
||||
.stat.g{background:var(--gap-soft)} .stat.g .n{color:var(--gap)}
|
||||
.stat.ok{background:var(--done-soft)} .stat.ok .n{color:var(--done)}
|
||||
|
||||
.group{margin-top:18px}
|
||||
.group h3{font-size:16px;margin:0 0 2px;display:flex;align-items:center;gap:8px}
|
||||
.group h3 .dot{width:10px;height:10px;border-radius:3px}
|
||||
.group .hint{color:var(--muted);font-size:13px;margin:0 0 8px}
|
||||
.task{display:flex;align-items:center;gap:10px;padding:9px 0;border-top:1px solid var(--line)}
|
||||
.task:last-child{border-bottom:1px solid var(--line)}
|
||||
.task .tn{flex:1;min-width:0}
|
||||
.task .tn a{color:var(--ink);text-decoration:none;font-weight:600}
|
||||
.task .tn a:hover{text-decoration:underline}
|
||||
.task .tl{display:block;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.task .hrs{font-weight:700;font-size:15px;white-space:nowrap}
|
||||
.status{font-size:11px;font-weight:700;border-radius:5px;padding:2px 7px;white-space:nowrap;text-transform:lowercase;border:1px solid}
|
||||
.empty{color:var(--muted);font-size:14px;padding:8px 0}
|
||||
.tags{display:inline-flex;gap:4px;flex-wrap:wrap;vertical-align:middle}
|
||||
.tag{font-size:11px;font-weight:700;border-radius:5px;padding:1px 7px;white-space:nowrap;background:var(--accent-soft);color:var(--accent)}
|
||||
|
||||
/* Sugerencias */
|
||||
.side h2{font-size:18px;margin:0 0 4px}
|
||||
.side .need{font-size:15px;margin:0 0 14px;color:var(--muted)}
|
||||
.side .need b{color:var(--gap)}
|
||||
.side .need.ok b{color:var(--done)}
|
||||
.sug{border-top:1px solid var(--line);padding:10px 0;display:grid;grid-template-columns:1fr auto;gap:4px 10px;align-items:center}
|
||||
.sug .tn a{color:var(--ink);text-decoration:none;font-weight:600}
|
||||
.sug .meta{grid-column:1/2;font-size:12px;color:var(--muted)}
|
||||
.sug .meta .late{color:var(--gap);font-weight:600}
|
||||
.sug .fits{color:var(--done);font-weight:600}
|
||||
.sug .act{grid-row:1/3;grid-column:2;display:flex;flex-direction:column;align-items:flex-end;gap:4px}
|
||||
.sug .hrs{font-weight:700}
|
||||
.filter{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
|
||||
.chip{border:1px solid var(--line);background:transparent;border-radius:999px;padding:3px 10px;font-size:12px;font-weight:600}
|
||||
.chip.on{background:var(--ink);color:var(--surface);border-color:var(--ink)}
|
||||
.weekly{margin-bottom:20px;padding-bottom:16px;border-bottom:2px solid var(--line)}
|
||||
.weekly .n{font-size:30px;font-weight:800}
|
||||
|
||||
/* Mensajes y diálogo */
|
||||
.msg{background:var(--gap-soft);border-radius:10px;padding:12px 14px;margin-bottom:16px;font-size:14px}
|
||||
.msg.info{background:var(--accent-soft)}
|
||||
.loading{opacity:.55;pointer-events:none}
|
||||
dialog{border:none;border-radius:14px;padding:24px;max-width:460px;width:92vw;background:var(--surface);color:var(--ink)}
|
||||
dialog::backdrop{background:rgba(10,15,25,.55)}
|
||||
dialog h2{margin:0 0 6px}
|
||||
dialog p{color:var(--muted);font-size:14px;margin:0 0 16px}
|
||||
.field{display:flex;flex-direction:column;gap:4px;margin-bottom:14px;font-size:14px;font-weight:600}
|
||||
.field input,.field select{border:1px solid var(--line);border-radius:8px;padding:8px 10px;background:var(--bg);font-weight:400}
|
||||
.field small{font-weight:400;color:var(--muted)}
|
||||
.row{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;margin-top:6px}
|
||||
.check{display:flex;gap:8px;align-items:center;font-size:14px;margin-bottom:14px}
|
||||
#nt{max-width:560px;max-height:92vh;overflow:auto}
|
||||
#nt .chip{cursor:pointer}
|
||||
#nt small.warn{color:var(--gap);font-weight:600}
|
||||
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--surface);
|
||||
padding:10px 16px;border-radius:10px;font-size:14px;font-weight:600;opacity:0;transition:opacity .2s;pointer-events:none}
|
||||
.toast.show{opacity:1}
|
||||
@media (max-width:700px){
|
||||
.week{grid-template-columns:repeat(var(--cols,5),minmax(92px,1fr));overflow-x:auto}
|
||||
.dayhero .big{font-size:64px}
|
||||
.stats{grid-template-columns:1fr}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){.toast{transition:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="app">
|
||||
<div class="top">
|
||||
<h1>Mis 8 horas</h1>
|
||||
<span class="who" id="who"></span>
|
||||
<span class="who" id="sprintNow"></span>
|
||||
<span id="demoTag"></span>
|
||||
<span class="sp"></span>
|
||||
<button class="btn" id="prevW" aria-label="Semana anterior">◀</button>
|
||||
<button class="btn" id="todayB">Hoy</button>
|
||||
<button class="btn" id="nextW" aria-label="Semana siguiente">▶</button>
|
||||
<button class="btn" id="refresh">Actualizar</button>
|
||||
<button class="btn" id="cfgB">Ajustes</button>
|
||||
</div>
|
||||
<div id="msg"></div>
|
||||
<div class="week" id="week"></div>
|
||||
<div class="main">
|
||||
<section class="panel" id="dayPanel"></section>
|
||||
<aside class="panel side" id="side"></aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="cfg">
|
||||
<h2>Conectar con ClickUp</h2>
|
||||
<p>Tu API key se guarda solo en este navegador y se usa únicamente para hablar con ClickUp.</p>
|
||||
<label class="check" id="fUseWrap" hidden><input type="checkbox" id="fUseFile"> Usar la API key del archivo config.js</label>
|
||||
<label class="field">API key personal
|
||||
<input type="password" id="fKey" placeholder="pk_..." autocomplete="off">
|
||||
<small>ClickUp → avatar → Settings → Apps → API Token</small>
|
||||
</label>
|
||||
<label class="field" id="fTeamWrap" hidden>Espacio de trabajo
|
||||
<select id="fTeam"></select>
|
||||
</label>
|
||||
<label class="field">Meta diaria (horas)
|
||||
<input type="number" id="fMeta" min="1" max="16" step="0.5" value="8">
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" id="fWeekend"> Mostrar sábado y domingo</label>
|
||||
<div class="row">
|
||||
<button class="btn" id="fCancel">Cancelar</button>
|
||||
<button class="btn" id="fDemo">Ver con datos de ejemplo</button>
|
||||
<button class="btn primary" id="fSave">Conectar</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="nt">
|
||||
<h2>Nueva tarea</h2>
|
||||
<p>Se crea con estado <b>asignado</b> y queda en «Por cerrar». Cuando la termines, usa «Marcar terminado» para que sume.</p>
|
||||
<label class="field">Asunto
|
||||
<input type="text" id="ntName" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">Fecha
|
||||
<input type="date" id="ntDate">
|
||||
</label>
|
||||
<div id="ntOff" class="msg" hidden></div>
|
||||
<label class="field">Sprint
|
||||
<select id="ntList"></select>
|
||||
<small id="ntSprintHint"></small>
|
||||
</label>
|
||||
<label class="field">Tiempo estimado (H:MM)
|
||||
<input type="text" id="ntHrs" inputmode="decimal" placeholder="2:30" autocomplete="off">
|
||||
<span class="filter" id="ntPresets"></span>
|
||||
<small id="ntHint"></small>
|
||||
</label>
|
||||
<label class="field">Clientes
|
||||
<input type="text" id="ntClient" list="dlClients" autocomplete="off" placeholder="Escribe para buscar">
|
||||
</label>
|
||||
<label class="field">Jefatura
|
||||
<input type="text" id="ntBoss" list="dlUsers" autocomplete="off" placeholder="Escribe para buscar">
|
||||
</label>
|
||||
<div class="field">Assignees
|
||||
<span class="filter" id="ntAssn"></span>
|
||||
<input type="text" id="ntAssIn" list="dlUsers" autocomplete="off" placeholder="Agregar persona">
|
||||
</div>
|
||||
<datalist id="dlClients"></datalist>
|
||||
<datalist id="dlUsers"></datalist>
|
||||
<div id="ntErr" class="msg" hidden></div>
|
||||
<div class="row">
|
||||
<button class="btn" id="ntCancel">Cancelar</button>
|
||||
<button class="btn primary" id="ntSave">Crear tarea</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="est">
|
||||
<h2>Tiempo estimado</h2>
|
||||
<p id="estName"></p>
|
||||
<label class="field">Tiempo estimado (H:MM)
|
||||
<input type="text" id="estHrs" inputmode="decimal" placeholder="2:00" autocomplete="off">
|
||||
<small id="estHint"></small>
|
||||
</label>
|
||||
<div id="estErr" class="msg" hidden></div>
|
||||
<div class="row">
|
||||
<button class="btn" id="estCancel">Cancelar</button>
|
||||
<button class="btn primary" id="estSave">Guardar estimado</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="mv">
|
||||
<h2>Mover tarea</h2>
|
||||
<p id="mvName"></p>
|
||||
<label class="field">Nueva fecha
|
||||
<input type="date" id="mvDate">
|
||||
</label>
|
||||
<div id="mvOff" class="msg" hidden></div>
|
||||
<div id="mvErr" class="msg" hidden></div>
|
||||
<div class="row">
|
||||
<button class="btn" id="mvCancel">Cancelar</button>
|
||||
<button class="btn primary" id="mvSave">Mover tarea</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="config.js" onerror="void 0"></script>
|
||||
<script src="days-off.js" onerror="void 0"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const API = "https://api.clickup.com/api/v2";
|
||||
const WD = ["Domingo","Lunes","Martes","Miércoles","Jueves","Viernes","Sábado"];
|
||||
const MO = ["enero","febrero","marzo","abril","mayo","junio","julio","agosto","septiembre","octubre","noviembre","diciembre"];
|
||||
const H = 3600000, DAY = 86400000;
|
||||
|
||||
/* ---------- Almacenamiento local ---------- */
|
||||
const store = {
|
||||
get(k,d){try{const v=localStorage.getItem("m8:"+k);return v===null?d:JSON.parse(v)}catch(e){return d}},
|
||||
set(k,v){try{localStorage.setItem("m8:"+k,JSON.stringify(v))}catch(e){}}
|
||||
};
|
||||
|
||||
const FILE_KEY = ((window.M8_CONFIG&&window.M8_CONFIG.apiKey)||"").trim();
|
||||
const FILE_KEY_OK = FILE_KEY && FILE_KEY !== "pk_xxxxxxxx";
|
||||
let useFile = FILE_KEY_OK && store.get("useFile",true); // usar la key de config.js (si existe) en vez de una escrita a mano
|
||||
const S = {
|
||||
key: useFile ? FILE_KEY : store.get("key",""), team: store.get("team",""), meta: store.get("meta",8),
|
||||
weekend: store.get("weekend",false), demo: useFile ? false : store.get("demo",false), // usando el archivo, siempre arranca con datos reales
|
||||
user:null, teams:[], weekStart: mondayOf(new Date()), sel: startOfDay(new Date()),
|
||||
weekTasks:[], backlog:[], filter:"todas"
|
||||
};
|
||||
|
||||
/* ---------- Días libres (feriados y vacaciones) ---------- */
|
||||
// Lista en days-off.js: [{ "date": "YYYY-MM-DD", "label": "Feriado" }]. Todos tienen el mismo efecto: meta 0.
|
||||
const OFF = new Map((Array.isArray(window.M8_DAYS_OFF)?window.M8_DAYS_OFF:[])
|
||||
.filter(x=>x&&/^\d{4}-\d{2}-\d{2}$/.test(x.date)).map(x=>[x.date,String(x.label||"Feriado")]));
|
||||
function offLabel(d){return OFF.get(dkey(d))||null}
|
||||
function isHoliday(d){return OFF.has(dkey(d))}
|
||||
|
||||
/* ---------- Fechas ---------- */
|
||||
function startOfDay(d){const x=new Date(d);x.setHours(0,0,0,0);return x}
|
||||
function mondayOf(d){const x=startOfDay(d);const w=(x.getDay()+6)%7;x.setDate(x.getDate()-w);return x}
|
||||
function addDays(d,n){const x=new Date(d);x.setDate(x.getDate()+n);return x}
|
||||
function dkey(d){const x=new Date(d);return x.getFullYear()+"-"+String(x.getMonth()+1).padStart(2,"0")+"-"+String(x.getDate()).padStart(2,"0")}
|
||||
function sameDay(a,b){return dkey(a)===dkey(b)}
|
||||
function fmtH(ms){const m=Math.round(ms/60000);const h=Math.floor(m/60);return h+":"+String(m%60).padStart(2,"0")}
|
||||
function fullDate(d){return WD[d.getDay()]+" "+d.getDate()+" de "+MO[d.getMonth()]}
|
||||
function shortDate(d){return d.getDate()+" "+MO[d.getMonth()].slice(0,3)}
|
||||
function esc(s){return String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]))}
|
||||
|
||||
/* ---------- API de ClickUp ---------- */
|
||||
async function api(path,opts={}){
|
||||
const r = await fetch(API+path,{...opts,headers:{"Authorization":S.key,"Content-Type":"application/json"}});
|
||||
if(!r.ok){
|
||||
let t=""; try{t=(await r.json()).err||""}catch(e){}
|
||||
throw new Error(r.status===401?"La API key no es válida o expiró. Revísala en Ajustes.":"ClickUp respondió "+r.status+(t?": "+t:""));
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
const CLIENT_FIELD = ((window.M8_CONFIG&&window.M8_CONFIG.clientField)||"Clientes").trim().toLowerCase();
|
||||
function clientsOf(t){
|
||||
const f=(t.custom_fields||[]).find(c=>(c.name||"").trim().toLowerCase()===CLIENT_FIELD);
|
||||
if(!f||f.value===undefined||f.value===null||f.value==="") return [];
|
||||
const opts=(f.type_config&&f.type_config.options)||[];
|
||||
const mk=o=>({name:o.name||o.label||"",color:o.color||null});
|
||||
if(f.type==="drop_down"){
|
||||
const o=opts.find(o=>o.orderindex===Number(f.value))||opts.find(o=>o.id===f.value);
|
||||
return o?[mk(o)]:[];
|
||||
}
|
||||
if(f.type==="labels") return (Array.isArray(f.value)?f.value:[]).map(id=>opts.find(o=>o.id===id)).filter(Boolean).map(mk);
|
||||
return [{name:String(f.value),color:null}];
|
||||
}
|
||||
function norm(t){
|
||||
const st=t.status||{};
|
||||
const closed = st.type==="closed"||st.type==="done"||!!t.date_closed;
|
||||
return {
|
||||
id:t.id, name:t.name, url:t.url, closed,
|
||||
status:st.status||"", color:st.color||"#888",
|
||||
est: Number(t.time_estimate)||0,
|
||||
clients: clientsOf(t),
|
||||
due: t.due_date?Number(t.due_date):null,
|
||||
list: [t.space&&t.space.name, t.folder&&t.folder.hidden===false?t.folder.name:null, t.list&&t.list.name].filter(Boolean).join(" / ")
|
||||
};
|
||||
}
|
||||
async function fetchAll(query,maxPages=10){
|
||||
let out=[];
|
||||
for(let p=0;p<maxPages;p++){
|
||||
const d = await api(`/team/${S.team}/task?${query}&page=${p}`);
|
||||
out = out.concat(d.tasks||[]);
|
||||
if(d.last_page||!d.tasks||d.tasks.length<100) break;
|
||||
}
|
||||
return out.map(norm);
|
||||
}
|
||||
async function connect(){
|
||||
const u = await api("/user"); S.user=u.user;
|
||||
const t = await api("/team"); S.teams=t.teams||[];
|
||||
if(!S.teams.find(x=>x.id===S.team)) S.team = S.teams[0]?S.teams[0].id:"";
|
||||
store.set("team",S.team);
|
||||
}
|
||||
async function loadWeek(){
|
||||
const from = S.weekStart.getTime(), to = addDays(S.weekStart,7).getTime();
|
||||
const base = `assignees[]=${S.user.id}&subtasks=true`;
|
||||
const [week,open] = await Promise.all([
|
||||
fetchAll(`${base}&include_closed=true&due_date_gt=${from-1}&due_date_lt=${to}`),
|
||||
fetchAll(`${base}`)
|
||||
]);
|
||||
S.weekTasks = week.filter(t=>t.due!==null);
|
||||
const today = startOfDay(new Date()).getTime();
|
||||
S.backlog = open.filter(t=>!t.closed && (t.due===null || t.due<today));
|
||||
}
|
||||
async function moveTask(task,day){
|
||||
const ms = new Date(day).setHours(12,0,0,0);
|
||||
if(!S.demo) await api(`/task/${task.id}`,{method:"PUT",body:JSON.stringify({due_date:ms,due_date_time:false})});
|
||||
S.backlog = S.backlog.filter(t=>t.id!==task.id);
|
||||
task.due = ms;
|
||||
if(ms>=S.weekStart.getTime() && ms<addDays(S.weekStart,7).getTime()) S.weekTasks.push(task);
|
||||
}
|
||||
|
||||
/* ---------- Datos de ejemplo ---------- */
|
||||
function demoData(){
|
||||
const names=["Revisar propuesta comercial","Configurar módulo FI","Reunión de seguimiento","Documentar proceso de cierre",
|
||||
"Corregir reporte de ventas","Pruebas integrales","Capacitación usuarios","Ajustes de interfaz","Análisis de requerimientos",
|
||||
"Migración de datos maestros","Validar asientos contables","Preparar demo para cliente"];
|
||||
const lists=["Cliente Andes / Implementación","Interno / Soporte","Cliente Pacífico / Mejoras"];
|
||||
let i=0; const today=startOfDay(new Date());
|
||||
const mk=(day,h,closed)=>({id:"d"+(i++),name:names[i%names.length],url:"#",closed,status:closed?"cerrado":"en progreso",
|
||||
color:closed?"#0E8A68":"#D99A16",est:h*H,due:new Date(day).setHours(12,0,0,0),list:lists[i%3],
|
||||
clients:[[{name:"Andes",color:null}],[{name:"Interno",color:null}],[{name:"Pacífico",color:null}]][i%3]});
|
||||
const w=[];
|
||||
for(let d=0;d<5;d++){
|
||||
const day=addDays(S.weekStart,d); const past=day<today, isT=sameDay(day,today);
|
||||
const plan=[[3,1.5,2],[2,4,1],[1.5,2.5],[3,2,1.5,1],[2,2]][d];
|
||||
plan.forEach((h,k)=>w.push(mk(day,h,past?k!==1:isT?k===0:false)));
|
||||
}
|
||||
w.push({...mk(today,0,false),est:0,name:"Llamada con el equipo"});
|
||||
S.weekTasks=w;
|
||||
S.backlog=[mk(addDays(today,-4),1.5,false),mk(addDays(today,-2),3,false),
|
||||
{...mk(today,1,false),due:null},{...mk(today,2,false),due:null},{...mk(today,0.5,false),due:null},{...mk(today,0,false),due:null,est:0}];
|
||||
S.user={username:"Ejemplo"};
|
||||
}
|
||||
|
||||
/* ---------- Cálculo por día ---------- */
|
||||
function dayInfo(d){
|
||||
const ts=S.weekTasks.filter(t=>sameDay(t.due,d));
|
||||
const done=ts.filter(t=>t.closed&&t.est>0), pend=ts.filter(t=>!t.closed&&t.est>0), noest=ts.filter(t=>t.est===0);
|
||||
const sum=a=>a.reduce((s,t)=>s+t.est,0);
|
||||
const meta=isHoliday(d)?0:S.meta*H, dH=sum(done), pH=sum(pend);
|
||||
return {ts,done,pend,noest,meta,dH,pH,gap:Math.max(0,meta-dH-pH),miss:Math.max(0,meta-dH)};
|
||||
}
|
||||
function weekDays(){const n=S.weekend?7:5;return Array.from({length:n},(_,i)=>addDays(S.weekStart,i))}
|
||||
|
||||
/* ---------- Render ---------- */
|
||||
const $=id=>document.getElementById(id);
|
||||
function render(){
|
||||
$("who").textContent = S.user?(S.user.username||S.user.email||""):"";
|
||||
$("demoTag").innerHTML = S.demo?'<span class="demo-tag">Datos de ejemplo</span>':"";
|
||||
const sp=sprintOf(S.sel); $("sprintNow").textContent=sp?"· "+sp.name:"";
|
||||
renderWeek(); renderDay(); renderSide();
|
||||
}
|
||||
function renderWeek(){
|
||||
const days=weekDays(), today=startOfDay(new Date());
|
||||
$("week").style.setProperty("--cols",days.length);
|
||||
$("week").innerHTML = days.map(d=>{
|
||||
const I=dayInfo(d), past=d<today, isT=sameDay(d,today);
|
||||
const hol=isHoliday(d), sc=I.meta||S.meta*H;
|
||||
const pd=Math.min(100,I.dH/sc*100), pp=Math.min(100-pd,I.pH/sc*100);
|
||||
const cls=["day",sameDay(d,S.sel)?"sel":"",hol?"hol":(I.dH>=I.meta?"ok":(past?"short":""))].join(" ");
|
||||
return `<button class="${cls}" data-d="${dkey(d)}" aria-label="${fullDate(d)}">
|
||||
<span class="dhead"><span class="num">${d.getDate()}</span><span><span class="wd">${WD[d.getDay()]}</span><br><span class="mo">${MO[d.getMonth()]}</span></span>${isT?'<span class="today">hoy</span>':""}</span>
|
||||
<span class="mini"><i style="width:${pd}%;background:var(--done)"></i><i style="width:${pp}%;background:var(--pend)"></i></span>
|
||||
<span class="dstat">${hol?`<b>${offLabel(d)}</b>${I.dH?` · ${fmtH(I.dH)} trabajadas`:""}${I.pH?` · ${fmtH(I.pH)} por cerrar`:""}`
|
||||
:`<b>${fmtH(I.dH)}</b> de ${fmtH(I.meta)} completadas${I.pH?` · ${fmtH(I.pH)} por cerrar`:""}`}</span>
|
||||
</button>`;
|
||||
}).join("");
|
||||
$("week").querySelectorAll(".day").forEach(b=>b.onclick=()=>{const [y,m,dd]=b.dataset.d.split("-");S.sel=new Date(y,m-1,dd);render()});
|
||||
}
|
||||
function tagChips(t){
|
||||
return t.clients&&t.clients.length?`<span class="tags">${t.clients.map(c=>`<span class="tag"${c.color?` style="background:${esc(c.color)};color:#fff"`:""}>${esc(c.name)}</span>`).join("")}</span>`:"";
|
||||
}
|
||||
function taskRow(t,showH=true){
|
||||
return `<div class="task">
|
||||
<span class="tn">${tagChips(t)} <a href="${esc(t.url)}" target="_blank" rel="noopener">${esc(t.name)}</a><span class="tl">${esc(t.list)}</span></span>
|
||||
<span class="status" style="color:${esc(t.color)};border-color:${esc(t.color)}">${esc(t.status)}</span>
|
||||
${showH?`<span class="hrs">${t.est?fmtH(t.est):"—"}</span>`:""}
|
||||
<button class="btn small" data-est="${esc(t.id)}">${t.est?"Editar":"Poner estimado"}</button>
|
||||
<button class="btn small" data-move="${esc(t.id)}">Mover</button>
|
||||
${(t.status||"").toLowerCase()!==DONE_STATUS?`<button class="btn small" data-fin="${esc(t.id)}">Marcar terminado</button>`:""}
|
||||
</div>`;
|
||||
}
|
||||
function renderDay(){
|
||||
const d=S.sel, I=dayInfo(d), today=startOfDay(new Date()), past=d<today;
|
||||
const hol=isHoliday(d);
|
||||
const total=Math.max(I.meta,I.dH+I.pH,H), pct=ms=>ms/total*100;
|
||||
let segs="";
|
||||
I.done.forEach(t=>segs+=`<div class="seg d" style="width:${pct(t.est)}%" title="${esc(t.name)} · ${fmtH(t.est)}">${esc(t.name)}</div>`);
|
||||
I.pend.forEach(t=>segs+=`<div class="seg p" style="width:${pct(t.est)}%" title="${esc(t.name)} · ${fmtH(t.est)} (por cerrar)">${esc(t.name)}</div>`);
|
||||
if(I.gap>0) segs+=`<div class="seg g" style="width:${pct(I.gap)}%">faltan ${fmtH(I.gap)}</div>`;
|
||||
const hours=Math.ceil(total/H); let ticks="";
|
||||
for(let h=0;h<=hours;h++){ if(h*H<=total) ticks+=`<span style="left:${pct(h*H)}%">${h}h</span>`; }
|
||||
|
||||
let verdict, vs;
|
||||
if(hol){verdict=offLabel(d);vs=I.dH?`${fmtH(I.dH)} trabajadas como extra`:"sin meta este día"}
|
||||
else if(I.dH>=I.meta){verdict="Meta cumplida";vs=`${fmtH(I.dH)} completadas`}
|
||||
else if(I.dH+I.pH>=I.meta){verdict=past?`Quedaron ${fmtH(I.miss)}`:`Cierra ${fmtH(I.miss)}`;vs=past?"sin completar ese día":"y llegas a la meta"}
|
||||
else {verdict=`Faltan ${fmtH(I.gap)}`;vs="por planificar"}
|
||||
|
||||
$("dayPanel").innerHTML = `
|
||||
<div class="dayhero">
|
||||
<span class="big">${d.getDate()}</span>
|
||||
<span class="lbl">${WD[d.getDay()]}<small>${d.getDate()} de ${MO[d.getMonth()]} de ${d.getFullYear()}</small></span>
|
||||
<span class="verdict"><span class="v" style="color:${hol?"var(--accent)":I.dH>=I.meta?"var(--done)":I.gap>0?"var(--gap)":"var(--pend)"}">${verdict}</span><br><span class="vs">${vs}</span><br><button class="btn primary small" id="newB" style="margin-top:10px">Nueva tarea</button></span>
|
||||
</div>
|
||||
<div class="ruler">
|
||||
<div class="track">${segs||'<div class="seg g" style="width:100%">Sin tareas con horas este día</div>'}</div>
|
||||
<div class="ticks">${ticks}</div>
|
||||
${total>I.meta&&I.meta>0?`<div class="metaline" style="left:${pct(I.meta)}%"><em>meta ${S.meta}h</em></div>`:""}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat d"><div class="n">${fmtH(I.dH)}</div><div class="t">Ya suman (HPC)</div></div>
|
||||
<div class="stat p"><div class="n">${fmtH(I.pH)}</div><div class="t">Sumarán si las cierras</div></div>
|
||||
<div class="stat ${I.gap>0?"g":"ok"}"><div class="n">${fmtH(I.gap)}</div><div class="t">Falta planificar</div></div>
|
||||
</div>
|
||||
<div class="group"><h3><span class="dot" style="background:var(--done)"></span>Sumando · ${I.done.length}</h3>
|
||||
<p class="hint">Cerradas, con estimación y fecha de este día.</p>
|
||||
${I.done.map(t=>taskRow(t)).join("")||'<div class="empty">Todavía no cierras ninguna tarea de este día.</div>'}</div>
|
||||
<div class="group"><h3><span class="dot" style="background:var(--pend)"></span>Por cerrar · ${I.pend.length}</h3>
|
||||
<p class="hint">Planificadas para este día. Suman en cuanto las marques como completadas.</p>
|
||||
${I.pend.sort((a,b)=>b.est-a.est).map(t=>taskRow(t)).join("")||'<div class="empty">No hay tareas pendientes para este día.</div>'}</div>
|
||||
${I.noest.length?`<div class="group"><h3><span class="dot" style="background:var(--gap)"></span>No suman · ${I.noest.length}</h3>
|
||||
<p class="hint">Tienen fecha de este día pero no tienen tiempo estimado. Ponles estimación en ClickUp para que cuenten.</p>
|
||||
${I.noest.map(t=>taskRow(t,false)).join("")}</div>`:""}
|
||||
`;
|
||||
$("newB").onclick=openNt;
|
||||
$("dayPanel").querySelectorAll("[data-est]").forEach(b=>b.onclick=()=>openEst(b.dataset.est));
|
||||
$("dayPanel").querySelectorAll("[data-fin]").forEach(b=>b.onclick=()=>finishTask(b));
|
||||
$("dayPanel").querySelectorAll("[data-move]").forEach(b=>b.onclick=()=>openMv(b.dataset.move));
|
||||
}
|
||||
async function finishTask(b){
|
||||
const t=S.weekTasks.find(x=>x.id===b.dataset.fin); if(!t) return;
|
||||
b.disabled=true; b.textContent="Guardando…";
|
||||
try{
|
||||
if(S.demo){t.status=DONE_STATUS;t.color="#0E8A68";t.closed=true}
|
||||
else{
|
||||
const r=await api(`/task/${t.id}`,{method:"PUT",body:JSON.stringify({status:DONE_STATUS})});
|
||||
const n=norm(r); t.status=n.status; t.color=n.color; t.closed=n.closed;
|
||||
}
|
||||
toast(`Marcada como terminado: ${t.name}`); render();
|
||||
}catch(e){showMsg(esc(e.message)); b.disabled=false; b.textContent="Marcar terminado"}
|
||||
}
|
||||
function renderSide(){
|
||||
const d=S.sel, I=dayInfo(d), today=startOfDay(new Date());
|
||||
const isPast = d<today;
|
||||
let list=S.backlog.slice();
|
||||
if(S.filter==="atrasadas") list=list.filter(t=>t.due!==null);
|
||||
if(S.filter==="sinfecha") list=list.filter(t=>t.due===null);
|
||||
const gap=I.gap;
|
||||
list.sort((a,b)=>{
|
||||
const fa=a.est>0&&a.est<=gap, fb=b.est>0&&b.est<=gap;
|
||||
if(fa!==fb) return fa?-1:1;
|
||||
if((a.est>0)!==(b.est>0)) return a.est>0?-1:1;
|
||||
if((a.due!==null)!==(b.due!==null)) return a.due!==null?-1:1;
|
||||
return b.est-a.est;
|
||||
});
|
||||
const nAt=S.backlog.filter(t=>t.due!==null).length, nSf=S.backlog.filter(t=>t.due===null).length;
|
||||
|
||||
// Rendimiento semanal sobre días transcurridos
|
||||
// Los feriados no cuentan como día transcurrido, pero lo que cierres esos días suma como extra
|
||||
const sofar=weekDays().filter(x=>x<=today);
|
||||
const elapsed=sofar.filter(x=>(S.weekend||(x.getDay()%6)) && !isHoliday(x));
|
||||
const wDone=sofar.reduce((s,x)=>s+dayInfo(x).dH,0), wMeta=elapsed.length*S.meta*H;
|
||||
const rend=wMeta?Math.round(wDone/wMeta*100):0;
|
||||
const rc=rend>=90?"var(--done)":rend>=70?"var(--pend)":"var(--gap)";
|
||||
|
||||
$("side").innerHTML = `
|
||||
<div class="weekly">
|
||||
<div class="n" style="color:${rc}">${rend}%</div>
|
||||
<div>Rendimiento de la semana hasta hoy</div>
|
||||
<div class="hint" style="color:var(--muted);font-size:13px">${fmtH(wDone)} completadas de ${fmtH(wMeta)} en ${elapsed.length} día${elapsed.length===1?"":"s"} transcurrido${elapsed.length===1?"":"s"}.</div>
|
||||
</div>
|
||||
<h2>Para completar el ${d.getDate()} de ${MO[d.getMonth()]}</h2>
|
||||
<p class="need ${gap?"":"ok"}">${gap?`Te faltan <b>${fmtH(gap)}</b> de tareas planificadas.`:isHoliday(d)?`<b>Este día es ${offLabel(d).toLowerCase()}.</b> No hay meta, pero puedes adelantar trabajo.`:`<b>Ya tienes ${S.meta} h planificadas.</b> Puedes adelantar trabajo igual.`}
|
||||
${isPast?" Este día ya pasó: traer tareas aquí no cambia lo que no se cerró.":""}</p>
|
||||
<div class="filter">
|
||||
<button class="chip ${S.filter==="todas"?"on":""}" data-f="todas">Todas (${S.backlog.length})</button>
|
||||
<button class="chip ${S.filter==="atrasadas"?"on":""}" data-f="atrasadas">Atrasadas (${nAt})</button>
|
||||
<button class="chip ${S.filter==="sinfecha"?"on":""}" data-f="sinfecha">Sin fecha (${nSf})</button>
|
||||
</div>
|
||||
${list.slice(0,25).map(t=>{
|
||||
const late=t.due!==null?Math.round((today-startOfDay(t.due))/DAY):0;
|
||||
const fits=t.est>0&&t.est<=gap;
|
||||
return `<div class="sug">
|
||||
<span class="tn">${tagChips(t)} <a href="${esc(t.url)}" target="_blank" rel="noopener">${esc(t.name)}</a></span>
|
||||
<span class="act"><span class="hrs">${t.est?fmtH(t.est):"sin est."}</span>
|
||||
<button class="btn small" data-mv="${esc(t.id)}">Traer al ${d.getDate()}</button></span>
|
||||
<span class="meta">${t.due!==null?`<span class="late">vencida hace ${late} día${late===1?"":"s"}</span>`:"sin fecha"}${fits?' · <span class="fits">encaja</span>':""}${t.est?"":" · no sumará sin estimación"}</span>
|
||||
</div>`;
|
||||
}).join("")||'<div class="empty">No tienes tareas atrasadas ni sin fecha. Crea tareas nuevas en ClickUp y pulsa Actualizar.</div>'}`;
|
||||
$("side").querySelectorAll("[data-f]").forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;renderSide()});
|
||||
$("side").querySelectorAll("[data-mv]").forEach(b=>b.onclick=async()=>{
|
||||
const t=S.backlog.find(x=>x.id===b.dataset.mv); if(!t) return;
|
||||
b.disabled=true; b.textContent="Moviendo…";
|
||||
try{await moveTask(t,S.sel); toast(`Movida al ${fullDate(S.sel).toLowerCase()}`); render()}
|
||||
catch(e){showMsg(e.message); b.disabled=false; b.textContent="Traer al "+S.sel.getDate()}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Nueva tarea ---------- */
|
||||
const SPRINTS_FOLDER = (window.M8_CONFIG&&window.M8_CONFIG.sprintsFolderId)||"90117541378";
|
||||
const DONE_STATUS = "terminado";
|
||||
const NEW_STATUS = "asignado"; // estado con el que se crea la tarea; luego se pasa a terminado con el botón
|
||||
const NT = {all:[],allLoaded:false,sprints:[],clients:[],users:[],assignees:[],clientFieldId:"",bossFieldId:"",touched:false,loaded:false,demo:false};
|
||||
|
||||
// "Sprint 19 (10/5 - 10/19)" → número y fecha final (el año se infiere: el más cercano a hoy)
|
||||
function sprintInfo(l){
|
||||
const m=/^Sprint\s+(\d+)\s*\((\d+)\/(\d+)\s*-\s*(\d+)\/(\d+)\)/i.exec(l.name||""); if(!m) return null;
|
||||
const [n,sm,sd,em,ed]=m.slice(1).map(Number), Y=new Date().getFullYear(), now=Date.now();
|
||||
let best=null;
|
||||
for(const y of [Y-1,Y,Y+1]){
|
||||
const start=new Date(y,sm-1,sd).getTime(), end=new Date(y+(em<sm?1:0),em-1,ed,23,59,59).getTime();
|
||||
if(!best||Math.abs(end-now)<Math.abs(best.end-now)) best={start,end};
|
||||
}
|
||||
return {id:l.id,name:l.name,n,start:best.start,end:best.end};
|
||||
}
|
||||
// Todos los Sprints de la carpeta (abiertos y cerrados), para saber a cuál pertenece cada día
|
||||
async function loadSprints(){
|
||||
if(S.demo){NT.all=[{id:"demo",name:"Sprint 19 (10/5 - 10/19)",n:19,start:0,end:Infinity}];NT.allLoaded=false;return}
|
||||
if(NT.allLoaded) return;
|
||||
const d=await api(`/folder/${SPRINTS_FOLDER}/list?archived=false`);
|
||||
NT.all=(d.lists||[]).map(sprintInfo).filter(Boolean).sort((a,b)=>a.n-b.n);
|
||||
NT.allLoaded=true;
|
||||
}
|
||||
// Si un día cae en dos Sprints (día límite), gana el que empieza más tarde
|
||||
function sprintOf(d){
|
||||
const t=startOfDay(d).getTime();
|
||||
return NT.all.filter(s=>t>=s.start&&t<=s.end).sort((a,b)=>b.start-a.start)[0]||null;
|
||||
}
|
||||
async function loadNtMeta(){
|
||||
if(NT.loaded&&NT.demo===S.demo) return;
|
||||
NT.demo=S.demo;
|
||||
if(S.demo){
|
||||
NT.sprints=[{id:"demo",name:"Sprint 19 (10/5 - 10/19)",n:19,start:0,end:Infinity}];
|
||||
NT.clients=["Andes","Pacífico","Interno"].map((n,i)=>({id:"c"+i,name:n}));
|
||||
NT.users=[{id:"0",name:"Ejemplo"},{id:"1",name:"Jefatura Demo"}];
|
||||
NT.loaded=true; return;
|
||||
}
|
||||
await loadSprints();
|
||||
const today=startOfDay(new Date()).getTime();
|
||||
NT.sprints=NT.all.filter(s=>s.end>=today);
|
||||
if(!NT.sprints.length) throw new Error("No hay Sprints abiertos en la carpeta de Sprints.");
|
||||
const f=await api(`/list/${NT.sprints[0].id}/field`);
|
||||
const byName=n=>(f.fields||[]).find(x=>(x.name||"").trim().toLowerCase()===n);
|
||||
const c=byName("clientes"), j=byName("jefatura");
|
||||
if(!c||!j) throw new Error('No encontré los campos "Clientes" y "Jefatura" en la lista del Sprint.');
|
||||
NT.clientFieldId=c.id; NT.bossFieldId=j.id;
|
||||
NT.clients=((c.type_config&&c.type_config.options)||[]).map(o=>({id:o.id,name:o.name}));
|
||||
const team=S.teams.find(t=>t.id===S.team);
|
||||
NT.users=((team&&team.members)||[]).map(m=>m.user).filter(Boolean).map(u=>({id:String(u.id),name:u.username||u.email||String(u.id)}));
|
||||
NT.loaded=true;
|
||||
}
|
||||
function parseHrs(s){
|
||||
s=String(s||"").trim().replace(",",".");
|
||||
let min=0, m=/^(\d+):(\d{1,2})$/.exec(s);
|
||||
if(m) min=Number(m[1])*60+Number(m[2]); else if(/^\d*\.?\d+$/.test(s)) min=Math.round(Number(s)*60);
|
||||
return min*60000;
|
||||
}
|
||||
function ntDay(){const v=$("ntDate").value; if(!v) return null; const [y,m,d]=v.split("-"); return new Date(y,m-1,d)}
|
||||
function findIn(list,text){const t=text.trim().toLowerCase(); return t?list.find(x=>x.name.trim().toLowerCase()===t):null}
|
||||
function ntHint(){
|
||||
const d=ntDay(), el=$("ntHint"), ms=parseHrs($("ntHrs").value);
|
||||
if(!d){el.textContent="";return}
|
||||
const I=dayInfo(d), base=I.dH+I.pH, tot=base+ms;
|
||||
let t,warn=false;
|
||||
if(!ms){t="El tiempo estimado debe ser mayor a 0:00.";warn=true}
|
||||
else if(I.meta===0) t="Este día no tiene meta (feriado o vacaciones): las horas cuentan como extra.";
|
||||
else if(tot>I.meta){t=`Con esto el día queda en ${fmtH(tot)} y pasas de la meta de ${fmtH(I.meta)}. Igual se creará.`;warn=true}
|
||||
else if(tot===I.meta) t=`Con esto llegas a la meta de ${fmtH(I.meta)}.`;
|
||||
else t=`Con esto llevas ${fmtH(tot)} de ${fmtH(I.meta)}; faltarían ${fmtH(I.meta-tot)}.`;
|
||||
el.textContent=t; el.className=warn?"warn":"";
|
||||
}
|
||||
function proposeHrs(){
|
||||
const d=ntDay(); if(!d) return;
|
||||
const off=offLabel(d);
|
||||
$("ntOff").hidden=!off;
|
||||
if(off) $("ntOff").innerHTML=`<b>Ese día es ${esc(off.toLowerCase())}</b> y no debería tener tareas. Puedes crearla igual; las horas contarán como extra.`;
|
||||
const I=dayInfo(d), gap=I.gap>0?I.gap:H;
|
||||
if(!NT.touched) $("ntHrs").value=fmtH(gap);
|
||||
$("ntPresets").innerHTML=[["Hueco "+fmtH(gap),gap],["0:30",H/2],["1:00",H],["2:00",2*H],["4:00",4*H]]
|
||||
.map(([l,ms])=>`<button type="button" class="chip" data-ms="${ms}">${l}</button>`).join("");
|
||||
$("ntPresets").querySelectorAll("[data-ms]").forEach(b=>b.onclick=()=>{NT.touched=true;$("ntHrs").value=fmtH(Number(b.dataset.ms));ntHint()});
|
||||
ntHint();
|
||||
}
|
||||
// Cada día pertenece al Sprint cuyo rango (desde - hasta) lo contiene
|
||||
function syncSprint(){
|
||||
const d=ntDay(); if(!d) return;
|
||||
const t=d.getTime(), cur=NT.sprints.filter(s=>t>=s.start&&t<=s.end).sort((a,b)=>b.start-a.start)[0];
|
||||
if(cur) $("ntList").value=cur.id;
|
||||
$("ntSprintHint").textContent=cur?"":"Ningún Sprint abierto incluye esta fecha. Elige otro de la lista.";
|
||||
$("ntSprintHint").className=cur?"":"warn";
|
||||
}
|
||||
function renderAssn(){
|
||||
$("ntAssn").innerHTML=NT.assignees.map(u=>`<button type="button" class="chip on" data-rm="${esc(u.id)}" title="Quitar">${esc(u.name)} ✕</button>`).join("")||'<span class="empty">Sin asignados</span>';
|
||||
$("ntAssn").querySelectorAll("[data-rm]").forEach(b=>b.onclick=()=>{NT.assignees=NT.assignees.filter(u=>u.id!==b.dataset.rm);renderAssn()});
|
||||
}
|
||||
async function openNt(){
|
||||
showMsg("");
|
||||
try{ await loadNtMeta() }catch(e){ showMsg(esc(e.message)); return }
|
||||
const d=S.sel;
|
||||
$("ntName").value=""; $("ntClient").value=""; $("ntBoss").value=""; $("ntAssIn").value=""; $("ntErr").hidden=true;
|
||||
$("ntDate").value=dkey(d);
|
||||
$("ntList").innerHTML=NT.sprints.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join("");
|
||||
syncSprint();
|
||||
$("dlClients").innerHTML=NT.clients.map(c=>`<option value="${esc(c.name)}"></option>`).join("");
|
||||
$("dlUsers").innerHTML=NT.users.map(u=>`<option value="${esc(u.name)}"></option>`).join("");
|
||||
NT.assignees=NT.users.filter(u=>S.user&&String(S.user.id)===u.id);
|
||||
NT.touched=false; renderAssn(); proposeHrs();
|
||||
$("nt").showModal();
|
||||
}
|
||||
async function createTask(){
|
||||
const err=m=>{$("ntErr").textContent=m;$("ntErr").hidden=false};
|
||||
$("ntErr").hidden=true;
|
||||
const name=$("ntName").value.trim(), d=ntDay(), ms=parseHrs($("ntHrs").value);
|
||||
const client=findIn(NT.clients,$("ntClient").value), boss=findIn(NT.users,$("ntBoss").value);
|
||||
if(!name) return err("Escribe el asunto de la tarea.");
|
||||
if(!d) return err("Elige la fecha.");
|
||||
if(!ms) return err("El tiempo estimado debe ser mayor a 0:00.");
|
||||
if(!client) return err("Elige un cliente de la lista.");
|
||||
if(!boss) return err("Elige una jefatura de la lista.");
|
||||
if(!NT.assignees.length) return err("Agrega al menos un asignado.");
|
||||
const due=new Date(d).setHours(12,0,0,0), btn=$("ntSave");
|
||||
btn.disabled=true; btn.textContent="Creando…";
|
||||
try{
|
||||
let task;
|
||||
if(S.demo) task={id:"n"+Date.now(),name,url:"#",closed:false,status:NEW_STATUS,color:"#D99A16",est:ms,due,list:"Sprint demo",clients:[{name:client.name,color:null}]};
|
||||
else{
|
||||
const r=await api(`/list/${$("ntList").value}/task`,{method:"POST",body:JSON.stringify({
|
||||
name,status:NEW_STATUS,due_date:due,due_date_time:false,time_estimate:ms,
|
||||
assignees:NT.assignees.map(u=>Number(u.id)),
|
||||
custom_fields:[{id:NT.clientFieldId,value:client.id},{id:NT.bossFieldId,value:{add:[Number(boss.id)],rem:[]}}]
|
||||
})});
|
||||
task=norm(r); if(!task.clients.length) task.clients=[{name:client.name,color:null}];
|
||||
}
|
||||
if(due>=S.weekStart.getTime()&&due<addDays(S.weekStart,7).getTime()) S.weekTasks.push(task);
|
||||
$("nt").close(); toast(`Creada el ${fullDate(d).toLowerCase()}`); render();
|
||||
}catch(e){ err(e.message) }
|
||||
finally{ btn.disabled=false; btn.textContent="Crear tarea" }
|
||||
}
|
||||
$("ntDate").onchange=()=>{syncSprint();proposeHrs()};
|
||||
$("ntHrs").oninput=()=>{NT.touched=true;ntHint()};
|
||||
$("ntAssIn").onchange=()=>{
|
||||
const u=findIn(NT.users,$("ntAssIn").value);
|
||||
if(u&&!NT.assignees.find(x=>x.id===u.id)){NT.assignees.push(u);renderAssn()}
|
||||
$("ntAssIn").value="";
|
||||
};
|
||||
$("ntCancel").onclick=()=>$("nt").close();
|
||||
$("ntSave").onclick=createTask;
|
||||
|
||||
/* ---------- Editar estimado ---------- */
|
||||
let estTask=null;
|
||||
function estHint(){
|
||||
const el=$("estHint"), ms=parseHrs($("estHrs").value);
|
||||
if(!estTask||!estTask.due){el.textContent="";return}
|
||||
const I=dayInfo(new Date(estTask.due));
|
||||
const tot=I.dH+I.pH-estTask.est+ms; // el estimado actual (0 si no tiene) se reemplaza por el nuevo
|
||||
if(!ms){el.textContent="El tiempo estimado debe ser mayor a 0:00.";el.className="warn"}
|
||||
else if(I.meta===0){el.textContent="Este día no tiene meta (feriado o vacaciones): las horas cuentan como extra.";el.className=""}
|
||||
else{el.textContent=`Con esto el día queda en ${fmtH(tot)} de ${fmtH(I.meta)}.`+(tot>I.meta?" Pasas de la meta.":"");el.className=tot>I.meta?"warn":""}
|
||||
}
|
||||
function openEst(id){
|
||||
estTask=S.weekTasks.find(t=>t.id===id); if(!estTask) return;
|
||||
$("estName").textContent=estTask.name;
|
||||
$("estHrs").value=estTask.est?fmtH(estTask.est):"";
|
||||
$("estErr").hidden=true; estHint();
|
||||
$("est").showModal(); $("estHrs").focus();
|
||||
}
|
||||
async function saveEst(){
|
||||
const ms=parseHrs($("estHrs").value), btn=$("estSave");
|
||||
if(!ms){$("estErr").textContent="El tiempo estimado debe ser mayor a 0:00.";$("estErr").hidden=false;return}
|
||||
btn.disabled=true; btn.textContent="Guardando…";
|
||||
try{
|
||||
if(!S.demo) await api(`/task/${estTask.id}`,{method:"PUT",body:JSON.stringify({time_estimate:ms})});
|
||||
estTask.est=ms; $("est").close(); toast(`Estimado guardado: ${fmtH(ms)}`); render();
|
||||
}catch(e){$("estErr").textContent=e.message;$("estErr").hidden=false}
|
||||
finally{btn.disabled=false;btn.textContent="Guardar estimado"}
|
||||
}
|
||||
$("estHrs").oninput=estHint;
|
||||
$("estHrs").onkeydown=e=>{if(e.key==="Enter"){e.preventDefault();saveEst()}};
|
||||
$("estCancel").onclick=()=>$("est").close();
|
||||
$("estSave").onclick=saveEst;
|
||||
|
||||
/* ---------- Mover tarea a otro día ---------- */
|
||||
let mvTask=null;
|
||||
function mvDay(){const v=$("mvDate").value; if(!v) return null; const [y,m,d]=v.split("-"); return new Date(y,m-1,d)}
|
||||
function mvWarn(){
|
||||
const d=mvDay(), off=d&&offLabel(d);
|
||||
$("mvOff").hidden=!off;
|
||||
if(off) $("mvOff").innerHTML=`<b>Ese día es ${esc(off.toLowerCase())}</b> y no debería tener tareas. Puedes moverla igual.`;
|
||||
}
|
||||
function openMv(id){
|
||||
mvTask=S.weekTasks.find(t=>t.id===id); if(!mvTask) return;
|
||||
$("mvName").textContent=mvTask.name;
|
||||
$("mvDate").value=dkey(addDays(startOfDay(mvTask.due),1)); // por defecto, el día siguiente
|
||||
$("mvErr").hidden=true; mvWarn(); $("mv").showModal();
|
||||
}
|
||||
async function saveMv(){
|
||||
const d=mvDay(), btn=$("mvSave");
|
||||
if(!d){$("mvErr").textContent="Elige la nueva fecha.";$("mvErr").hidden=false;return}
|
||||
const ms=new Date(d).setHours(12,0,0,0);
|
||||
btn.disabled=true; btn.textContent="Moviendo…";
|
||||
try{
|
||||
if(!S.demo) await api(`/task/${mvTask.id}`,{method:"PUT",body:JSON.stringify({due_date:ms,due_date_time:false})});
|
||||
mvTask.due=ms;
|
||||
if(ms<S.weekStart.getTime()||ms>=addDays(S.weekStart,7).getTime()) S.weekTasks=S.weekTasks.filter(x=>x!==mvTask); // salió de esta semana
|
||||
$("mv").close(); toast(`Movida al ${fullDate(d).toLowerCase()}`); render();
|
||||
}catch(e){$("mvErr").textContent=e.message;$("mvErr").hidden=false}
|
||||
finally{btn.disabled=false;btn.textContent="Mover tarea"}
|
||||
}
|
||||
$("mvDate").onchange=mvWarn;
|
||||
$("mvCancel").onclick=()=>$("mv").close();
|
||||
$("mvSave").onclick=saveMv;
|
||||
|
||||
/* ---------- Utilidades UI ---------- */
|
||||
function showMsg(t,info){$("msg").innerHTML=t?`<div class="msg ${info?"info":""}">${t}</div>`:""}
|
||||
let tt; function toast(t){const el=$("toast");el.textContent=t;el.classList.add("show");clearTimeout(tt);tt=setTimeout(()=>el.classList.remove("show"),2200)}
|
||||
|
||||
async function refresh(){
|
||||
showMsg("");
|
||||
if(S.demo){demoData();loadSprints().then(render);render();return}
|
||||
if(!S.key){openCfg();return}
|
||||
$("app").classList.add("loading");
|
||||
try{ if(!S.user) await connect(); await loadWeek(); render(); loadSprints().then(render).catch(()=>{}); }
|
||||
catch(e){
|
||||
const net = e instanceof TypeError;
|
||||
showMsg(net?"No se pudo conectar con ClickUp. Revisa tu conexión a internet. Si abriste el archivo con doble clic y el navegador bloquea la conexión, ábrelo desde un servidor local (en la carpeta del archivo: <b>python3 -m http.server</b> y entra a localhost:8000/mis-8-horas.html).":esc(e.message));
|
||||
render();
|
||||
}
|
||||
finally{$("app").classList.remove("loading")}
|
||||
}
|
||||
|
||||
/* ---------- Ajustes ---------- */
|
||||
function openCfg(){
|
||||
$("fUseWrap").hidden=!FILE_KEY_OK; $("fUseFile").checked=useFile; syncKeyField();
|
||||
if(!useFile) $("fKey").value=store.get("key","");
|
||||
$("fMeta").value=S.meta; $("fWeekend").checked=S.weekend;
|
||||
if(S.teams.length){$("fTeamWrap").hidden=false;$("fTeam").innerHTML=S.teams.map(t=>`<option value="${esc(t.id)}" ${t.id===S.team?"selected":""}>${esc(t.name)}</option>`).join("")}
|
||||
$("cfg").showModal();
|
||||
}
|
||||
$("fSave").onclick=async()=>{
|
||||
useFile=FILE_KEY_OK&&$("fUseFile").checked;
|
||||
const k=useFile?FILE_KEY:$("fKey").value.trim();
|
||||
S.meta=Math.max(1,Number($("fMeta").value)||8); S.weekend=$("fWeekend").checked;
|
||||
if(k!==S.key){S.user=null;S.teams=[]}
|
||||
S.key=k; S.demo=false;
|
||||
if(!$("fTeamWrap").hidden && $("fTeam").value && $("fTeam").value!==S.team){S.team=$("fTeam").value}
|
||||
store.set("useFile",useFile); if(!useFile) store.set("key",S.key);
|
||||
store.set("meta",S.meta);store.set("weekend",S.weekend);store.set("demo",false);store.set("team",S.team);
|
||||
$("cfg").close(); refresh();
|
||||
};
|
||||
$("fDemo").onclick=()=>{
|
||||
S.meta=Math.max(1,Number($("fMeta").value)||8); S.weekend=$("fWeekend").checked;
|
||||
S.demo=true; if(!useFile) store.set("demo",true); store.set("meta",S.meta);store.set("weekend",S.weekend);
|
||||
$("cfg").close(); refresh();
|
||||
};
|
||||
function syncKeyField(){
|
||||
const f=FILE_KEY_OK&&$("fUseFile").checked;
|
||||
$("fKey").disabled=f; $("fKey").placeholder=f?"Leída desde config.js":"pk_...";
|
||||
if(f) $("fKey").value="";
|
||||
}
|
||||
$("fUseFile").onchange=syncKeyField;
|
||||
$("fCancel").onclick=()=>$("cfg").close(); // no guarda nada; el diálogo se rellena de nuevo al abrirlo
|
||||
$("cfgB").onclick=openCfg;
|
||||
$("refresh").onclick=refresh;
|
||||
$("prevW").onclick=()=>{S.weekStart=addDays(S.weekStart,-7);S.sel=S.weekStart;refresh()};
|
||||
$("nextW").onclick=()=>{S.weekStart=addDays(S.weekStart,7);S.sel=S.weekStart;refresh()};
|
||||
$("todayB").onclick=()=>{S.weekStart=mondayOf(new Date());S.sel=startOfDay(new Date());refresh()};
|
||||
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user