<!-- Generated by scripts/build-prompts.mjs — do not edit by hand. -->
# Build me this tool: Project portfolio

*Everything below is the functional specification. Follow it as written; where it is silent, use your judgement and say what you assumed.*

## Start here

Use the **openToolbox** template: https://github.com/m-dohmen/openToolbox. Read `AGENTS.md` in that repository first — it is the authority on the schema shape, the field types and the rules that break a single-file build. Everything domain-specific goes into one file, `src/domain.js`.

> If you have the openToolbox skill installed (`claude plugin install opentoolbox@opentoolbox`), just paste this file — it fetches the template itself.

## The problem this solves

A consultancy runs several client engagements at once. Each has a budget, a lead, a phase and milestones that belong to it. The recurring question in every steering meeting is whether the engagement is still inside its budget — and the answer is usually assembled by hand from a spreadsheet nobody trusts.

## What it has to be at the end

One self-contained HTML file, opened by double-click, no server and no installation. The file is also the database: saving writes a new HTML file with the records embedded in it.

## Record types

Entity key `projects` — one record is a **project**, several are **projects**.

**Fields**

| Key | Label | Type | Detail |
| --- | --- | --- | --- |
| `name` | Project | text | required |
| `client` | Client | text | — |
| `lead` | Engagement lead | text | table header `Lead` |
| `phase` | Phase | enum | one of: Initiation · Delivery · Rollout · Closed |
| `risk` | Risk | enum | one of: low · medium · high |
| `budget` | Budget in kEUR | number | table header `Budget` |
| `spent` | Spent in kEUR | number | table header `Spent` |
| `start` | Start | date | — |
| `end` | Planned end | date | table header `End` |
| `variance` | Budget left | computed | calculated, never stored, table header `Left` |
| `note` | Note | text | multi-line |

**Presentation**

- Headline column: `name`
- Second line under the headline: `client`
- Table columns, in this order: `name`, `lead`, `phase`, `risk`, `budget`, `variance`, `end`
- Sidebar filters: `phase`, `risk`
- Summed in the overview: `budget`
- No longer counts as open when: `r.phase === 'Closed'`
- Flagged red when: `r.phase !== 'Closed' && r.end && r.end < today()`

**Calculated fields**

These are derived on every render and never written into the record — a stored derivation goes stale the moment one of its inputs changes.

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

**Validation rules**

Conditions between fields. They must be enforced in one place so that the edit form, the CSV import and anything the AI proposes all pass through the same check.

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

**Metric tiles**

Declared on this entity from a closed catalog — count, sum and average over numeric fields. Computed at render time, never stored.

*The framework formats averages with two decimals in the interface language’s decimal notation, rejects invalid declarations by name when the file loads instead of hiding them, and a tile click jumps to that entity’s list — unfiltered in this version.*

- **Running projects** — the number of records (only records matching a filter): `r.phase !== 'Closed'` · „not yet closed“
- **Spent so far** — the sum of `spent` (Spent in kEUR) · „kEUR, all projects“
- **Average budget** — the average of `budget` (Budget in kEUR) · „kEUR per project“
- **Budget left** — the sum of `variance` (Budget left) · „kEUR remaining, computed“

---

Entity key `milestones` — one record is a **milestone**, several are **milestones**.

**Fields**

| Key | Label | Type | Detail |
| --- | --- | --- | --- |
| `title` | Milestone | text | required |
| `projectId` | Project | reference | required, reference to `projects` |
| `owner` | Owner | text | — |
| `due` | Due date | date | table header `Due` |
| `status` | Status | enum | one of: open · in progress · waiting · done |
| `effort` | Effort in days | number | table header `D` |
| `daysLeft` | Days left | computed | calculated, never stored, table header `Left` |
| `note` | Note | text | multi-line |

**Presentation**

- Headline column: `title`
- Table columns, in this order: `title`, `projectId`, `owner`, `due`, `daysLeft`, `status`, `effort`
- Sidebar filters: `status`
- Summed in the overview: `effort`
- No longer counts as open when: `r.status === 'done'`
- Flagged red when: `r.status !== 'done' && r.due && r.due < today()`

