<!-- Generated by scripts/build-prompts.mjs — do not edit by hand. -->
# Constrúyeme esta herramienta: Project portfolio

*Todo lo que sigue es la especificación funcional. Síguela tal como está; donde calle, decide tú y di qué supusiste.*

## Punto de partida

Usa la plantilla **openToolbox**: https://github.com/m-dohmen/openToolbox. Lee primero `AGENTS.md` en ese repositorio — es la autoridad sobre la forma del esquema, los tipos de campo y las reglas que rompen una compilación de archivo único. Todo lo específico del dominio va en un solo archivo, `src/domain.js`.

> Si tienes instalado el skill de openToolbox (`claude plugin install opentoolbox@opentoolbox`), basta con pegar este archivo — él mismo se trae la plantilla.

## El problema que resuelve

Una consultora lleva varios proyectos de cliente a la vez. Cada uno tiene presupuesto, responsable, fase e hitos que le pertenecen. La pregunta que se repite en cada comité es si el proyecto sigue dentro de presupuesto, y la respuesta suele armarse a mano desde una hoja de cálculo en la que nadie confía.

## Qué debe quedar al final

Un único archivo HTML autocontenido, que se abre con doble clic, sin servidor y sin instalación. El archivo es también la base de datos: guardar escribe un nuevo HTML con los registros incrustados.

## Tipos de registro

Clave de entidad `projects` — un registro es **project**, varios son **projects**.

**Campos**

| Clave | Etiqueta | Tipo | Detalle |
| --- | --- | --- | --- |
| `name` | Project | text | obligatorio |
| `client` | Client | text | — |
| `lead` | Engagement lead | text | cabecera de tabla `Lead` |
| `phase` | Phase | enum | uno de: Initiation · Delivery · Rollout · Closed |
| `risk` | Risk | enum | uno de: low · medium · high |
| `budget` | Budget in kEUR | number | cabecera de tabla `Budget` |
| `spent` | Spent in kEUR | number | cabecera de tabla `Spent` |
| `start` | Start | date | — |
| `end` | Planned end | date | cabecera de tabla `End` |
| `variance` | Budget left | computed | calculado, nunca almacenado, cabecera de tabla `Left` |
| `note` | Note | text | varias líneas |

**Presentación**

- Columna principal: `name`
- Segunda línea debajo: `client`
- Columnas de la tabla, en este orden: `name`, `lead`, `phase`, `risk`, `budget`, `variance`, `end`
- Filtros de la barra lateral: `phase`, `risk`
- Sumado en el resumen: `budget`
- Deja de contar como abierto cuando: `r.phase === 'Closed'`
- Se marca en rojo cuando: `r.phase !== 'Closed' && r.end && r.end < today()`

**Campos calculados**

Se derivan en cada renderizado y nunca se escriben en el registro: una derivación almacenada queda obsoleta en cuanto cambia una de sus entradas.

- `variance` (Budget left) — `(Number(r.budget) || 0) - (Number(r.spent) || 0)`

**Reglas de validación**

Condiciones entre campos. Deben aplicarse en un único lugar para que el formulario, la importación CSV y lo que proponga la IA pasen por la misma comprobación.

- **Cuando** `r.phase !== 'Initiation'` → **Entonces** `lead`
  **Mensaje:** „A project past initiation needs an engagement lead.“
- **Cuando** `r.start && r.end` → **Entonces** `r.end >= r.start`
  **Mensaje:** „The planned end cannot be before the start.“

**Métricas**

Declaradas en esta entidad a partir de un catálogo cerrado: recuento, suma y promedio sobre campos numéricos. Se calculan al renderizar y nunca se almacenan.

*El framework formatea los promedios con dos decimales en la notación decimal del idioma de la interfaz, rechaza las declaraciones inválidas nombrándolas al cargar en lugar de ocultarlas, y un clic en una tarjeta salta a la lista de esa entidad, sin filtrar en esta versión.*

