Fragile is a self-hosted engineering metrics dashboard that connects to a Jira Cloud instance and surfaces DORA metrics, sprint and Kanban planning analytics, cycle time analysis, and roadmap accuracy tracking. It is designed for small engineering teams who want actionable delivery data without leaving their existing Jira workflow.
All Jira data is synced into a local PostgreSQL database on demand. Metric calculations run against the cached data, keeping the UI fast and Jira API usage low. Every aspect of the metric rules — done statuses, failure issue types, incident definitions, roadmap date field IDs — is configurable per board through the Settings UI, with no hardcoded assumptions in the codebase.
Access is controlled via Google Workspace SSO — any user in the configured Google Workspace domain can log in. The first user to log in becomes an admin; subsequent users are read-only by default. Admin users can manage board configuration, trigger syncs, and promote other users.
DORA Metrics — organisation-wide delivery performance across all boards
Cycle Time — distribution scatter plot with percentile cards and trend
Planning Accuracy — sprint commitment vs delivery with scope change tracking
Roadmap Accuracy — sprint work aligned to JPD roadmap items
Issues Gaps — open issues missing epic links or story point estimates
The DORA page shows all four DORA metrics — Deployment Frequency, Lead Time for Changes, Change Failure Rate, and MTTR — at the organisation level and broken down per board. Toggle between week and quarter views. Each metric card carries a DORA band badge (Elite / Good / Fair / Poor) derived from the DORA research thresholds, and a board breakdown table allows comparison across projects.
The Cycle Time page plots individual issue cycle times on a scatter chart with a trend line. Three percentile cards (p50, p75, p95) summarise the distribution. Each data point is annotated with its DORA band. Epics and sub-tasks are excluded from all cycle time calculations. Supports per-board filtering and week/quarter time range toggles. Cycle times are measured in working days by default (weekends and configured public holidays excluded); the unit label adapts to "calendar days" when weekend exclusion is disabled.
The Planning page provides a per-sprint breakdown for Scrum boards: issue count, story points, completion rate, and scope change percentage. Sprint membership history is reconstructed from the Jira changelog so that issues added or removed mid-sprint are counted accurately. Active sprints are flagged in the UI.
Kanban boards have no sprints. The Planning page adapts to show issues grouped by the week or
quarter in which they first entered the board (board-entry date derived from changelog). Completion
rate and throughput are shown per period. The dataStartDate board config field prevents old
backlog issues from inflating Kanban period counts.
The Roadmap page tracks whether delivered issues were backed by an active Jira Product Discovery (JPD) idea. Two metrics are reported:
- Roadmap Coverage — percentage of completed issues that are linked (via epic) to a JPD idea that was active during the delivery period.
- Roadmap Delivery Rate — percentage of covered issues that were actually completed.
A JPD idea is considered active only during its startDate–targetDate window. Both dates are
read from tenant-specific Polaris interval custom fields, configured in the Settings UI.
The Settings page exposes per-board configuration (done status names, in-progress status names, failure issue types, failure labels, failure link types, incident types, incident priorities, recovery statuses, backlog status IDs, and data start date) and per-JPD-project configuration (start date field ID, target date field ID). Changes are persisted to PostgreSQL and take effect on the next data request without requiring a restart.
A manual sync button in the Settings page triggers a full refresh of sprints, issues, changelogs,
versions, and JPD ideas from Jira. Sync status and last-synced timestamps are displayed per board.
Sync history (issue count, status, error messages) is stored in the sync_logs table.
Cycle time and lead time durations exclude weekends (and optionally configured public holidays) by
default. The WorkingTimeService walks calendar boundaries in the configured TIMEZONE using
Intl.DateTimeFormat with a binary-search algorithm to handle Daylight Saving Time correctly.
MTTR is measured in calendar hours (incidents are production events that do not pause on
weekends). Deployment frequency uses calendar days (unchanged). Weekend exclusion and the
working-day definition are configurable via the workingTime: stanza in boards.yaml or through
GET /api/config.
Fragile ships an MCP (Model Context Protocol) server —
@fragile.app/mcp — that lets AI assistants
query the metrics dashboard directly. Claude Desktop, Cursor, and any other MCP-compatible client
can invoke tools like get_dora_metrics or run prompt templates like dora_health_report without
manual API calls or copy-pasting from the UI.
The MCP server is a standalone subprocess that calls the Fragile REST API over HTTP. It requires no database access and makes no Jira API calls — all data comes from the PostgreSQL cache maintained by the sync service.
Claude Desktop — add to ~/Library/Application Support/Claude/claude_desktop_config.json
(macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"fragile": {
"command": "npx",
"args": ["-y", "@fragile.app/mcp"],
"env": {
"API_BASE_URL": "https://api.your-fragile-domain.com",
"API_KEY": "frg_your_generated_key"
}
}
}
}Cursor — add to .cursor/mcp.json:
{
"mcpServers": {
"fragile": {
"command": "npx",
"args": ["-y", "@fragile.app/mcp"],
"env": {
"API_BASE_URL": "https://api.your-fragile-domain.com",
"API_KEY": "frg_your_generated_key"
}
}
}
}Restart the client. The Fragile tools appear in the tool picker immediately.
| Variable | Required | Description |
|---|---|---|
API_BASE_URL |
Yes | Base URL of the Fragile API, e.g. https://api.your-fragile-domain.com or http://localhost:3001 for local use |
API_KEY |
Yes | A personal Fragile API key (generated in the app under API Keys). Sent as Authorization: Bearer <key>. The API requires authentication — an unset/invalid key returns 401. |
| Category | Tools |
|---|---|
| DORA | get_dora_metrics, get_dora_trend, get_snapshot_status |
| Planning | get_planning_accuracy, list_sprints, list_quarters |
| Cycle time | get_cycle_time, get_cycle_time_trend |
| Sprint | get_sprint_detail, get_sprint_report |
| Roadmap | get_roadmap_accuracy |
| Boards | list_boards, get_board_config |
| Sync | get_sync_status |
| Gaps | get_hygiene_gaps, get_unplanned_done |
All tools are read-only — no tool triggers a mutation in Fragile or Jira.
Pre-canned multi-tool workflows that return a structured Markdown report in a single invocation:
| Prompt | What it produces |
|---|---|
dora_health_report |
Org-level DORA bands, per-board breakdown, 4-quarter trend, and data freshness |
sprint_retrospective |
Planning accuracy, ticket-level classification, scope changes, and recommendations for a sprint |
release_readiness |
Readiness verdict combining sprint completion, DORA signals, hygiene gaps, and unplanned work |
quarterly_planning_review |
Cross-board planning accuracy, DORA aggregate, roadmap coverage, and observations for leadership |
See apps/mcp/README.md for full documentation, including the resource
URIs (boards://list, boards://{boardId}/config) and local development instructions.
| Layer | Technology |
|---|---|
| Frontend framework | Next.js 16 (App Router), React 19 |
| Frontend language | TypeScript (strict, no semicolons) |
| Styling | Tailwind CSS v4 (CSS-first configuration) |
| Charts | Recharts |
| State management | Zustand |
| Icons | lucide-react |
| Backend framework | NestJS 11 |
| Backend language | TypeScript (strict, semicolons, .js ESM imports) |
| ORM | TypeORM |
| Database | PostgreSQL 16 |
| Jira integration | Jira Cloud REST API v3 + Agile API v1 (Basic auth) |
| Task automation | GNU Make |
| Local infrastructure | Docker Compose |
Default ports: Frontend :3000 | Backend :3001 | PostgreSQL :5432
- Node.js 20 or later (both backend and frontend)
- PostgreSQL 16 — Docker is the simplest path (see Quick Start); an existing PostgreSQL instance works equally well
- Docker (optional, but recommended for running PostgreSQL locally)
- A Jira Cloud account with:
- At least one Jira Software project (Scrum or Kanban)
- An API token (see Jira Setup)
- Optionally, one or more Jira Product Discovery projects for roadmap accuracy
git clone https://github.com/your-org/fragile.git
cd fragileUsing Docker (recommended):
docker run -d \
--name fragile-db \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=fragile \
-p 5432:5432 \
postgres:16-alpineOr use the provided Docker Compose file:
docker compose up -dNote:
docker-compose.ymldefaults to the database namefragile. If you want to use a different name, edit bothdocker-compose.yml(thePOSTGRES_DBenvironment variable) andDB_DATABASEinbackend/.envto match.
cp backend/.env.example backend/.envEdit backend/.env and fill in your Jira credentials:
JIRA_BASE_URL=https://yourorg.atlassian.net
JIRA_USER_EMAIL=you@yourorg.com
JIRA_API_TOKEN=your_jira_api_token
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE=fragile
PORT=3001
FRONTEND_URL=http://localhost:3000
TIMEZONE=UTC
# Google OAuth (see Google OAuth Setup section)
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_ALLOWED_DOMAIN=yourorg.com
SESSION_SECRET=run-openssl-rand-hex-64-to-generateNote:
JIRA_BOARD_IDShas been removed from the.envfile. Boards are now registered through the Settings UI or viabackend/config/boards.yaml(see YAML Configuration below).
Fragile can pre-populate its board and roadmap configuration from two YAML files that live in
backend/config/. This is the recommended setup path — it is faster than configuring everything
through the Settings UI and can be committed to version control.
Copy the annotated example files:
cp backend/config/boards.example.yaml backend/config/boards.yaml
cp backend/config/roadmap.example.yaml backend/config/roadmap.yamlThese files are in .gitignore by default (they contain deployment-specific values). The
*.example.yaml templates are tracked in git and serve as the canonical field reference.
Both files are read by YamlConfigService on every application startup. Values in the YAML
files overwrite matching database rows on each restart. Boards or roadmaps in the database
that are absent from the YAML files are left untouched. This means partial YAML is safe — you
only need to list the boards you want to seed or override.
backend/config/boards.yaml
boards:
# Scrum board
- boardId: ACC # Jira project key — must be unique, normalised to UPPERCASE
boardType: scrum # "scrum" or "kanban"
doneStatusNames:
- Done
- Released
inProgressStatusNames:
- In Progress
- In Review
cancelledStatusNames:
- Cancelled
- "Won't Do"
failureIssueTypes: # Issue types that count toward Change Failure Rate
- Bug
- Incident
failureLinkTypes: # Link type names that flag a failure relationship
- "is caused by"
- "caused by"
failureLabels: # Issue labels that mark a deployment failure
- regression
- hotfix
incidentIssueTypes: # Issue types used for MTTR calculation
- Bug
- Incident
recoveryStatusNames: # Status names that mark incident resolution (MTTR end)
- Done
- Resolved
incidentLabels: [] # Additional labels for incident detection
incidentPriorities:
- Critical
backlogStatusIds: [] # Status IDs representing pre-board backlog (Kanban only)
dataStartDate: null # ISO date or null — lower bound for Kanban flow metrics
# Kanban board — note backlogStatusIds and dataStartDate
- boardId: PLAT
boardType: kanban
doneStatusNames:
- Done
- Released
inProgressStatusNames:
- In Progress
cancelledStatusNames:
- Cancelled
failureIssueTypes:
- Bug
failureLinkTypes:
- "is caused by"
failureLabels:
- regression
incidentIssueTypes:
- Bug
recoveryStatusNames:
- Done
incidentLabels: []
incidentPriorities:
- Critical
backlogStatusIds:
- "10303" # Jira status ID for the backlog column — issues here are excluded
dataStartDate: "2024-01-01" # Exclude issues that entered the board before this datebackend/config/roadmap.yaml
roadmaps:
- jpdKey: DISC # Jira Product Discovery project key
description: "Discovery roadmap" # Optional human-readable label
startDateFieldId: "customfield_10015" # Custom field ID for the idea start date
targetDateFieldId: "customfield_10021" # Custom field ID for the idea target/end date
- jpdKey: STRAT
description: "Strategic initiatives"
startDateFieldId: null # null = field not mapped; ideas excluded from coverage
targetDateFieldId: nullTo find the custom field IDs for JPD date fields, see Configuring JPD date fields.
If either YAML file is absent at startup, the application starts normally and all configuration falls back to the Settings UI — no error is raised and no migration is required.
cp frontend/.env.example frontend/.envfrontend/.env only needs one variable:
NEXT_PUBLIC_API_URL=http://localhost:3001make installOr manually:
cd backend && npm install
cd ../frontend && npm installThe backend must be compiled before migrations can run because data-source.ts references
compiled output in dist/.
make migrateOr manually:
cd backend && npm run build && npm run migration:runOpen two terminals:
# Terminal 1 — backend (NestJS, port 3001)
make dev-api
# Terminal 2 — frontend (Next.js, port 3000)
make dev-webOpen http://localhost:3000 in your browser.
Open http://localhost:3000 in your browser. You will be redirected to Google login. The first person to log in becomes the admin automatically. After logging in, navigate to Settings in the sidebar and click Sync now. The initial sync may take a minute or two depending on the number of issues and boards. Subsequent syncs are incremental.
| Target | Description |
|---|---|
make install |
Install npm dependencies for both backend and frontend |
make up |
Start PostgreSQL via Docker Compose |
make down |
Stop Docker Compose |
make migrate |
Build backend and run TypeORM migrations |
make seed |
Seed default board configurations |
make dev-api |
Start NestJS in watch mode (port 3001) |
make dev-web |
Start Next.js dev server (port 3000) |
make test-api |
Run backend Jest test suite |
make test-web |
Run frontend Vitest test suite |
make sync |
Trigger a manual Jira data sync via POST /api/sync |
make start |
Start Docker, backend, and frontend together |
make stop |
Kill running servers and stop Docker Compose |
make clean |
Wipe the database volume and re-run migrations |
make reset |
Full rebuild: stop everything, delete node_modules and dist, reinstall, remigrate |
| Variable | Required | Default | Description |
|---|---|---|---|
JIRA_BASE_URL |
Yes | — | Jira Cloud base URL, e.g. https://yourorg.atlassian.net |
JIRA_USER_EMAIL |
Yes | — | Email address associated with the Jira API token |
JIRA_API_TOKEN |
Yes | — | Jira API token (see Jira Setup) |
DB_HOST |
No | localhost |
PostgreSQL host |
DB_PORT |
No | 5432 |
PostgreSQL port |
DB_USERNAME |
No | postgres |
PostgreSQL username |
DB_PASSWORD |
No | postgres |
PostgreSQL password |
DB_DATABASE |
No | fragile |
PostgreSQL database name (must match docker-compose.yml) |
PORT |
No | 3001 |
Port the NestJS server listens on |
FRONTEND_URL |
No | http://localhost:3000 |
Allowed CORS origin for the frontend |
TIMEZONE |
No | UTC |
IANA timezone string used for quarter/week boundary calculations and working-time day boundaries, e.g. America/New_York |
BOARD_CONFIG_FILE |
No | config/boards.yaml |
Override path to the boards YAML file (absolute or relative to backend/) |
ROADMAP_CONFIG_FILE |
No | config/roadmap.yaml |
Override path to the roadmap YAML file (absolute or relative to backend/) |
GOOGLE_CLIENT_ID |
Yes | — | Google OAuth2 client ID (see Google OAuth Setup) |
GOOGLE_ALLOWED_DOMAIN |
No | mypassglobal.com |
Google Workspace domain to restrict login |
SESSION_SECRET |
Yes | — | Random secret for signing session cookies (min 64 hex chars) |
SESSION_MAX_AGE_MS |
No | 604800000 (7 days) |
Session maximum age in milliseconds |
Removed:
JIRA_BOARD_IDSis no longer used. Boards are registered through the Settings UI or viabackend/config/boards.yaml.
| Variable | Required | Default | Description |
|---|---|---|---|
NEXT_PUBLIC_API_URL |
No | http://localhost:3001 |
Base URL of the backend API |
The Jira account used for the API token needs the following permissions on each project:
- Browse projects — to read issues and sprints
- View development tools — to read changelogs and fix versions
- If using Jira Product Discovery: View ideas on each JPD project
A read-only service account is recommended for production deployments.
- Log in to https://id.atlassian.com.
- Navigate to Security > API tokens.
- Click Create API token, give it a label (e.g.
fragile), and copy the token. - Paste the token into
JIRA_API_TOKENinbackend/.env. - Set
JIRA_USER_EMAILto the email address of the account that owns the token.
Project keys are the uppercase prefix before issue numbers (e.g. ACC in ACC-123). They
appear in the URL when you open a Jira project: https://yourorg.atlassian.net/jira/software/projects/ACC/boards.
Add an entry for each project key you want to track in backend/config/boards.yaml under the
boards list (see YAML Configuration). Alternatively, boards can be
added through the Settings UI after the application is running.
Board type (Scrum vs Kanban) is stored in the BoardConfig entity. After the first sync you can
update boardType for each board through the Settings UI or directly in the database.
- Scrum boards support sprint-based Planning metrics.
- Kanban boards use changelog-derived board-entry dates for Planning metrics. Sprint Planning metrics are not available for Kanban boards.
Roadmap accuracy depends on reading start and target dates from JPD ideas. These dates are stored
in tenant-specific Polaris interval custom fields (type jira.polaris:interval). The field IDs
differ between Jira tenants and must be configured manually:
- In the Jira admin, go to Project settings > Fields for your JPD project and note the
custom field IDs for the start date and target date interval fields. Field IDs look like
customfield_10056. - Alternatively, fetch any JPD idea via the API and inspect the field keys in the response to identify which field holds the interval data.
- In Fragile, go to Settings > Roadmap configs and enter the field IDs for each JPD
project. Fragile will read
{"start":"YYYY-MM-DD","end":"YYYY-MM-DD"}from those fields and usestartasstartDateandendastargetDate.
For an issue to count as roadmap-covered, it must be linked to an Epic that is in turn linked to a JPD idea via a delivery issue link. The default delivery link type names recognised by Fragile are:
is delivered by/delivers
These link types are created automatically when you connect Jira Software delivery tickets to JPD ideas using the native Delivery panel in JPD.
If your Jira instance uses different delivery link type names (e.g. is implemented by /
implements), update jpdDeliveryLinkInward and jpdDeliveryLinkOutward in the jira: stanza
of backend/config/boards.yaml (see jira: stanza)
or set the values through the Settings UI.
Fragile uses Google Workspace SSO for authentication. Only users in the configured Google Workspace domain can access the application. Follow these steps to create the OAuth2 credentials.
- Go to the Google Cloud Console.
- Select or create a project (e.g.
fragile-internal). - Ensure the project is associated with your Google Workspace organisation.
- Navigate to APIs & Services > OAuth consent screen.
- Select Internal as the user type (this restricts access to users within your Google Workspace domain — no external users can authenticate).
- Fill in the required fields:
- App name:
Fragile(or your preferred display name) - User support email: your team's email
- Authorised domain: your domain (e.g.
mypassglobal.com)
- App name:
- Under Scopes, add:
openidemailprofile
- Save.
- Navigate to APIs & Services > Credentials.
- Click Create Credentials > OAuth client ID.
- Application type: Web application.
- Name:
Fragile Backend(or similar). - Under Authorised JavaScript origins, add the URL the app is served from (the
Google Identity Services button runs client-side, so origins — not redirect URIs —
are what matter):
- For local development:
http://localhost:3000 - For production:
https://app.your-domain.com
- For local development:
- Click Create.
- Copy the Client ID. (No client secret is needed — the app uses the Google Identity Services ID-token flow, which verifies the token server-side against the client ID.)
Add the following to backend/.env (local development) or populate the corresponding
Secrets Manager values (production):
# Google OAuth2 (ID-token flow — no client secret required)
GOOGLE_CLIENT_ID=123456789-abcdef.apps.googleusercontent.com
GOOGLE_ALLOWED_DOMAIN=mypassglobal.com
# Session (JWT cookie signing)
SESSION_SECRET=generate-a-random-64-byte-hex-string
SESSION_MAX_AGE_MS=604800000The frontend also needs NEXT_PUBLIC_GOOGLE_CLIENT_ID (same client ID) so the
"Sign in with Google" button can initialise.
Build-time, not runtime:
NEXT_PUBLIC_*variables are inlined into the Next.js bundle when the image is built, not read at runtime.make ecr-pushbakes the client ID into the frontend image automatically — it reads thegoogle_client_idTerraform output (set viagoogle_client_idinterraform.tfvars), the same way it resolves the API URL. Setgoogle_client_idinterraform.tfvarsand runterraform applybefore building. Setting the value only on the ECS task has no effect and the button will fail withMissing required parameter: client_id.
Generate a session secret:
openssl rand -hex 64The backend reads GOOGLE_CLIENT_ID and SESSION_SECRET from Secrets Manager (injected
into the ECS task). The client ID is public, so it is managed by Terraform via the
google_client_id variable in terraform.tfvars — terraform apply writes it into the
fragile/prod/google-client-id secret. Only the session secret must be set out-of-band:
| Secret | Source |
|---|---|
fragile/prod/google-client-id → GOOGLE_CLIENT_ID |
google_client_id in terraform.tfvars (via terraform apply) |
fragile/prod/session-secret → SESSION_SECRET |
Set out-of-band (it is a real secret) |
Set the session secret via the AWS Console or CLI (once):
aws secretsmanager put-secret-value \
--secret-id fragile/prod/session-secret \
--secret-string "$(openssl rand -hex 64)"Set GOOGLE_ALLOWED_DOMAIN as a plain environment variable in the ECS task
definition (it is not a secret).
After deployment, the first person to log in is automatically promoted to admin. This
bootstraps the admin role without requiring manual database intervention. Subsequent users
receive the user role (read-only access). Admins can promote other users via
Settings > Users.
| Role | Access |
|---|---|
user |
All dashboard views (read-only) |
admin |
All views + Settings (board config, user management) + Sync trigger |
| Symptom | Cause | Fix |
|---|---|---|
| "Access denied" after Google login | Email domain doesn't match GOOGLE_ALLOWED_DOMAIN |
Check the user's email domain matches the configured value |
| Redirect loops / "origin not allowed" on login | The app's origin isn't in Authorised JavaScript origins in GCP | Add the exact origin (scheme + host + port, no path), e.g. http://localhost:3000 |
| "Internal" consent screen not available | GCP project not associated with a Google Workspace org | Link the project to your Workspace admin |
| Session lost after deploy | SESSION_SECRET changed between deploys |
Use a stable secret; rotate only intentionally |
Configuration can be managed in two ways:
- Settings UI — navigate to
/settingsin the browser. Changes are persisted to PostgreSQL immediately and take effect on the next data request without a restart. - YAML files — edit
backend/config/boards.yamlandbackend/config/roadmap.yamlbefore starting the backend. Values in YAML overwrite the database on every startup. See YAML Configuration for the full workflow.
For a step-by-step walkthrough including how to find tenant-specific Jira field IDs, see
docs/setup.md.
The backend exposes a lightweight config endpoint consumed by the frontend to adapt its UI labels:
{
"timezone": "Australia/Sydney",
"excludeWeekends": true
}excludeWeekends: true causes the frontend to show "working days" as the unit for cycle time and
lead time cards; false shows "calendar days".
Each board has an editable configuration block. The fields are:
| Field | Description | Default |
|---|---|---|
| Board type | scrum or kanban |
scrum |
| Done status names | Status names that count as "deployed / complete" for Deployment Frequency and Lead Time | Done, Closed, Released |
| In-progress status names | First transition to one of these statuses marks cycle time start | In Progress |
| Failure issue types | Issue types that contribute to Change Failure Rate | Bug, Incident |
| Failure labels | Issue labels that flag a failure | regression, incident, hotfix |
| Failure link types | Issue link type names that indicate a deployment caused a failure | is caused by, caused by |
| Incident issue types | Issue types used to identify incidents for MTTR | Bug, Incident |
| Incident priorities | Priorities that qualify an issue as an incident | Critical |
| Incident labels | Labels used to identify incidents | (empty) |
| Recovery statuses | Status names that indicate an incident is resolved (MTTR end) | Done, Resolved |
| Backlog status IDs | Status IDs representing the pre-board backlog state (Kanban only) | (empty) |
| Data start date | ISO date (YYYY-MM-DD) — Kanban issues that entered the board before this date are excluded from flow metrics |
(none) |
Each Jira Product Discovery project that you want to use for roadmap accuracy tracking needs a configuration entry. The fields are:
| Field | Description |
|---|---|
| JPD project key | The project key of the JPD project, e.g. ROADMAP |
| Description | Optional human-readable label |
| Start date field ID | Custom field ID for the interval field used as the idea start date |
| Target date field ID | Custom field ID for the interval field used as the idea target date |
Fragile's YamlConfigService reads two YAML files from backend/config/ on every application
startup and upserts their contents into the board_configs and roadmap_configs database tables.
This provides a version-controllable, declarative alternative to configuring everything through
the Settings UI.
- YAML wins on conflict — any field present in the YAML file overwrites the corresponding database row on restart.
- Absent entries are untouched — boards or roadmaps in the database but not listed in the YAML file are left unchanged.
- Partial entries are safe — if a field is omitted from a YAML entry (not
null, but entirely absent), the existing database value for that field is preserved. - Startup validation — both files are parsed and validated with Zod at startup. Invalid YAML causes the application to refuse to start, printing a clear error listing every offending field.
- Optional — if either file is missing the application starts normally, logs a warning, and falls back entirely to the Settings UI. No error is raised.
| File | Default path | Purpose |
|---|---|---|
boards.yaml |
backend/config/boards.yaml |
Board metric rules (one entry per Jira project) |
roadmap.yaml |
backend/config/roadmap.yaml |
JPD project date-field mappings |
boards.example.yaml |
backend/config/boards.example.yaml |
Annotated template — tracked in git |
roadmap.example.yaml |
backend/config/roadmap.example.yaml |
Annotated template — tracked in git |
backend.tf |
infra/terraform/environments/prod/backend.tf |
Terraform S3 backend config (bucket name, state key) |
backend.tf.example |
infra/terraform/environments/prod/backend.tf.example |
Annotated template — tracked in git |
Both live files (boards.yaml and roadmap.yaml) are excluded from git via .gitignore because
they contain deployment-specific values. The *.example.yaml files serve as the canonical field
reference and are always tracked.
Override the default paths using environment variables in backend/.env:
BOARD_CONFIG_FILE=/absolute/path/to/boards.yaml
ROADMAP_CONFIG_FILE=/absolute/path/to/roadmap.yamlboards:
- boardId: ACC # (required) Jira project key. Normalised to UPPERCASE.
# Must be unique within the file.
boardType: scrum # (required) "scrum" or "kanban"
# Scrum boards support sprint-based Planning metrics.
# Kanban boards use changelog-derived board-entry dates.
doneStatusNames: # (optional) Status names that count as "deployed / complete"
- Done # for Deployment Frequency and Lead Time.
- Closed # Default: ["Done", "Closed", "Released"]
- Released
inProgressStatusNames: # (optional) First transition into one of these statuses
- In Progress # marks the cycle-time start event.
- In Review # Default: ["In Progress"]
cancelledStatusNames: # (optional) Issues in these statuses are excluded from
- Cancelled # roadmap coverage calculations.
- "Won't Do" # Default: ["Cancelled", "Won't Do"]
failureIssueTypes: # (optional) Issue types counted toward Change Failure Rate.
- Bug # Default: ["Bug", "Incident"]
- Incident
failureLinkTypes: # (optional) Link type names indicating a failure
- "is caused by" # relationship between issues (CFR signal).
- "caused by" # Default: ["is caused by", "caused by"]
failureLabels: # (optional) Jira labels that flag a deployment failure.
- regression # Default: ["regression", "incident", "hotfix"]
- incident
- hotfix
incidentIssueTypes: # (optional) Issue types counted as production incidents
- Bug # for MTTR calculation.
- Incident # Default: ["Bug", "Incident"]
recoveryStatusNames: # (optional) Transitioning to one of these statuses ends
- Done # the MTTR clock (incident resolved).
- Resolved # Default: ["Done", "Resolved"]
incidentLabels: [] # (optional) Additional labels for incident identification.
# Default: []
incidentPriorities: # (optional) Priorities that qualify an issue as an incident.
- Critical # Default: ["Critical"]
backlogStatusIds: # (optional, Kanban only) Jira status IDs (not names) that
- "10303" # represent the pre-board backlog state. Issues whose
# current statusId is in this list are excluded from
# flow metrics. When empty, a changelog heuristic is used.
# Default: []
dataStartDate: "2024-01-01" # (optional, Kanban only) ISO date (YYYY-MM-DD) or null.
# Hard lower bound — issues whose board-entry date is
# before this date are excluded from flow metrics.
# Prevents old backlog items inflating period counts.
# Default: nullIn addition to the boards: list, boards.yaml accepts an optional top-level jira: key that
controls which Jira custom field IDs are used for story points and JPD delivery link types. All
fields inside the stanza are optional — any field omitted keeps its current database value.
jira:
# Story point field IDs — Fragile tries each in order and uses the first non-null value found.
# List all field ID variants present in your Jira instance.
# Default: all five IDs below.
storyPointsFieldIds:
- story_points # legacy Jira Server / some older cloud projects
- customfield_10016 # "Story point estimate" (classic projects)
- customfield_10026 # "Story Points" (classic projects, older)
- customfield_10028 # "Story Points" (some cloud instances)
- customfield_11031 # "Story point estimate" (team-managed / next-gen)
# Epic Link custom field — used for legacy epic relationships pre-dating Jira's parent field.
# Set to null to disable and rely solely on the native parent field.
# Default: "customfield_10014"
epicLinkFieldId: customfield_10014
# JPD delivery link type names — must match exactly what Jira shows on the issue link panel.
# Accepts either a bare string or a list. Default: both values shown below.
# Check by opening any delivery-linked issue in your Jira instance.
jpdDeliveryLinkInward:
- "is implemented by"
- "is delivered by"
jpdDeliveryLinkOutward:
- "implements"
- "delivers"These values are stored in the jira_field_config table (singleton row, id = 1) and loaded once
per sync. If the jira: stanza is absent, the database values — seeded with the defaults above by
the AddJiraFieldConfig migration — are left untouched.
boards.yaml also accepts an optional top-level workingTime: key that controls how cycle-time
and lead-time durations are calculated. All fields inside the stanza are optional — any field
omitted keeps its current database value. If the entire stanza is absent, the defaults below
(Monday–Friday, 8 hours/day, no holidays, weekends excluded) are used.
workingTime:
# Set to false to measure cycle time and lead time in raw calendar days.
# Default: true
excludeWeekends: true
# ISO weekday numbers that count as working days.
# 0 = Sunday, 1 = Monday, …, 6 = Saturday.
# Override for regions with a non-standard weekend (e.g. [0,1,2,3,4] for Sun–Thu).
# Default: [1, 2, 3, 4, 5]
workDays:
- 1 # Monday
- 2 # Tuesday
- 3 # Wednesday
- 4 # Thursday
- 5 # Friday
# Number of working hours in a standard day. Used as the divisor when converting
# raw working milliseconds into working-day units (raw_hours / hoursPerDay).
# This is a normalisation factor, NOT a work-hours-per-day limit.
# Default: 8
hoursPerDay: 8
# Public holidays to exclude from working-time calculations, in the tenant's
# local timezone (set via TIMEZONE env var). Format: YYYY-MM-DD.
# Default: []
holidays:
- "2026-01-01" # New Year's Day
- "2026-04-25" # ANZAC Day (example)These values are stored in the working_time_config table (singleton row, id = 1) and take
effect on the next data request without a restart. MTTR is always measured in calendar hours
regardless of this configuration — incidents are production events that do not pause on weekends.
roadmaps:
- jpdKey: DISC # (required) Jira Product Discovery project key.
# Must be unique within the file.
description: "Discovery roadmap" # (optional) Human-readable label shown in the
# Settings UI. Default: null
startDateFieldId: "customfield_10015" # (optional) Jira custom field ID that stores the
# idea start date (type: jira.polaris:interval).
# null means the field is unmapped; ideas without
# a start date are excluded from coverage.
# Default: null
targetDateFieldId: "customfield_10021" # (optional) Jira custom field ID that stores the
# idea target / delivery date.
# Same discovery method as startDateFieldId.
# Default: nullTo find your tenant's custom field IDs, see Configuring JPD date fields.
-
Copy both example files:
cp backend/config/boards.example.yaml backend/config/boards.yaml cp backend/config/roadmap.example.yaml backend/config/roadmap.yaml
-
Edit
backend/config/boards.yaml— add one entry per board, setboardType, and adjust status and metric rules to match your Jira workflow. -
Edit
backend/config/roadmap.yaml— add one entry per JPD project, filling in thecustomfield_xxxxxIDs forstartDateFieldIdandtargetDateFieldId. -
Start the backend (
make dev-api). Watch the startup logs for confirmation:[YamlConfigService] YAML config: 6 board config(s) applied from boards.yaml [YamlConfigService] YAML config: 2 roadmap config(s) applied from roadmap.yaml -
Trigger a sync from the Settings UI to populate issue data for the configured boards.
-
Fine-tune individual board settings through the Settings UI at any time. Changes made in the UI persist to the database. On the next restart, only fields explicitly present in the YAML file will overwrite those values — omitted fields are always preserved.
The included docker-compose.yml starts a PostgreSQL 16 container. Edit it if you need to
change the database name, port, or credentials:
services:
postgres:
image: postgres:16-alpine
container_name: ai-starter-db
restart: unless-stopped
ports:
- "5432:5432" # host:container — change the left side if port 5432 is taken
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: fragile
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:If you rename POSTGRES_DB, update DB_DATABASE in backend/.env to the same value before
running migrations.
fragile/
├── apps/
│ └── mcp/ # @fragile.app/mcp — MCP server npm package
│ ├── src/ # TypeScript source (tools, resources, prompts, client)
│ ├── test/ # Vitest unit tests
│ └── package.json # Published to npm as @fragile.app/mcp
├── backend/ # NestJS 11 API server (port 3001)
│ ├── config/ # YAML configuration files (not committed)
│ │ ├── boards.example.yaml # Annotated board config template (tracked in git)
│ │ ├── boards.yaml # Live board metric rules — created from example
│ │ ├── roadmap.example.yaml# Annotated roadmap config template (tracked in git)
│ │ └── roadmap.yaml # Live JPD date-field mappings — created from example
│ ├── src/
│ │ ├── boards/ # Board config CRUD (controller + service)
│ │ ├── database/
│ │ │ └── entities/ # TypeORM entity classes
│ │ ├── health/ # GET /health (unguarded)
│ │ ├── jira/ # Typed Jira API client — all Jira HTTP calls live here
│ │ ├── metrics/ # DORA metrics and cycle time services + controllers
│ │ ├── migrations/ # TypeORM migration files (reversible up + down)
│ │ ├── planning/ # Sprint and Kanban planning services + controllers
│ │ ├── quarter/ # Quarter detail view service
│ │ ├── roadmap/ # Roadmap accuracy service + controller
│ │ ├── sprint/ # Sprint detail view service
│ │ ├── sync/ # Jira sync orchestration service + controller
│ │ ├── week/ # Week detail view service
│ │ ├── yaml-config/ # YamlConfigService — reads boards.yaml + roadmap.yaml at startup
│ │ ├── app.module.ts
│ │ ├── data-source.ts # TypeORM DataSource (used by migration CLI)
│ │ └── main.ts
│ ├── .env # Backend environment variables (not committed)
│ └── package.json
├── frontend/ # Next.js 16 app (port 3000)
│ ├── src/
│ │ ├── app/ # Next.js App Router pages
│ │ │ ├── dora/ # DORA metrics dashboard
│ │ │ ├── cycle-time/ # Cycle time scatter plot
│ │ │ ├── planning/ # Sprint + Kanban planning
│ │ │ ├── roadmap/ # Roadmap accuracy
│ │ │ └── settings/ # Board and roadmap config
│ │ ├── components/ # Shared React components
│ │ │ └── layout/ # Sidebar, shell
│ │ ├── lib/ # Typed API client, utility functions
│ │ └── store/ # Zustand state stores
│ ├── .env # Frontend environment variables (not committed)
│ └── package.json
├── docs/
│ ├── decisions/ # Architecture Decision Records (ADRs)
│ └── proposals/ # Design proposals (written before implementation)
├── docker-compose.yml
├── Makefile
└── README.md
All Jira API calls are routed through a single typed JiraClient service in backend/src/jira/.
No metric service or controller calls Jira directly. This keeps the integration surface contained
and makes the services independently testable.
Calculation logic lives entirely in NestJS services. Controllers only handle request parsing, response shaping, and delegation to services. No business logic appears in controllers.
Board configuration (done status names, failure rules, incident rules) is stored in the
board_configs table and loaded at runtime. Nothing metric-related is hardcoded.
Epics and sub-tasks are excluded from all metric calculations at the query layer. This is enforced in the sync service and in all metric service queries.
Sprint membership history for Scrum boards is reconstructed from the jira_changelogs table
(field: Sprint). Jira does not expose a point-in-time snapshot of sprint membership, so
changelog replay is the authoritative source.
For the full rationale behind each of these decisions, see the ADRs in docs/decisions/.
The following TypeORM entities form the core data model. All entities map to snake_case table names in PostgreSQL.
| Entity | Table | Key fields | Purpose |
|---|---|---|---|
BoardConfig |
board_configs |
boardId (PK) |
Per-board metric rules and status configuration |
JiraFieldConfig |
jira_field_config |
id (PK, always 1) |
Singleton row storing tenant-specific Jira custom field IDs for story points and JPD delivery link type names |
WorkingTimeConfig |
working_time_config |
id (PK, always 1) |
Singleton row controlling whether weekends are excluded from cycle-time and lead-time calculations, plus work-day definition, hours-per-day, and public holidays |
JiraIssue |
jira_issues |
key (PK), boardId, sprintId, epicKey, issueType, status, statusId, points, labels, createdAt |
Snapshot of each Jira issue |
JiraSprint |
jira_sprints |
id (PK), boardId, state, startDate, endDate |
Sprint metadata for Scrum boards |
JiraChangelog |
jira_changelogs |
id (PK, auto), issueKey, field, fromValue, toValue, changedAt |
Full field-change history; used for sprint membership reconstruction and cycle time |
JiraVersion |
jira_versions |
id (PK), projectKey, releaseDate, released |
Fix versions / releases; primary deployment signal for Deployment Frequency |
JiraIssueLink |
jira_issue_links |
id (PK, auto), sourceIssueKey, targetIssueKey, linkTypeName, isInward |
Issue-to-issue links; used for CFR causal links and roadmap delivery links |
JpdIdea |
jpd_ideas |
key (PK), jpdKey, deliveryIssueKeys, startDate, targetDate, syncedAt |
JPD idea snapshots with delivery epic links and active date window |
RoadmapConfig |
roadmap_configs |
id (PK, auto), jpdKey (unique), startDateFieldId, targetDateFieldId |
Per-JPD-project custom field IDs for extracting idea dates |
SyncLog |
sync_logs |
id (PK, auto), boardId, syncedAt, issueCount, status, errorMessage |
Audit trail of sync runs per board |
Migration files live in backend/src/migrations/. All migrations have reversible up and down
methods.
# Run all pending migrations
cd backend && npm run build && npm run migration:run
# Revert the most recent migration
cd backend && npm run migration:revert
# Generate a new migration from entity changes
cd backend && npm run migration:generate -- src/migrations/DescriptiveNameThe migration CLI uses
dist/paths, sonpm run buildmust be run beforemigration:runormigration:revert.
Design decisions in this project follow a proposal-then-ADR workflow:
-
Before implementing any significant change — a new module, a schema change affecting multiple entities, a new Jira API integration point, or a cross-cutting concern — write a proposal in
docs/proposals/using the template and naming convention described in that directory's README. -
After the proposal is accepted, record the decision as an Architecture Decision Record in
docs/decisions/. ADRs are immutable once written; superseded decisions are marked as such and linked to the replacement ADR. -
Calculation logic belongs in services, not controllers. All Jira HTTP calls belong in
JiraClient, not in any other service. Board configuration must come from the database, not from environment variables or hardcoded values. -
TypeScript is strict mode throughout. The backend uses semicolons and
.jsextension ESM imports. The frontend uses no semicolons. Match the style of the file you are editing. -
Migrations must be reversible. Every
upmust have a correspondingdown.
Pull requests are welcome. Please open an issue or discussion first for any change that would affect the data model, the Jira sync strategy, or the metric calculation logic.
MIT License — Copyright © 2025–2026 Gareth Hughes. See LICENSE for details.




