Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Deploy to GitHub Pages

on:
push:
branches: [feature/scenario-name-label]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: dist
- id: deployment
uses: actions/deploy-pages@v4
37 changes: 35 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@
<header class="navbar sticky-top p-2 shadow">
<div class="navbar-nav w-100">
<form id="new-scenario" class="row gy-2 gx-3 align-items-center flex-wrap">
<div class="col-auto">
<div class="input-group">
<div class="input-group-text">Recipe</div>
<select class="form-select" id="recipe-select" style="max-width: 13rem">
<option value="">pick one…</option>
</select>
<button id="delete-recipe" class="btn btn-outline-danger" type="button"
title="Delete the selected recipe" disabled>&times;</button>
</div>
</div>

<div class="col-auto">
<div class="input-group">
<div class="input-group-text">Name</div>
<input class="form-control" type="text" id="scenario-name" name="scenario-name" value="" placeholder="optional label" style="max-width: 11rem">
</div>
</div>

<div class="col-auto">
<div class="input-group">
<div class="input-group-text">Work per story</div>
Expand All @@ -28,14 +46,14 @@
<div class="col-auto">
<div class="input-group">
<div class="input-group-text">WIP-limit</div>
<input class="form-control" type="text" id="wip-limit" name="wip-limit" placeholder="none">
<input class="form-control" type="text" id="wip-limit" name="wip-limit" placeholder="none" style="max-width: 4.5rem">
</div>
</div>

<div class="col-auto">
<div class="input-group">
<div class="input-group-text">#stories</div>
<input class="form-control" type="text" id="numberOfStories" name="numberOfStories" placeholder="50">
<input class="form-control" type="text" id="numberOfStories" name="numberOfStories" placeholder="50" style="max-width: 4.5rem">
</div>
</div>

Expand All @@ -49,6 +67,19 @@
<div class="col-auto">
<button id="create-scenario" class="btn btn-primary">Run</button>
</div>

<div class="col-auto">
<div class="input-group">
<div class="input-group-text">Developer cost</div>
<input class="form-control" type="text" id="memberCost" name="memberCost" value="$100,000"
inputmode="numeric" style="max-width: 7rem"
title="Annual cost per team member — each run's price tag is headcount × this">
</div>
</div>

