<bmx-timesheet>
A timesheet for professional services, agencies, consultancies, IT departments and anyone who bills or costs time: one person's lines of project and task across the days of a week (or any period up to 31 days).
12 properties · 4 events · 25 methods · 7 parts
Example
A consultant's week at a fictional consultancy, Northgate Partners, part filled in. Type 7:30, 7.5, 45m or a span such as 9-17:30 into any day; start a timer on a line; add a line below the grid; then Submit. Wednesday's support line still needs a note, so the sheet will not submit until it has one.
Show markup
<bmx-timesheet id="ex-ts" label="Timesheet, week 42" today="2026-10-15" locale="en-GB" file-name="northgate-timesheet"></bmx-timesheet>
<p class="note" id="ex-ts-out" role="status">
A consultant's week at a fictional consultancy, Northgate Partners, part filled in. Type 7:30, 7.5, 45m or a span such as 9-17:30 into any day; start a timer on a line; add a
line below the grid; then Submit. Wednesday's support line still needs a note, so the sheet will not submit until it has one.
</p>
<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block: 0.5rem 1.5rem">
<label><input type="checkbox" id="ex-ts-approver" /> View as the approver</label>
<button type="button" id="ex-ts-csv">Show the week as CSV</button>
</div>
<pre id="ex-ts-csv-out" style="max-block-size: 14rem; overflow: auto" hidden></pre>
<script type="module">
await customElements.whenDefined('bmx-timesheet');
const sheet = document.getElementById('ex-ts');
const out = document.getElementById('ex-ts-out');
sheet.sheet = {
person: { name: 'Amara Osei', role: 'Senior consultant' },
start: '2026-10-12',
rules: { expected: [0, 7.5, 7.5, 7.5, 7.5, 7, 0], dayMaxHours: 12, increment: 15, rounding: 'nearest', lockedBefore: '2026-10-12' },
holidays: [],
projects: [
{
id: 'HRB',
code: 'HRB-104',
name: 'Harbour Bank onboarding',
client: 'Harbour Bank',
color: '#2563eb',
requireTask: true,
tasks: ['Discovery', 'Process design', 'Workshops', { name: 'Travel', billable: false }],
},
{ id: 'MER', code: 'MER-022', name: 'Meridian ERP rollout', client: 'Meridian Foods', color: '#0d9488', tasks: ['Data migration', 'Testing', 'Cut-over'] },
{ id: 'SUP', code: 'SUP', name: 'Client support', client: 'Several', color: '#d97706', requireNote: true, tasks: ['Tickets', 'Calls'] },
{ id: 'BD', name: 'Proposals and sales', kind: 'internal', color: '#7c3aed', tasks: ['Bid writing', 'Pitches'] },
{ id: 'TRN', name: 'Training', kind: 'internal', color: '#64748b' },
{ id: 'AL', name: 'Annual leave', kind: 'leave', color: '#16a34a' },
{ id: 'SICK', name: 'Sickness', kind: 'leave', color: '#dc2626' },
{ id: 'OLD', name: 'Atlas retail (closed)', client: 'Atlas', closed: true },
],
rows: [
{ id: 'r1', project: 'HRB', task: 'Workshops' },
{ id: 'r2', project: 'HRB', task: 'Process design' },
{ id: 'r3', project: 'MER', task: 'Testing' },
{ id: 'r4', project: 'SUP', task: 'Tickets' },
{ id: 'r5', project: 'BD', task: 'Bid writing' },
{ id: 'r6', project: 'AL' },
],
entries: [
{ row: 'r1', date: '2026-10-12', start: '09:00', end: '13:00' },
{ row: 'r2', date: '2026-10-12', minutes: 150, note: 'Current-state maps for payments' },
{ row: 'r4', date: '2026-10-12', minutes: 45, note: 'Two password resets, one report fix' },
{ row: 'r1', date: '2026-10-13', minutes: 210 },
{ row: 'r3', date: '2026-10-13', minutes: 240, note: 'Regression pack, week 2' },
{ row: 'r3', date: '2026-10-14', minutes: 300 },
{ row: 'r4', date: '2026-10-14', minutes: 60 },
{ row: 'r5', date: '2026-10-14', minutes: 90 },
{ row: 'r2', date: '2026-10-15', minutes: 120 },
{ row: 'r6', date: '2026-10-16', minutes: 450, note: 'Booked in August' },
],
};
sheet.addEventListener('bmxChange', e => {
if (e.detail.reason === 'status') out.textContent = `The sheet is now ${e.detail.sheet.status}.`;
});
sheet.addEventListener('bmxStatusChange', e => {
// A real page would save here, and cancel the event if the server refused.
out.textContent = `${e.detail.from} → ${e.detail.to}${e.detail.comment ? `: “${e.detail.comment}”` : ''}. ${e.detail.problems.length} warning${e.detail.problems.length === 1 ? '' : 's'} left.`;
});
sheet.addEventListener('bmxTimer', e => {
out.textContent =
e.detail.state === 'stop' ? `Timer stopped: ${e.detail.minutes} minutes booked to ${e.detail.date}.` : `Timer ${e.detail.state === 'start' ? 'started' : 'discarded'}.`;
});
document.getElementById('ex-ts-approver').addEventListener('change', e => {
sheet.mode = e.target.checked ? 'approve' : 'entry';
out.textContent = e.target.checked ? 'Approver view: read only, with Approve and Reject once the sheet is submitted.' : 'Back to the consultant’s view.';
});
document.getElementById('ex-ts-csv').addEventListener('click', async () => {
const pre = document.getElementById('ex-ts-csv-out');
pre.textContent = await sheet.exportCsv();
pre.hidden = false;
});
</script>
WHAT IT DOES
- Time is typed straight into the grid the way people say it: 7:30, 7.5, 45m, 7h 30m, a span of the day (9-17:30, 9am-5:30pm, past midnight too), or +30m to add to what is there; rounded to the organisation's increment. Arrow keys and Enter move like a spreadsheet.
- A timer on any line books the time it ran, rounded, to the day it started.
- Totals for every line and day, against the expected hours of each day (public holidays included), with overtime, time still to book, billable share and leave; a breakdown by project.
- Rules: hours a day and a period, notes and tasks a project needs, closed projects and tasks, locked days, time booked ahead of the day, the same line twice, spans that overlap. Errors stop a submission.
- Draft, submitted, approved and rejected, with the trail of who did what; an approver mode with Approve and Reject (with a reason).
- Copy last period's lines (and time), copy a day across the week; undo; CSV and JSON out. On a phone, one day at a time.
The sheet is plain JSON on the element (sheet, bmxChange); nothing is
sent anywhere - the page saves it where it wants.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
actor |
actor |
string |
— | The name recorded in the approval trail for changes made here. Unset: the sheet's person (entry) or nobody (approve). |
compactBelow |
compact-below |
number |
640 |
Below this width in pixels, one day is shown at a time. 0 never does. |
fileName |
file-name |
string |
'timesheet' |
The name exported files are saved under, without the extension. |
format |
format |
BmxTimesheetFormat |
'hm' |
How time is written: hm (7:30) or decimal (7.50). |
label |
label |
string |
— | The element's accessible name. |
locale |
locale |
string |
— | The locale for dates and numbers. Unset: the page's lang. |
mode |
mode |
BmxTimesheetMode |
'entry' |
entry for the person filling the sheet in; approve for someone approving it (read only, with Approve and Reject). |
readonly |
readonly |
boolean |
false |
Look but do not change. |
sheet |
sheet |
Partial<BmxTimesheetData> | string |
— | The sheet: projects, lines, time, rules, holidays, status (an object, or its JSON). Kept up to date as it changes. |
showSummary |
show-summary |
boolean |
true |
Show the summary, the breakdown by project and the approval trail. |
strings |
strings |
Partial<BmxTimesheetStrings> | string |
— | Replacements for any of the wording (an object, or its JSON). |
today |
today |
string |
— | Today, as YYYY-MM-DD. Unset: the computer's date. |
Events
| Event | Detail | Description |
|---|---|---|
bmxChange |
BmxTimesheetChangeDetail |
Fired when time, notes, lines, the period or the status change. |
bmxPeriodChange |
BmxTimesheetPeriodDetail |
Fired before the sheet moves to another period. Cancelable: cancel it to load that period's sheet yourself. |
bmxStatusChange |
BmxTimesheetStatusDetail |
Fired before the sheet is submitted, recalled, approved, rejected or reopened. Cancelable. |
bmxTimer |
BmxTimesheetTimerDetail |
Fired when a timer starts, stops (with the time booked) or is discarded. |
Methods
| Method | Signature | Description |
|---|---|---|
addLine |
addLine(project: string, task?: string) => Promise<string | null> |
Adds a line for a project (and task) and returns its id; an existing line for the same pair is returned instead. |
approve |
approve(comment?: string) => Promise<boolean> |
Approves a submitted sheet. |
check |
check(options?: { forSubmit?: boolean; }) => Promise<BmxTimesheetProblem[]> |
What needs attention; forSubmit also counts short days after today. |
copyFrom |
copyFrom(sheet: Partial<BmxTimesheetData> | string | null, options?: { withTime?: boolean; }) => Promise<void> |
Brings in the lines (and with withTime, the time) of another sheet - or, with null, of the period before this one in this sheet. |
download |
download(format?: "csv" | "csv-grid" | "json") => Promise<void> |
Saves the sheet as a file. |
exportCsv |
exportCsv(layout?: "long" | "grid") => Promise<string> |
The period as CSV: one line per entry (long) or per sheet line with a column per day (grid). |
focusCell |
focusCell(row: string, date: string) => Promise<void> |
Moves the focus to a line's time on a day. |
getSheet |
getSheet() => Promise<BmxTimesheetData> |
The sheet as it is now. |
getTotals |
getTotals() => Promise<BmxTimesheetTotals> |
The period's figures. |
goToPeriod |
goToPeriod(start: string) => Promise<boolean> |
Moves to the period starting on a date (YYYY-MM-DD). |
nextPeriod |
nextPeriod() => Promise<boolean> |
Moves to the next period. |
previousPeriod |
previousPeriod() => Promise<boolean> |
Moves to the previous period. |
recall |
recall() => Promise<boolean> |
Takes a submitted sheet back to draft. |
redo |
redo() => Promise<void> |
Puts back what undo took. |
reject |
reject(comment: string) => Promise<boolean> |
Rejects a submitted sheet, with the reason. |
removeLine |
removeLine(row: string) => Promise<void> |
Removes a line and its time. |
reopen |
reopen(comment?: string) => Promise<boolean> |
Takes an approved sheet back to draft. |
setFocus |
setFocus() => Promise<void> |
Moves the focus into the sheet: the chosen day, else the first. |
setNote |
setNote(row: string, date: string, note: string) => Promise<boolean> |
Sets (or with an empty text clears) the note on a line on a day. |
setSheet |
setSheet(sheet: Partial<BmxTimesheetData> | string, options?: { keepHistory?: boolean; }) => Promise<void> |
Replaces the sheet. Undo goes back to what was there, unless keepHistory is false. |
setTime |
setTime(row: string, date: string, value: number | string) => Promise<boolean> |
Sets the time on a line on a day: minutes, or text as it would be typed (7:30, 7.5, 9-17:30, +30m). False when the text is not a time or the day cannot be changed. |
startTimer |
startTimer(row: string) => Promise<void> |
Starts a timer on a line (a timer running elsewhere is stopped and booked first). |
stopTimer |
stopTimer() => Promise<number> |
Stops the running timer and books its time; returns the minutes booked. |
submit |
submit() => Promise<boolean> |
Submits the sheet. False when it cannot be (errors, or not a draft). |
undo |
undo() => Promise<void> |
Takes back the last change. |
CSS shadow parts
| Part | Description |
|---|---|
details |
The chosen day's time, span and note. |
grid |
The table of lines and days. |
header |
The period, the status and the tools. |
history |
The approval trail. |
problems |
What needs attention. |
summary |
The figures and the breakdown by project. |
timer |
The running timer's bar. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-timesheet-day-width |
Least width of a day's column. |
--bmx-timesheet-line-width |
Width of the project-and-task column. |
--bmx-timesheet-line-width-narrow |
Its width when the sheet is narrow (one day at a time). |
--bmx-timesheet-radius |
Corner radius of the frame. |
--bmx-timesheet-today |
Accent of today's column. |
--bmx-timesheet-weekend |
Background of days with no expected hours. |