- **Running projects** — el número de registros (solo registros que cumplen un filtro): `r.phase !== 'Closed'` · „not yet closed“
- **Spent so far** — la suma de `spent` (Spent in kEUR) · „kEUR, all projects“
- **Average budget** — el promedio de `budget` (Budget in kEUR) · „kEUR per project“
- **Budget left** — la suma de `variance` (Budget left) · „kEUR remaining, computed“

---

Clave de entidad `milestones` — un registro es **milestone**, varios son **milestones**.

**Campos**

| Clave | Etiqueta | Tipo | Detalle |
| --- | --- | --- | --- |
| `title` | Milestone | text | obligatorio |
| `projectId` | Project | reference | obligatorio, referencia a `projects` |
| `owner` | Owner | text | — |
| `due` | Due date | date | cabecera de tabla `Due` |
| `status` | Status | enum | uno de: open · in progress · waiting · done |
| `effort` | Effort in days | number | cabecera de tabla `D` |
| `daysLeft` | Days left | computed | calculado, nunca almacenado, cabecera de tabla `Left` |
| `note` | Note | text | varias líneas |

**Presentación**

- Columna principal: `title`
- Columnas de la tabla, en este orden: `title`, `projectId`, `owner`, `due`, `daysLeft`, `status`, `effort`
- Filtros de la barra lateral: `status`
- Sumado en el resumen: `effort`
- Deja de contar como abierto cuando: `r.status === 'done'`
- Se marca en rojo cuando: `r.status !== 'done' && r.due && r.due < today()`

**Campos calculados**

Se derivan en cada renderizado y nunca se escriben en el registro: una derivación almacenada queda obsoleta en cuanto cambia una de sus entradas.

- `daysLeft` (Days left):

  ```js
  if (!r.due || r.status === 'done') return ''
  const due = localDateFromIso(r.due)
  if (!due) return ''
  // Both sides as whole local calendar days: Date.UTC on the day
  // components keeps the difference an exact day count, free of the
  // daylight-saving hours that break a plain millisecond division -
  // and of mixing a UTC-midnight constructor with local midnight.
  const now = new Date()
  const days =
    (Date.UTC(due.getFullYear(), due.getMonth(), due.getDate()) -
      Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) /
    86400000
  return days
  ```

**Reglas de validación**

Condiciones entre campos. Deben aplicarse en un único lugar para que el formulario, la importación CSV y lo que proponga la IA pasen por la misma comprobación.

- **Cuando** `r.status !== 'open'` → **Entonces** `owner`
  **Mensaje:** „A milestone that has started needs an owner.“

**Métricas**

Declaradas en esta entidad a partir de un catálogo cerrado: recuento, suma y promedio sobre campos numéricos. Se calculan al renderizar y nunca se almacenan.

*El framework formatea los promedios con dos decimales en la notación decimal del idioma de la interfaz, rechaza las declaraciones inválidas nombrándolas al cargar en lugar de ocultarlas, y un clic en una tarjeta salta a la lista de esa entidad, sin filtrar en esta versión.*

- **Milestones in progress** — el número de registros (solo registros que cumplen un filtro): `r.status === 'in progress'`
- **Average effort** — el promedio de `effort` (Effort in days) · „days per milestone“

---

## Panel

Fichas sobre todo el conjunto de registros, no sobre la vista filtrada.

- Un número: **Portfolio budget** — `budget` (Budget in kEUR) (solo registros que cumplen un filtro): `r.phase !== 'Closed'` · „kEUR, running projects“
- Un número: **High risk** — el número de registros (solo registros que cumplen un filtro): `r.risk === 'high'` · „projects needing attention“
- Un número: **Overdue milestones** — el número de registros (solo registros que cumplen un filtro): `r.status !== 'done' && r.due && r.due < today()` · „across all projects“
- Un anillo por valor de `phase`
- Barras por valor de `phase`, midiendo `budget` (Budget in kEUR) — **Budget by phase**
- Un anillo por valor de `status`