<div class="col-auto">
<button id="clear-runs" class="btn btn-outline-secondary" type="button" title="Clear all runs and charts; saved recipes are kept">Clear</button>
</div>
</form>
</div>
</header>
Expand All @@ -59,6 +90,7 @@
<div id="scenarios" class="d-flex flex-nowrap overflow-auto">
<ul class="scenario legend col col-1">
<li class="scenario-title">&nbsp;</li>
<li class="teamCost">Team cost</li>
<li class="throughput">Throughput</li>
<li class="leadtime">Cycle Time</li>
<li class="wip">WIP</li>
Expand Down Expand Up @@ -94,6 +126,7 @@
<template id="scenario-template">
<ul class="scenario instance col col-1">
<li class="scenario-title"></li>
<li class="teamCost"></li>
<li class="throughput"></li>
<li class="leadtime"></li>
<li class="wip"></li>
Expand Down
282 changes: 59 additions & 223 deletions readme.md

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions src/form-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ const validateWorkers = ({workload, workers}: {workload?: string, workers: strin
const validateWorkersFormat = () => /^(\s*(\w+)(\+\w+)*\s*)(,\s*(\w+)(\+\w+)*\s*)*$/gm.test(workers);

const validateWorkLoadCanBeExecuted = () => {
if (workers.includes('fullstack')) return workers;
if (workers.includes('fs')) return workers;
const workersLC = workers.toLowerCase();
if (workersLC.includes('fullstack')) return workers;
if (workersLC.includes('fs')) return workers;
if (!workload) return false;

const expectedWorkers = workload.split(',')
const expectedWorkers = workload.toLowerCase().split(',')
.map(worker => worker.split(':')[0]!.trim());

return expectedWorkers.every(w => workers.includes(w))
return expectedWorkers.every(w => workersLC.includes(w))
};

return validateWorkersFormat() && validateWorkLoadCanBeExecuted();
Expand Down
4 changes: 2 additions & 2 deletions src/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function parseWorkers(input: string) {
function parseSkills(input: string): string[] {
return input
.split('+')
.map(skill => skill.trim());
.map(skill => skill.trim().toLowerCase());
}

function parseWorkload(input: string): Record<string, number> {
Expand All @@ -42,7 +42,7 @@ function parseWorkload(input: string): Record<string, number> {
.split(":")
)
.reduce((work: Record<string, number>, pair) => {
work[pair[0]!.trim()] = parseInt(pair[1]!.trim());
work[pair[0]!.trim().toLowerCase()] = parseInt(pair[1]!.trim());
return work;
}, {})
}
Expand Down
59 changes: 59 additions & 0 deletions src/recipes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export type Recipe = {
name: string;
workload: string;
workers: string;
wipLimit: string;
numberOfStories: string;
random: boolean;
};

const KEY = 'explaining-flow.recipes';

// The Banana Software Company demo set — baked in so a fresh visitor gets a
// ready-made lecture series. Seeded only on first visit (see seedDefaults):
// deleting them stores '[]', which is respected — they don't resurrect.
const DEFAULT_RECIPES: Recipe[] = [
{ name: 'Base Team', workload: 'po: 2, ui: 4, dev: 8, qa: 2', workers: 'po, ui, ui, dev, dev, dev, qa', wipLimit: '', numberOfStories: '100', random: true },
{ name: '2x People', workload: 'po: 2, ui: 4, dev: 8, qa: 2', workers: 'po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa', wipLimit: '', numberOfStories: '100', random: true },
{ name: '4x People', workload: 'po: 2, ui: 4, dev: 8, qa: 2', workers: 'po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa', wipLimit: '', numberOfStories: '100', random: true },
{ name: '8x People', workload: 'po: 2, ui: 4, dev: 8, qa: 2', workers: 'po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa, po, ui, ui, dev, dev, dev, qa', wipLimit: '', numberOfStories: '100', random: true },
{ name: 'WIP throttling only', workload: 'po: 2, ui: 4, dev: 8, qa: 2', workers: 'po, ui, ui, dev, dev, dev, qa', wipLimit: '7', numberOfStories: '100', random: true },
{ name: 'Cross-skilling only', workload: 'po: 2, ui: 4, dev: 8, qa: 2', workers: 'dev+ui, dev+ui, dev+qa, dev+po, ui+po, ui+qa, qa+po', wipLimit: '', numberOfStories: '100', random: true },
{ name: 'Cut Batch only', workload: 'po: 1, ui: 2, dev: 4, qa: 1', workers: 'po, ui, ui, dev, dev, dev, qa', wipLimit: '', numberOfStories: '200', random: true },
{ name: 'WIP + cx2 skill + batch', workload: 'po: 1, ui: 2, dev: 4, qa: 1', workers: 'dev+ui, dev+ui, dev+qa, dev+po, ui+po, ui+qa, qa+po', wipLimit: '7', numberOfStories: '200', random: true },
{ name: 'WIP + cx3 skill + batch', workload: 'po: 1, ui: 2, dev: 4, qa: 1', workers: 'po+ui+dev, po+ui+dev, ui+dev+qa, ui+dev+qa, po+dev+qa, po+ui+qa, po+ui+dev', wipLimit: '7', numberOfStories: '200', random: true },
{ name: 'WIP + fullstack + batch', workload: 'po: 1, ui: 2, dev: 4, qa: 1', workers: 'fullstack, fullstack, fullstack, fullstack, fullstack, fullstack, fullstack', wipLimit: '7', numberOfStories: '200', random: true },
{ name: 'Base Team (noVar)', workload: 'po: 2, ui: 4, dev: 8, qa: 2', workers: 'po, ui, ui, dev, dev, dev, qa', wipLimit: '', numberOfStories: '100', random: false },
];

const load = (): Recipe[] => {
try {
return JSON.parse(localStorage.getItem(KEY) || '[]');
} catch {
return [];
}
};

const save = (recipes: Recipe[]) => localStorage.setItem(KEY, JSON.stringify(recipes));

const all = (): Recipe[] => load();

const find = (name: string): Recipe | undefined => load().find(r => r.name === name);

const upsert = (recipe: Recipe) => {
if (!recipe.name.trim()) return;
const recipes = load();
const index = recipes.findIndex(r => r.name === recipe.name);
if (index >= 0) recipes[index] = recipe;
else recipes.push(recipe);
save(recipes);
};

const remove = (name: string) => save(load().filter(r => r.name !== name));

// First visit only: the key has never been written, so bake in the demo set.
const seedDefaults = () => {
if (localStorage.getItem(KEY) === null) save(DEFAULT_RECIPES);
};

export { all, find, upsert, remove, seedDefaults, DEFAULT_RECIPES };
84 changes: 82 additions & 2 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { parseInput } from './parsing';
import { Chart } from 'chart.js';
import crosshairPlugin from './crosshair';
import * as FormHelper from './form-helper';
import * as Recipes from './recipes';
import { initialize as initBoardTimeline, getState as getBoardTimelineState, restoreState as restoreBoardTimelineState } from './boardTimeline';

Chart.register(crosshairPlugin);
Expand Down Expand Up @@ -77,15 +78,23 @@ function createScenarioContainer(scenario: any) {

const clone = (template.content.cloneNode(true) as DocumentFragment).querySelector('ul')!;
clone.setAttribute('id', `scenario-${scenario.id}`);
clone.querySelector('.scenario-title')!.textContent = scenario.title;
const $title = clone.querySelector('.scenario-title') as HTMLElement;
$title.textContent = scenario.title;
$title.title = scenario.title;
// Price tag: headcount × cost-per-member, snapshotted at Run time.
const headcount = (scenario.workers || []).length;
const costPerMember = parseMoney((document.getElementById('memberCost') as HTMLInputElement)?.value);
const $cost = clone.querySelector('.teamCost') as HTMLElement;
$cost.textContent = '$' + (headcount * costPerMember).toLocaleString('en-US');
$cost.title = `${headcount} people × $${costPerMember.toLocaleString('en-US')} each`;
return clone
}

function parse(form: HTMLFormElement) {
const field = (fieldName: string) => (form.querySelector(`[name="${fieldName}"]`) as HTMLInputElement).value;

return parseInput({
title: field('workload'),
title: field('scenario-name').trim() || field('workload'),
workers: field('workers'),
workload: field('workload'),
wipLimit: field('wip-limit'),
Expand All @@ -98,14 +107,85 @@ function parseScenario(event: Event) {
return Scenario(parse(event.target as HTMLFormElement));
}

const $field = (id: string) => document.getElementById(id) as HTMLInputElement;

// "$100,000" -> 100000; tolerant of any mix of digits, $, commas, spaces.
const parseMoney = (s: string | undefined) => Number(String(s || '').replace(/[^0-9.]/g, '')) || 0;

function refreshRecipeDropdown() {
const $select = document.getElementById('recipe-select') as HTMLSelectElement | null;
if (!$select) return;
$select.innerHTML = '<option value="">pick one…</option>';
Recipes.all().forEach(recipe => {
const option = document.createElement('option');
option.value = recipe.name;
option.textContent = recipe.name;
$select.appendChild(option);
});
}

function fillFormFromRecipe(name: string) {
const recipe = Recipes.find(name);
if (!recipe) return;
$field('scenario-name').value = recipe.name;
$field('workload').value = recipe.workload;
$field('workers').value = recipe.workers;
$field('wip-limit').value = recipe.wipLimit;
$field('numberOfStories').value = recipe.numberOfStories;
$field('random').checked = recipe.random;
}

function saveRecipeFromForm() {
Recipes.upsert({
name: $field('scenario-name').value.trim(),
workload: $field('workload').value,
workers: $field('workers').value,
wipLimit: $field('wip-limit').value,
numberOfStories: $field('numberOfStories').value,
random: $field('random').checked,
});
refreshRecipeDropdown();
}

document.addEventListener('DOMContentLoaded', () => {
const form = FormHelper.initialize();

Recipes.seedDefaults();
refreshRecipeDropdown();

// Keep the cost field looking like money: "$100,000" no matter what gets typed.
const $cost = document.getElementById('memberCost') as HTMLInputElement;
$cost.addEventListener('change', () => {
$cost.value = '$' + parseMoney($cost.value).toLocaleString('en-US');
});

const $recipeSelect = document.getElementById('recipe-select') as HTMLSelectElement;
const $deleteRecipe = document.getElementById('delete-recipe') as HTMLButtonElement;

$recipeSelect.addEventListener('change', (event: Event) => {
const name = (event.target as HTMLSelectElement).value;
fillFormFromRecipe(name);
$deleteRecipe.disabled = !name;
});

$deleteRecipe.addEventListener('click', () => {
const name = $recipeSelect.value;
if (!name) return;
Recipes.remove(name);
refreshRecipeDropdown();
$recipeSelect.value = '';
$deleteRecipe.disabled = true;
});

document.getElementById('clear-runs')!
.addEventListener('click', () => window.location.reload());

document.getElementById('new-scenario')!
.addEventListener('submit', (event: Event) => {
event.preventDefault()
if(!form?.isValid()) return;

saveRecipeFromForm();
const scenario = parseScenario(event);
const $container = createScenarioContainer(scenario);
const $scenarios = document.getElementById('scenarios')!;
Expand Down
30 changes: 30 additions & 0 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,30 @@



#stats {
padding-top: 0.5rem;
}

#scenarios {
padding: 4px;
/* Fixed-height frame: tall enough to show a 2x team (~14 worker rows +
the stat rows) without scrolling. Taller scenario columns scroll
vertically INSIDE this frame instead of shoving the animation down
the page — the board below stays put no matter how big a team gets. */
max-height: 480px;
overflow-y: auto;
/* Columns must size to their CONTENT, not stretch to the frame: the
default flex stretch capped each ul's box at 480px, so the selection
outline stopped mid-list while the worker rows overflowed past it. */
align-items: flex-start;
}

.scenario-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.scenario {
list-style: none;
padding: 0;
Expand All @@ -180,6 +204,12 @@
font-weight: bold;
}

.teamCost {
background-color: rgba(46, 125, 74, 0.1);
border: rgb(46, 125, 74) solid 1px;
font-weight: bold;
}

.throughput {
background-color: rgba(54, 162, 235, 0.1);
border: rgb(54, 162, 235) solid 1px;
Expand Down