Pattern: timesheet
Who uses it: anyone who books time — a consultant, a field engineer, a project team member. How often: daily in passing, then once at period end to submit. What "done" looks like: every day accounted for, the period total reconciles against its target, and the whole period moves into approval as ONE unit. The column axis is fixed — it is the period's days — and both row totals (per project) and day totals are always visible, because the question "did Wednesday add up?" is asked as often as "how much went to this project?". Not for editing arbitrary fields across rows — a grid whose columns are a record's own attributes is editable grid; the fixed calendar axis and the reconcile-then-submit gate are what make this its own screen.
The screen — one week
| Project / task | Mon 17 | Tue 18 | Wed 19 | Thu 20 | Fri 21 | Sat 22 | Sun 23 | Total |
|---|---|---|---|---|---|---|---|---|
| PRJ-4471 · Hydraulics rollout | 34.00 | |||||||
| PRJ-1180 · Vendor onboarding | 4.00 | |||||||
| INT-0002 · Internal · training | 2.00 | |||||||
| Day total | 8.00 | 8.00 | 8.00 | 8.00 | 8.00 | 0.00 | 0.00 | 40.00 / 40 |
Cells are seamless numeric inputs — the grid
reads as a sheet, not as a wall of boxes. Every cell carries a full
accessible name ("PRJ-4471, Wednesday 19 August, hours"), because a
screen-reader user landing mid-grid has no visual row/column context to
fall back on. Empty means no time booked and renders empty, not
0.00: a typed zero is a statement ("I worked none on this"),
a blank is the absence of one.
Two totals, because two questions
Row totals answer "how much went to this project this week"; day
totals answer "does Wednesday add up". Both are always on screen — the
references that ship only one force the reader to add up a row or a
column in their head, which is exactly where a wrong timesheet comes
from. The grand total carries its target beside it
(40.00 / 40), so the gap that blocks submission is visible
before the reader tries.
Markup
<!-- The column axis is the PERIOD's days and does not change;
rows are what time is booked to. Row totals AND day totals are both
shown, and the grand total is checked against the period target before
Submit enables. -->
<table class="bo-data-table">
<thead>
<tr>
<th scope="col">Project / task</th>
<th scope="col" class="bo-data-table__col--numeric">Mon 17</th>
…one per day…
<th scope="col" class="bo-data-table__col--numeric">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">PRJ-4471 · Hydraulics rollout</th>
<td class="bo-data-table__col--numeric">
<input class="bo-input bo-input--seamless bo-input--numeric" type="number"
step="0.25" min="0" max="24" value="8" data-sum-col="d0"
aria-label="PRJ-4471, Monday 17 August, hours" />
</td>
…one cell per day…
<td class="bo-data-table__col--numeric"><span data-sum-of="row">30.00</span></td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Day total</th>
<td class="bo-data-table__col--numeric"><span data-sum-of="d0">8.00</span></td>
…
<td class="bo-data-table__col--numeric"><strong>40.00</strong> / 40</td>
</tr>
</tfoot>
</table> Anatomy
- Period header — which period, its state (badge: Draft / Submitted / Approved / Returned), and the period actions.
- Copy last week — a button that seeds the recurring rows; present in every reference, because retyping the same five projects weekly is the pattern's main friction.
- The grid — a data table: fixed day columns, one row per booking target, seamless numeric inputs in the cells.
- Row totals — the last column, per project.
- Day totals + grand total vs target — the
<tfoot>; the grand total is what the submit gate reads. - Submit — moves the WHOLE period into approval; the approver sees this same grid, read-only.
Data contract
| Request | Returns / on failure |
|---|---|
| PATCH /timesheet/:period/cell | one cell, saved as typed; returns the affected row total, day total and grand total as one fragment — never the whole grid, which would blow away the caret mid-entry |
| POST /timesheet/:period/copy | the rows (not the hours) of the previous period; returns the re-rendered grid |
| POST /timesheet/:period/submit | 422 with the failing rule named when validation does not pass (under target, a day over its cap, a closed cost object) — the disabled button is a courtesy, the server is the gate |
| POST … on a submitted period | 409 — re-render read-only with who approved and when; a submitted period is not editable in place |
States
| State | What ships |
|---|---|
| Draft, incomplete | Submit disabled; the grand total shows the gap against target (36.00 / 40) so the reason is on screen, not in a tooltip |
| Draft, reconciled | Submit enabled; the target text flips to a success tone — two channels, since tone alone cannot carry it |
| A day over its cap | the offending cell gets aria-invalid + a message; the day total is marked, not just the cell — the rule is about the day |
| Submitted | the whole grid renders read-only with a Submitted badge; editing requires the approver to return it |
| Returned | editable again, with the approver's reason shown above the grid — a returned period without a reason is an audit problem |
| Approved | read-only permanently; corrections go through an adjustment, never a silent edit |
| Empty (new period) | the grid renders with no rows and "Copy last week" as the primary action — an empty timesheet's first job is getting its rows |
| No JS | fully functional: the cells are real inputs in a real form, and Save recomputes every total server-side. Without JS the totals update on save rather than on each keystroke — the numbers are never browser-computed truth |
Components used
complexity 3 of 4