A fixed-price job post asks for one number. You read the description, picture the work, and type a figure into the bid box. The trouble is that the figure is really a guess about several things at once: how long each part takes when it goes well, how long it takes when it doesn't, and which parts you don't understand yet.
A single number hides all of that. If it's too low, you work the extra hours for free. If you pad it to be safe, the client compares it with cheaper bids and moves on. Neither outcome tells you which part of the guess was wrong.
This post builds a small TypeScript script that keeps the guess honest. For each task you write three numbers instead of one. The script turns them into an expected value and a range, adds them up per milestone, and, most usefully, points out the tasks where your best and worst cases are so far apart that you should ask the client a question before you quote. The whole thing is one file with no dependencies, plus a test file that runs on Node's built-in test runner.
Three numbers per task
Three-point estimation is an old technique from project planning (it's often called PERT). For each task you write down:
- best: the hours if everything goes right,
- likely: the hours you actually expect,
- worst: the hours if the usual things go wrong. Not a disaster, just the slow version: the content arrives late, the API docs are out of date, the design needs a second round.
The expected value weights the likely case most heavily:
expected = (best + 4 × likely + worst) / 6
spread = (worst − best) / 6
The spread is a rough measure of uncertainty. A task you've done many times has a small spread. A task that depends on something you haven't seen yet has a big one.
Nothing about this is precise. The formula doesn't know your work better than you do. What it does is make you think about the bad case separately from the normal case, which is the step a single number lets you skip.
The task file
Tasks live in a plain JSON file, grouped by milestone. Here's an example for a small business website:
[
{ "milestone": "1. Design", "name": "Home page design", "best": 6, "likely": 8, "worst": 12 },
{ "milestone": "1. Design", "name": "Inner page template", "best": 3, "likely": 4, "worst": 6 },
{ "milestone": "2. Build", "name": "Next.js pages and layout", "best": 10, "likely": 14, "worst": 20 },
{ "milestone": "2. Build", "name": "Contact form with email", "best": 2, "likely": 3, "worst": 5 },
{ "milestone": "2. Build", "name": "Import old blog posts", "best": 3, "likely": 6, "worst": 16 },
{ "milestone": "3. Launch", "name": "Domain, hosting and handover", "best": 2, "likely": 3, "worst": 5 }
]
The milestone names matter. On Upwork, fixed-price contracts are commonly split into milestones that the client funds and approves one at a time, so it helps to estimate in the same shape you'll propose. If you're new to how that works from the client's side, Digital Dialogue's explainer on how Upwork works walks through jobs, proposals and contracts.
The script
Here's the whole of estimate.ts:
// estimate.ts: three-point estimates for a fixed-price proposal.
// Run: node estimate.ts tasks.json [hourlyRate]
import { readFileSync } from "node:fs";
type Task = {
milestone: string;
name: string;
best: number; // hours if everything goes right
likely: number; // hours you actually expect
worst: number; // hours if the usual things go wrong
};
type Estimate = Task & { expected: number; spread: number };
export function validate(tasks: unknown): Task[] {
if (!Array.isArray(tasks)) throw new Error("tasks file must contain an array");
return tasks.map((t, i) => {
const where = `task ${i + 1} (${t?.name ?? "no name"})`;
for (const k of ["best", "likely", "worst"] as const) {
if (typeof t?.[k] !== "number" || !(t[k] > 0)) throw new Error(`${where}: ${k} must be a positive number`);
}
if (!(t.best <= t.likely && t.likely <= t.worst)) throw new Error(`${where}: expected best <= likely <= worst`);
if (!t.milestone || !t.name) throw new Error(`${where}: milestone and name are required`);
return t as Task;
});
}
export function threePoint(t: Task): Estimate {
const expected = (t.best + 4 * t.likely + t.worst) / 6;
const spread = (t.worst - t.best) / 6;
return { ...t, expected, spread };
}
export function summarise(tasks: Task[]) {
const rows = tasks.map(threePoint);
const groups = new Map<string, Estimate[]>();
for (const r of rows) groups.set(r.milestone, [...(groups.get(r.milestone) ?? []), r]);
const milestones = [...groups].map(([name, items]) => total(name, items));
return { rows, milestones, project: total("Whole project", rows) };
}
function total(name: string, items: Estimate[]) {
const expected = items.reduce((s, r) => s + r.expected, 0);
// spreads add as variances, not as plain sums
const spread = Math.sqrt(items.reduce((s, r) => s + r.spread ** 2, 0));
return { name, expected, low: expected - spread, high: expected + spread };
}
export function questions(rows: Estimate[], ratio = 3): string[] {
return rows
.filter((r) => r.worst >= r.best * ratio)
.map((r) => `"${r.name}" could take ${hrs(r.best)} or ${hrs(r.worst)}. What would narrow it down?`);
}
const hrs = (n: number) => `${Math.round(n)} h`;
const money = (n: number, rate: number) => Math.round((n * rate) / 10) * 10;
export function toMarkdown(tasks: Task[], rate?: number): string {
const { rows, milestones, project } = summarise(tasks);
const out: string[] = ["| Milestone | Expected | Range |", "| --- | --- | --- |"];
const line = (m: ReturnType<typeof total>) => {
const range = `${hrs(m.low)} to ${hrs(m.high)}`;
return `| ${m.name} | ${hrs(m.expected)} | ${rate ? `${range} (${money(m.low, rate)} to ${money(m.high, rate)})` : range} |`;
};
for (const m of milestones) out.push(line(m));
out.push(line(project));
const qs = questions(rows);
if (qs.length) out.push("", "Questions before a fixed price:", ...qs.map((q) => `- ${q}`));
return out.join("\n");
}
if (import.meta.main) {
const [file, rateArg] = process.argv.slice(2);
if (!file) { console.error("usage: node estimate.ts tasks.json [hourlyRate]"); process.exit(2); }
const tasks = validate(JSON.parse(readFileSync(file, "utf8")));
console.log(toMarkdown(tasks, rateArg ? Number(rateArg) : undefined));
}
A few choices are worth explaining.
Validation comes first. validate() rejects anything that isn't a positive number and anything where the three values are out of order. A task with best larger than likely is almost always a typo, and a typo in an estimate is expensive.
Spreads combine as variances. It's tempting to add the best cases together and the worst cases together, but that assumes every task goes badly at once. In total(), the spreads are squared, summed and square-rooted, which is the usual way to combine independent uncertainties. The result is a project range that's wider than any one task's range but narrower than "everything goes wrong". If your tasks are clearly not independent (for example, every page waits on the same late content), treat the range as optimistic and widen it yourself.
Wide tasks become questions. questions() flags any task whose worst case is at least three times its best case. Those are the tasks where you don't know something, and the cheapest way to find out is usually to ask. The ratio is a parameter, so pick one that matches your own comfort.
Money is optional and rounded. Pass an hourly rate as the second argument and the script adds a price range next to each milestone, rounded to the nearest ten. It doesn't add a currency symbol, because it doesn't know which one you quote in, and it shouldn't pretend to.
It uses only erasable TypeScript syntax (types and annotations, no enums or namespaces), so a recent Node release with built-in type stripping can run it directly with node estimate.ts. On an older Node, npx tsx estimate.ts does the same job.
Running it
node estimate.ts tasks.json 25
The output is a Markdown table you can paste straight into a proposal or a project doc:
| Milestone | Expected | Range |
| --- | --- | --- |
| 1. Design | 13 h | 11 h to 14 h (280 to 340) |
| 2. Build | 25 h | 22 h to 27 h (550 to 690) |
| 3. Launch | 3 h | 3 h to 4 h (70 to 90) |
| Whole project | 40 h | 37 h to 43 h (930 to 1080) |
Questions before a fixed price:
- "Import old blog posts" could take 3 h or 16 h. What would narrow it down?
The milestone rows add up to 41 hours while the whole-project row says 40, because each row is rounded on its own and the total is worked out from the unrounded values. The interesting line is the question at the bottom. Importing the old blog posts could take a few hours or most of two days, and the difference depends entirely on something the job post didn't say: how many posts there are, where they live, and whether images come with them.
Turning the output into a proposal
Here's how I'd use the table in a fixed-price proposal, without pasting the whole thing in.
Lead with the question, not the number. Clients skim proposals. One specific question about their project shows you've read the post more convincingly than any summary of your skills. "Roughly how many posts are on the old blog, and is it WordPress or something else?" is the kind of question that only someone thinking about their project would ask.
Quote the milestones, not the hours. Most clients don't want to see hour counts. They want to know what they get at each stage and what each stage costs. Use the milestone ranges to set milestone prices, and keep the hours for yourself.
Choose where in the range to price. The range isn't the quote. If the client answers your questions and the uncertainty goes away, price nearer the expected value. If they can't answer, either price nearer the high end or split the uncertain task into its own milestone so it can be re-scoped once you know more.
Write down what each milestone includes. A range only holds if the scope holds. One line per milestone, like "home page and one inner page design, two rounds of changes", gives both of you something to point at later.
Testing it
Estimates feed straight into money, so the maths deserves tests. estimate.test.ts uses node:test and node:assert, so there's nothing to install:
import { test } from "node:test";
import assert from "node:assert/strict";
import { threePoint, summarise, questions, validate, toMarkdown } from "./estimate.ts";
const close = (a: number, b: number) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`);
test("three-point expected value and spread", () => {
const e = threePoint({ milestone: "m", name: "t", best: 2, likely: 4, worst: 12 });
assert.equal(e.expected, 5);
close(e.spread, 10 / 6);
});
test("milestone spreads combine as square root of summed squares", () => {
const tasks = [
{ milestone: "m", name: "a", best: 0.5, likely: 3, worst: 6.5 },
{ milestone: "m", name: "b", best: 1, likely: 4, worst: 9 },
];
const { milestones } = summarise(validate(tasks));
close(milestones[0].high - milestones[0].expected, Math.sqrt(1 + (8 / 6) ** 2));
});
test("wide tasks become questions", () => {
const rows = [threePoint({ milestone: "m", name: "Import", best: 3, likely: 6, worst: 16 }),
threePoint({ milestone: "m", name: "Form", best: 2, likely: 3, worst: 5 })];
assert.equal(questions(rows).length, 1);
assert.match(questions(rows)[0], /Import/);
});
test("validation rejects best > likely", () => {
assert.throws(() => validate([{ milestone: "m", name: "x", best: 5, likely: 3, worst: 8 }]), /best <= likely <= worst/);
});
test("markdown has one row per milestone plus total", () => {
const md = toMarkdown(validate([{ milestone: "A", name: "x", best: 1, likely: 2, worst: 3 },
{ milestone: "B", name: "y", best: 1, likely: 2, worst: 3 }]));
assert.equal(md.split("\n").filter((l) => l.startsWith("| ") && !l.startsWith("| ---")).length, 4);
});
Run it with node --test estimate.test.ts. The floating-point comparisons use a small tolerance instead of strict equality, because 10 / 6 and the same value computed another way don't always agree to the last bit.
Where this fits on Fiverr
On Fiverr the shape is different. You usually set package prices in advance, and buyers pick one. The same script still helps, just earlier: estimate the work behind each package once, look at which tasks have the widest ranges, and either leave them out of the package or describe them precisely enough that the range shrinks. If a buyer needs something outside the package, a custom offer is the place for a fresh estimate. Digital Dialogue's guide to how Fiverr works explains gigs, packages and orders from both sides.
Limits
This is a thinking tool, not a forecast. The output is only as good as the three numbers you type, and it can't tell you that a task is missing from the list. Two habits help:
- Keep your old task files. After a project, write the actual hours next to each estimate. You'll quickly see which kinds of tasks you underestimate.
- Re-run it when the scope changes. If the client adds a page, add a task and send the new milestone range with the change request, before you start the work.
FAQ
Is a three-point estimate only for big projects?
No. It's most useful on small fixed-price jobs, where one badly guessed task can wipe out the margin. Even three or four tasks are enough.
Should I show the range to the client?
Usually not as hours. Show prices per milestone and the questions that would firm them up. Some clients appreciate seeing a low and high figure for an uncertain part, especially if you explain what decides it.
Why not just add a fixed buffer to everything?
A flat buffer treats the content import and the contact form as equally risky. Three numbers per task put the buffer where the risk is, and tell you which question to ask.
I'm Abid Ali, a freelance full-stack developer in Karachi working mostly in TypeScript, Next.js, React and Node.js. I write practical guides on freelancing platforms, proposals and client work on Digital Dialogue.
Top comments (0)