/**
* The team's board read one way for every task band: who is on a task, the
* steps it breaks into and how many are done, when it is due in words, and
* how much talk and how many files it has gathered. The tasks are
* `db.tasks` — title, column, priority, owner, estimate, window and labels
* are the entity's own — and what the entity does not record is a rule over
* what it does, written down here once so a task reads the same on every
* band that shows it:
*
* - **Who is on it**: the owner first; then the project's lead, on work of
* five points or more; then, for a task in review, its reviewer — the next
* active member on the list after the owner who is not on it already.
* - **Its steps** follow from what the work is — the verb its title opens
* on — and there are more of them the bigger the estimate. A finished task
* has done them all, one in review all but the last, one in progress the
* share its window has run, and one not started none.
* - **Comments and files** are counted once from `seeded("board:<id>")`,
* busier the further a task has got.
* - **Due** is said in whole UTC days from `REFERENCE_DATE`: a task due today
* is due today, not late; it is late once its day has passed unfinished.
*
* Server-only, because it reads `db`: a section imports it from its own
* `<name>.data.ts` and hands the rows to its island as props.
*/
import { avatarFor } from "@/lib/avatars"
import { REFERENCE_DATE, db, intBetween, seeded, type Task } from "@/lib/sample-data"
/** The Projects dashboard's tasks page. */
export const TASKS_HREF = "/projects/tasks"
/** A task's page: the tasks view, opened on it. */
export function taskHref(id: string): string {
return `${TASKS_HREF}?task=${id}`
}
export type TaskStatus = Task["status"]
export type TaskPriority = Task["priority"]
/** The words each column is called by. */
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
backlog: "Backlog",
todo: "To do",
in_progress: "In progress",
review: "In review",
done: "Done",
}
export type BoardPerson = { id: string; name: string; avatarUrl: string }
export type Subtask = { id: string; title: string; done: boolean }
/** When a task is due: the day in UTC, how far off in whole days, and that said in words. */
export type Due = {
iso: string
/** "Sep 7". */
label: string
/** Whole UTC days from today; negative when the day has passed. */
days: number
/** "Due today", "Due in 5 days", "Overdue by 3 days", "Done". */
words: string
overdue: boolean
}
export type BoardTask = {
id: string
title: string
status: TaskStatus
statusLabel: string
priority: TaskPriority
points: number
project: { id: string; code: string; name: string }
/** The owner first. */
assignees: BoardPerson[]
due: Due
subtasks: Subtask[]
/** Subtasks done as a share of all of them, 0..100. */
progress: number
labels: string[]
comments: number
attachments: number
href: string
}
const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** How many steps a task of each estimate breaks into. */
const STEP_COUNT: Record<number, number> = { 1: 2, 2: 3, 3: 3, 5: 4, 8: 5 }
/** The steps of each kind of work, keyed by the verb a title opens on. */
const STEPS: Record<string, readonly string[]> = {
Add: ["Sketch the change", "Build it behind a flag", "Write the tests", "Ship to staging", "Turn the flag on"],
Audit: ["List what it touches", "Read the access paths", "Write up the findings", "File the fixes", "Sign off the audit"],
Backfill: ["Write the backfill script", "Dry-run on a copy", "Run it in batches", "Check the counts", "Remove the script"],
Cache: ["Measure the hot paths", "Add the cache layer", "Set the expiry rules", "Load-test it", "Watch the hit rate"],
Document: ["Outline the page", "Write the first draft", "Add the examples", "Get a review", "Publish it"],
Harden: ["Map the failure modes", "Add timeouts and retries", "Validate the inputs", "Add alerts", "Run a failure drill"],
Instrument: ["Pick the signals", "Add the metrics", "Add the traces", "Build the dashboard", "Set the alerts"],
Migrate: ["Write the migration", "Dry-run on staging", "Backfill the old rows", "Switch reads over", "Remove the old path"],
"Rate-limit": ["Pick the limits", "Add the limiter", "Return a clear 429", "Load-test it", "Document the limits"],
Refactor: ["Pin the behaviour with tests", "Split the module", "Move the callers", "Delete the dead code", "Check the timings"],
Retire: ["Find every caller", "Announce the date", "Move the callers off", "Switch it off", "Delete the code"],
Split: ["Draw the boundary", "Extract the first part", "Move the traffic", "Extract the rest", "Remove the old path"],
Throttle: ["Measure the peaks", "Add the throttle", "Queue the overflow", "Load-test it", "Tune the limits"],
Version: ["Freeze the current shape", "Add the new version", "Route by version", "Tell the callers", "Mark the old one deprecated"],
}
const FALLBACK_STEPS = ["Scope the work", "Build it", "Test it", "Review it", "Ship it"]
/** How much talk and how many files a task gathers by the column it is in: [min, max] of each, busier the further it has got. */
export const TASK_ACTIVITY: Record<TaskStatus, { comments: [number, number]; attachments: [number, number] }> = {
backlog: { comments: [0, 1], attachments: [0, 1] },
todo: { comments: [0, 2], attachments: [0, 2] },
in_progress: { comments: [1, 5], attachments: [0, 3] },
review: { comments: [2, 8], attachments: [1, 4] },
done: { comments: [1, 6], attachments: [1, 4] },
}
const utcDay = (at: Date) => Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())
/** When a task is due, said from `REFERENCE_DATE` in whole UTC days. */
export function dueOf(task: Pick<Task, "dueAt" | "status">): Due {
const days = Math.round((utcDay(task.dueAt) - utcDay(REFERENCE_DATE)) / DAY_MS)
const done = task.status === "done"
const plural = (n: number) => `${n} ${n === 1 ? "day" : "days"}`
const words = done
? "Done"
: days < 0
? `Overdue by ${plural(-days)}`
: days === 0
? "Due today"
: days === 1
? "Due tomorrow"
: `Due in ${plural(days)}`
return { iso: task.dueAt.toISOString(), label: DAY.format(task.dueAt), days, words, overdue: !done && days < 0 }
}
/** The steps a task breaks into, and which of them are done, by the rules above. */
export function subtasksOf(task: Pick<Task, "id" | "title" | "points" | "status" | "startAt" | "dueAt">): Subtask[] {
const verb = task.title.split(" ")[0]
const count = STEP_COUNT[task.points] ?? 3
const steps = (STEPS[verb] ?? FALLBACK_STEPS).slice(0, count)
let done = 0
if (task.status === "done") done = count
else if (task.status === "review") done = count - 1
else if (task.status === "in_progress") {
const span = task.dueAt.getTime() - task.startAt.getTime()
const ran = span <= 0 ? 1 : (REFERENCE_DATE.getTime() - task.startAt.getTime()) / span
done = Math.min(count - 1, Math.max(1, Math.floor(ran * count)))
}
return steps.map((title, index) => ({ id: `${task.id}-s${index + 1}`, title, done: index < done }))
}
function peopleOn(task: Task): BoardPerson[] {
const members = db.members.all()
const byId = new Map(members.map((member) => [member.id, member]))
const project = db.projects.all().find((row) => row.id === task.projectId)
const ids = [task.assignee]
if (task.points >= 5 && project && !ids.includes(project.lead)) ids.push(project.lead)
if (task.status === "review") {
const active = members.filter((member) => member.status === "active")
const at = active.findIndex((member) => member.id === task.assignee)
const reviewer = [...active.slice(at + 1), ...active.slice(0, at)].find((member) => !ids.includes(member.id))
if (reviewer) ids.push(reviewer.id)
}
return ids.flatMap((id) => {
const member = byId.get(id)
return member ? [{ id, name: member.name, avatarUrl: avatarFor(member.name) }] : []
})
}
/** One task as every band reads it. */
export function boardTask(task: Task): BoardTask {
const project = db.projects.all().find((row) => row.id === task.projectId)
const subtasks = subtasksOf(task)
const rand = seeded(`board:${task.id}`)
const activity = TASK_ACTIVITY[task.status]
return {
id: task.id,
title: task.title,
status: task.status,
statusLabel: TASK_STATUS_LABELS[task.status],
priority: task.priority,
points: task.points,
project: { id: task.projectId, code: project?.code ?? "—", name: project?.name ?? "No project" },
assignees: peopleOn(task),
due: dueOf(task),
subtasks,
progress: Math.round((subtasks.filter((step) => step.done).length / subtasks.length) * 100),
labels: task.labels.filter((label) => label !== "starred"),
comments: intBetween(rand, ...activity.comments),
attachments: intBetween(rand, ...activity.attachments),
href: taskHref(task.id),
}
}
/** Every task on the board, soonest due first; a tie goes to the id. */
export function boardTasks(): BoardTask[] {
return [...db.tasks.all()]
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime() || a.id.localeCompare(b.id))
.map(boardTask)
}