## Captura guiada

Una secuencia breve de pasos para quien tiene que reportar una cosa y no conoce la herramienta. No se escribe nada hasta confirmar el último paso: abandonarla no debe dejar rastro.

Título: **Add an engagement**

> Three steps: the engagement, its first milestone, then a check before anything is written.

1. **Paso 1 `projects`** — Engagement: campos: `name`, `client`, `lead`, `phase`, `risk`, `budget`, `start`, `end`
2. **Paso 2 `milestones`** — First milestone: campos: `title`, `projectId`, `owner`, `due`, `status`, `effort`
   solo cuando: `drafts.projects?.phase !== 'Initiation'`
3. **Paso 3 `milestones`** — More milestones: subida de CSV, que alimenta la misma sesión
   solo cuando: `Boolean(drafts.projects?.name)`
4. **Paso 4** — Check: resumen generado a partir del esquema

Pantalla final: «The engagement is in the file.»

## Valores por defecto

Ponlos en `DEFAULT_SETTINGS`, `DEFAULT_COLORS` y `DEFAULT_HOME` en `src/app.jsx`.

- Título: **Project portfolio**
- Subtítulo: Engagements, milestones and where the budget stands
- Nombre de archivo: `project-portfolio`
- Versión: `2.1`
- Idioma de la interfaz: `en`
- Se abre como: herramienta completa
- Colores: `accent` #0e7c86 · `band` #16202b · `flag` #c2521b · `ok` #2e7d5b · `pending` #d19a0a

## Página de inicio

La aplicación abre con este texto. Es un subconjunto de Markdown: títulos, listas, citas, negrita, cursiva, código en línea y enlaces. Úsalo literalmente:

```markdown
# Project portfolio

A worked example built with **openToolbox**: engagements, their milestones, and where the budget
stands. Everything you see comes out of one file, `src/domain.js`.

## What to try

- **List** — two record types that reference each other, calculated columns, filters that count
- **Search and filter** — the box in the header searches every field of both record types at once,
  and the sidebar narrows by contained text and by number and date ranges; active filters appear
  as removable chips above the table
- **Dashboard** — the same data as tiles, drawn without a charting library, plus a due-date widget
  grouping milestones into overdue, this week and the next 30 days, and metric tiles — running
  projects, spend, averages — computed from the data rather than typed
- **Guided entry** — a short wizard that creates an engagement and its first milestone in one run
- **Merge a file** — reconcile a copy that came back from someone else

> This page is editable in the app itself. In a tool you deliver, put here what the recipients
> need: what it is for, who maintains it, and where to ask.
```

## Datos de ejemplo

Añade projects: 7, milestones: 13 registros de ejemplo realistas para que el archivo no esté vacío al abrirlo. Invéntalos al estilo de los campos anteriores; son ilustración, no los datos del usuario. Dile que sus datos entran por **Import CSV → replace all**.

## Terminado cuando

- `npm run build` produce un único `dist/index.html` autocontenido
- `npm test` pasa
- el archivo se abre con doble clic y muestra los registros de ejemplo
- los campos calculados muestran valores y las reglas rechazan un registro que las incumpla
- los ajustes, colores y página de inicio coinciden con lo especificado arriba

## Antes de entregarla

Decide esto tú, no lo dejes al destinatario: pon `copyright` a quien sea dueño de la herramienta, sustituye el enlace de cabecera que apunta al repositorio de openToolbox y desactiva `examplePrompts` si el destinatario solo introduce datos. Menciona el contador de aperturas (Ajustes → Seguridad) al entregarla.

---

*Generado a partir de `examples/portfolio.domain.js`, el código real de la [demo en vivo](https://m-dohmen.github.io/openToolbox/demos/portfolio/). Regenerar con `npm run prompts`.*

*Todos los datos de la demo son inventados. Ilustra la estructura de una herramienta así — no es asesoramiento legal ni prueba de conformidad.*
