Submitting Timesheets from Slack Without Writing Too Early
I was staring at a Slack button that could submit an entire week of timesheets, and the risky part was not wiring it to the API. It was that one casual click could turn a bad preview into a Slack timesheet submission workflow someone else now had to unwind. A chat command feels casual. A timesheet submission is not, because once it enters an approval workflow, fixing a mistake stops being an edit and becomes paperwork with better fonts. Behind the button, the implementation had to stay fast without pretending a slash command was enough proof of intent.
TL;DR
A safe Slack timesheet workflow should separate preview from mutation. In this implementation, submit_timesheet reads unsubmitted time entries where active_submission_id is null, groups them by project, and shows a confirmation card before any write happens. The actual submission only occurs after the user clicks Submit, while a separate status command exposes what has already entered approval.
The argument I care about is simple: chatops workflows should treat Slack as a control surface, not as proof of intent. A slash command is a request to inspect and prepare. A button click, with the exact consequences visible, is the boundary where mutation becomes acceptable. Slack's own docs frame slash commands as payloads sent to an app, and interactive components as a separate interaction path. That split matters when the action is irreversible.
That distinction sounds fussy until you ship the opposite. Then someone runs a command to see what would happen, the system helpfully does it, and everyone learns a new definition of self-service. It is the same reason I keep review gates explicit in Building Press, Part 4: Review is where the human gates the irreversible: the irreversible step deserves a visible boundary.
The Trap In A Convenient Slack timesheet submission Command
The feature was straightforward on paper. People were already logging time. They wanted to submit timesheets and check approval status from Slack, without opening the external time system. Reasonable request. Good fit for chatops. Low ceremony, high utility.
The risky part sat inside the phrase "submit timesheets from Slack." If the command both found the entries and submitted them, the read path and write path would collapse into one gesture. That is convenient in the way a table saw without a guard is convenient.
The implementation split the operation into three pieces:
- Gather entries for a user and date range.
- Render a confirmation card grouped by project.
- Submit only after an explicit button interaction.
The important line in that diagram is not the final write. It is the gap between "confirm" and "click." That gap is where the user gets to notice the wrong week, the missing entry, the project they forgot to fill in, or the suspicious total that says Friday was either very productive or recorded twice.
The Query Is The Safety Rail
The command starts by finding entries that are eligible to submit. Eligibility matters more than presentation here. The system should not submit entries that are already attached to an active submission, and it should not ask the user to confirm a mixed pile where some rows are mutable and others are already in approval.
The core filter is boring, which is a compliment:
select
id,
work_date,
project_id,
project_name,
task_name,
minutes
from time_entries
where user_id = $1
and work_date >= $2
and work_date <= $3
and active_submission_id is null
order by project_name, work_date, id;That active_submission_id is null predicate is the hinge. It turns "all time for this range" into "time the user can still submit." Without it, the confirmation card becomes a polite lie. It might show entries that cannot be submitted, or worse, entries that are already in flight.
The first rejected design was to let the external service reject duplicates during submit. That would have been simpler locally, but it would push ambiguity to the worst possible point. A user would click Submit on a card that looked valid, then get a partial failure or a vague API error. I have a limited appetite for building interfaces where the user is technically informed after the damage has been attempted.
The second rejected design was an automatic submit with an undo window. That works for some chat workflows. It is wrong for approval flows. Once a timesheet enters a manager-visible state, "undo" is no longer a local operation. It depends on permissions, timing, and whether another human has already acted on it.
Grouping Is Not Decoration
Grouping by project was not a cosmetic choice. It changed the confirmation from a dump of rows into a reviewable artifact. Most people remember their week by project and day, not by row ID. If the card mirrors the way they reason about their work, it catches mistakes earlier.
The card payload kept the data needed for review visible and the data needed for mutation tucked into the action value. In a simplified form, the Slack blocks looked like this:
{
"response_type": "ephemeral",
"blocks": [
{ "type": "section", "text": { "type": "mrkdwn", "text": "Submit 36.5 hours for Jun 15 to Jun 21?" } },
{ "type": "section", "text": { "type": "mrkdwn", "text": "*Project A*\nMon 2.0h, Tue 6.5h, Wed 4.0h" } },
{ "type": "section", "text": { "type": "mrkdwn", "text": "*Project B*\nThu 8.0h, Fri 16.0h" } },
{
"type": "actions",
"elements": [
{ "type": "button", "text": { "type": "plain_text", "text": "Submit" }, "style": "primary", "action_id": "timesheet_confirm", "value": "range:2026-06-15:2026-06-21" },
{ "type": "button", "text": { "type": "plain_text", "text": "Cancel" }, "action_id": "timesheet_cancel", "value": "range:2026-06-15:2026-06-21" }
]
}
]
}There is a small but useful rule here: the preview should contain the information a person needs to decide, and the action should contain only the information the system needs to re-check. Do not smuggle trust into the button value. The button is not a database.
Confirm, Then Re-Read
The submit button handler does not blindly trust the preview. Between rendering the card and receiving the click, time can pass. The user might edit an entry. Another integration might submit it. The date range might still be valid, but the eligible rows might have changed.
So the confirm handler reads the eligible entries again before it writes:
app.action('timesheet_confirm', async ({ ack, body, client }) => {
await ack();
const userId = body.user.id;
const range = parseRange(body.actions[0].value);
const entries = await timeStore.findUnsubmittedEntries({
slackUserId: userId,
startDate: range.startDate,
endDate: range.endDate,
});
if (entries.length === 0) {
await client.chat.postEphemeral({
channel: body.channel.id,
user: userId,
text: 'No unsubmitted time entries remain for that date range.'
});
return;
}
const submission = await timeApi.submitTimesheet({
slackUserId: userId,
entryIds: entries.map((entry) => entry.id),
startDate: range.startDate,
endDate: range.endDate,
});
await client.chat.postEphemeral({
channel: body.channel.id,
user: userId,
text: `Submitted ${entries.length} entries as ${submission.id}.`
});
});This costs an extra read. That is the right cost. In chatops, stale previews are common because humans are slow and Slack messages sit there looking authoritative long after reality has moved on.
Important
The confirmation card is not a lock. It is a review surface. The write path still has to validate the current state before submitting.
The re-read reduces accidental writes, but it does not eliminate every race. If Slack retries an interaction payload or a user double-clicks before the first ack resolves, two submit requests can arrive close together. The right answer at the write layer is an idempotency key tied to the user and date range, a unique constraint on (user_id, period_start, period_end) in the submissions table, and a check that the external API is called only once per logical submission. In practice that means: write a pending submission row first inside a transaction, call the external API with the submission ID as the idempotency key, then update the row to submitted on success or failed on error. If Slack retries and the row already exists, return the cached result rather than re-calling the API. The Slack boundary makes the accidental case much less likely; the transaction and idempotency key make the concurrent case correct. Both layers are necessary.
Status Belongs Next To Submission
Submission without status is only half a self-service feature. People do not merely want to send the timesheet. They want to know whether it is waiting, approved, rejected, or missing.
A status command reads active submissions and reports where they are in the approval path without creating another chance to mutate state. It also gives users a safe first command to learn the Slack surface.
A simplified status query looks like this:
select
s.id,
s.period_start,
s.period_end,
s.state,
s.submitted_at,
s.reviewed_at
from timesheet_submissions s
where s.user_id = $1
and s.period_start >= $2
and s.period_end <= $3
order by s.submitted_at desc;The status command is deliberately separate from submit. Combining them into one command that says "submit if needed, otherwise show status" is tempting. It is also muddy. The user should know whether they are asking the system to inspect or to prepare a write.
I have come to like a small mental model for this kind of feature: preview, boundary, mutation, receipt. That same habit shows up in Tracking what the LLM costs in a one-person app, where the important thing is making a hidden system visible before it surprises you.
| Stage | User sees | System does |
|---|---|---|
| Preview | Eligible entries and totals | Reads current unsubmitted rows |
| Boundary | Submit and Cancel buttons | Waits for explicit intent |
| Mutation | Submission result | Re-reads, validates, writes |
| Receipt | Submission ID and state | Reports what changed |
The model is not grand. It fits on a sticky note, which is one of its better qualities. It makes a team answer the question that otherwise hides in implementation details: where, precisely, does this become irreversible enough to require a second gesture?
Slack Is A Sharp Interface
Slack makes internal tools feel smaller than they are. A slash command has the emotional weight of asking a coworker for a link. The backend behind it may be changing billing records, deployments, access permissions, or timesheets that route into approval.
That mismatch is where many chatops bugs come from. The interface feels conversational, but the operation is still a state transition. Treating every command as a possible mutation because "the user asked for it" is how convenience quietly eats the audit trail.
The safer pattern is not to make Slack timid. It is to make intent visible. The command gathers context. The card shows consequences. The button is the commit point. The status command makes the aftermath inspectable.
The result is still fast. The user stays in Slack. The system still submits the timesheet. But a safe Slack timesheet submission happens after the person has seen the rows, the grouping, and the total. That little pause is not friction for its own sake. It is the part of the workflow where the computer admits it is about to do something real.
FAQ
Why is submitting timesheets directly from a Slack command risky?
A Slack command can feel like a preview request, but timesheet submission starts an approval workflow. If the command writes immediately, a user can accidentally submit the wrong range or stale entries before seeing what changed.
Where should I capture Slack timesheet submission intent?
Capture intent at the confirmation action, not at the slash command. The command should read eligible entries and render a confirmation card; the Submit button should trigger the re-read and write.
Why filter on active_submission_id is null?
That filter keeps the preview and submission focused on entries that are not already attached to an active approval flow. It prevents the card from mixing submitted and unsubmitted time in one misleading review surface.
Should the confirm handler trust the Slack card payload?
No. The card payload should identify the request shape, such as the date range, but the handler should re-read current eligible entries before writing. Slack messages can be stale.
Why add a timesheet status command too?
Status makes the workflow inspectable after submission. It lets users check approval state from Slack without turning every status check into another possible mutation.