**Calculated fields**

These are derived on every render and never written into the record — a stored derivation goes stale the moment one of its inputs changes.

- `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
  ```

**Validation rules**

Conditions between fields. They must be enforced in one place so that the edit form, the CSV import and anything the AI proposes all pass through the same check.

- **When** `r.status !== 'open'` → **Then** `owner`
  **Message:** „A milestone that has started needs an owner.“

**Metric tiles**

Declared on this entity from a closed catalog — count, sum and average over numeric fields. Computed at render time, never stored.

*The framework formats averages with two decimals in the interface language’s decimal notation, rejects invalid declarations by name when the file loads instead of hiding them, and a tile click jumps to that entity’s list — unfiltered in this version.*

- **Milestones in progress** — the number of records (only records matching a filter): `r.status === 'in progress'`
- **Average effort** — the average of `effort` (Effort in days) · „days per milestone“

---

## Dashboard

Tiles over the whole record set, not the filtered view.

- A single number: **Portfolio budget** — `budget` (Budget in kEUR) (only records matching a filter): `r.phase !== 'Closed'` · „kEUR, running projects“
- A single number: **High risk** — the record count (only records matching a filter): `r.risk === 'high'` · „projects needing attention“
- A single number: **Overdue milestones** — the record count (only records matching a filter): `r.status !== 'done' && r.due && r.due < today()` · „across all projects“
- A ring per value of `phase`
- Bars per value of `phase`, measuring `budget` (Budget in kEUR) — **Budget by phase**
- A ring per value of `status`

## Guided entry

A short sequence of steps for someone who has to report one thing and does not know the tool. Nothing is written until the last step is confirmed — abandoning it must leave nothing behind.

Title: **Add an engagement**

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

1. **Step 1 `projects`** — Engagement: fields: `name`, `client`, `lead`, `phase`, `risk`, `budget`, `start`, `end`
2. **Step 2 `milestones`** — First milestone: fields: `title`, `projectId`, `owner`, `due`, `status`, `effort`
   shown only when: `drafts.projects?.phase !== 'Initiation'`
3. **Step 3 `milestones`** — More milestones: CSV upload, feeding the same run
   shown only when: `Boolean(drafts.projects?.name)`
4. **Step 4** — Check: summary generated from the schema

Closing screen: “The engagement is in the file.”

## Defaults

Set these in `DEFAULT_SETTINGS`, `DEFAULT_COLORS` and `DEFAULT_HOME` in `src/app.jsx`.

- Title: **Project portfolio**
- Subtitle: Engagements, milestones and where the budget stands
- File name: `project-portfolio`
- Version: `2.1`
- Interface language: `en`
- Opens as: the full tool
- Colours: `accent` #0e7c86 · `band` #16202b · `flag` #c2521b · `ok` #2e7d5b · `pending` #d19a0a

## Start page

The app opens on this text. It is a small Markdown subset — headings, lists, quotes, bold, italic, inline code and links. Use it verbatim:

```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.
```

## Demo data

Add projects: 7, milestones: 13 realistic demo records so the file is not empty on first open. Invent them in the style of the examples above; they are illustration, not the user’s data. Tell the user their own data goes in through **Import CSV → replace all**.

## Done when

- `npm run build` produces one self-contained `dist/index.html`
- `npm test` passes
- the file opens by double-click and shows the demo records
- the calculated fields show values, and the rules refuse a record that violates them
- the settings, colours and start page match the specification above

## Before handing it over

Decide these rather than leaving them to the recipient: set `copyright` to whoever owns the tool, replace the header link that points at the openToolbox repository, and switch `examplePrompts` off if the recipient only enters data. Mention the usage counter (Settings → Security) at handover rather than letting someone find it in a network log.

---

*Generated from `examples/portfolio.domain.js` — the working source of the [live demo](https://m-dohmen.github.io/openToolbox/demos/portfolio/). Regenerate with `npm run prompts`.*

*All data in the demo is invented. It illustrates the structure of such a tool — it is not legal advice and not proof of anyone’s compliance.*
