Skip to content

feat: task-centric API, settings and sidebar entries (rework PR 1) - #8

Closed
gdarko wants to merge 6 commits into
feat/ui-billingfrom
feat/task-centric-backend
Closed

gdarko wants to merge 6 commits into
feat/ui-billingfrom
feat/task-centric-backend

Conversation

@gdarko

@gdarko gdarko commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

PR 1 of the task-centric rework, on top of feat/ui-billing. Backend and
sidebar only: no file under resources/js is touched, so the existing UI keeps
working and the new Tasks sidebar entry points at the page that already exists.
The frontend of PR 2 is being built against the contract below.

What the API gains

time on every task. TaskResource now carries
{ logged_minutes, billable_minutes, unbilled_minutes, unbilled_amount, invoiced: none|uninvoiced|invoiced, running: [{ entry_id, user_id, started_at }] }
on the index, the show, the board columns and the create and update responses.
app/Application/TaskTimeSummary.php fills it in for a whole response at once
through three grouped reads over tp_time_entries: logged minutes, the billable
money with the unbilled split and the stamped count from a CASE that MySQL,
PostgreSQL and SQLite all speak, and the running rows. A test asserts the list
still costs exactly three reads whatever the page holds. Running entries stay
out of the totals, because their duration is zero until the clock stops.

Task-addressed timer. POST tasks/{id}/start (body { description? })
answers 201 with the running entry, or 409 timer_already_running.
POST tasks/{id}/stop answers 200 with the completed entry, or 409
timer_mismatch (new TimerMismatch) when the caller's clock runs elsewhere or
nowhere, leaving the real timer alone. The timer/* routes are unchanged.

GET tasks/{id}/time-log returns { data: TimeEntryResource[] }, running
rows first then newest first, capped at 500, narrowed to the caller's own rows
unless they hold view-all-time or the company opens the timesheet. Totals stay
visible to anyone with view-task; only the rows follow that rule.

GET tasks?invoiced=0|1 through portable whereExists subqueries, and
POST tasks/bulk (status or delete) answering { updated, failed },
each task in its own transaction. The controller docblock records why start,
stop and invoice are not bulk actions here.

Settings. rounding_direction (nearest, up, down), auto_start_tasks,
lock_invoiced_tasks, hide_invoiced_on_board and the six invoice line
toggles; rounding_increments becomes [1, 5, 6, 15, 30, 60]. Every switch
lives in one table in ModuleSettings, so the host schema, the typed getters,
the settings response and DataCleanup cannot drift apart (a test pins that).

Behaviour. auto_start_tasks starts the creator's clock on a new task only
when they have no timer running; the create succeeds either way.
lock_invoiced_tasks makes a fully invoiced task refuse edit, move and delete
with 422 task_locked, through the new TaskLock.

Independent fix. A stamped time entry could have its duration, billable flag
and task rewritten, and a later change to the rounding setting could re-round it
on any save, silently moving money an invoice had already recorded. A stamped
entry now takes a new description and nothing else, and refuses a real change to
started_at, ended_at, duration_minutes, billable or task_id. Posting
the row's own values back is not a change, so an edit form still works.

Sidebar and abilities. Projects (FolderIcon, priority 40) and Tasks
(ClipboardDocumentListIcon, priority 50) both register in the host's main
group, after Items. The registry keys stay separate, so
menuFor('tasks-projects') still answers with the primary entry.
invoice-tasks now also depends on the host edit-invoice ability, because
invoicing a task ends on the host invoice edit page.

Verification

vendor/bin/pint, composer run lint, composer run test (293 tests, 1235
assertions, up from 247), validate-module and validate-package all green.
No migration changed: the existing (company_id, task_id) index already covers
the summary and the filter.

Checked against the running dev stack (company 2): the bootstrap main group
lists Dashboard, Customers, Items, Projects, Tasks; GET tasks carries the
time block; tasks/3/start returned 201, tasks/4/stop returned 409
timer_mismatch, tasks/3/stop returned 200, and the smoke entry was removed,
leaving no timer running.

https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E

A task row that cannot say how long it took is not a row anyone can work
from, so TaskResource grows a `time` block: minutes logged, the billable
and still unbilled part of them, the money waiting for an invoice, whether
the task has reached one, and the clocks running on it right now.

TaskTimeSummary fills it in for a whole response at once through three
grouped reads over the time entries rather than a query per row: one for
logged minutes, one for the billable money with the unbilled split and the
stamped count taken from a CASE that MySQL, PostgreSQL and SQLite all
speak, and one for the running rows. Running entries stay outside the
totals, because their duration is zero until the clock stops and the UI
ticks them itself.
A play button on a task row has to address that task, not "whatever is
running": POST tasks/{id}/start opens the caller's clock on it and POST
tasks/{id}/stop closes the clock only while it really runs there. Stopping
the wrong task, or stopping with nothing running, is the same mistake to
the caller, so both answer 409 timer_mismatch and the timer the user does
have keeps running. A second start stays the 409 timer_already_running the
timer endpoints already report, which is what the UI turns into "stop and
start". The timer/* routes are untouched.

GET tasks/{id}/time-log is the grid on the task page: running rows first,
then newest first, capped rather than paged. Totals are open to anyone who
may see the task, but the rows behind them follow the timesheet rule, so
without view-all-time and with the company setting closed the caller sees
their own time and nobody else's.
Rounding to the nearest increment is one of three things a firm means by
rounding, and the other two are the ones that show up in an engagement
letter. Rounding::roundMinutes takes a direction: nearest keeps today's
behaviour to the minute, up takes the whole increment, and down drops the
part increment and may bill nothing, which is exactly what a firm asking to
round down is asking for. The increments now offer 5 and 60 as well.

The settings endpoint carries the rest of what the task-centric UI needs:
auto_start_tasks, lock_invoiced_tasks, hide_invoiced_on_board and the six
invoice line toggles. Every switch lives in one table in ModuleSettings, so
the schema the host renders, the typed getters, the settings response and
the data cleanup all read from the same list and cannot drift apart.
…time

Three rules that all guard the same thing: what a client was already billed
for.

lock_invoiced_tasks, when a company turns it on, makes a fully invoiced
task refuse an edit, a status move and a delete with 422 task_locked. The
check lives in TaskLock so the rule is written once; a task that is only
partly invoiced still moves, because the work is not finished.

auto_start_tasks starts the creator's clock on a task they have just
created, but only when they have no timer running. A creator who is already
timing something keeps that timer, and the task is created either way.

Independent fix: an invoiced time entry could have its duration, its
billable flag and its task rewritten, and a change to the rounding setting
could re-round it on any save, silently moving money an invoice had already
recorded. A stamped entry now accepts a new description and nothing else,
and refuses a real change to the protected fields; posting the row's own
values back is not a change, so an edit form still works.

The task list also gains ?invoiced=0|1, which asks the entries through
exists subqueries rather than caching a state on the task.
The module had one sidebar entry, filed under Modules, and time logging was
reachable only through an unlabelled clock button. Projects and Tasks are
two ways into the same module, not a feature and its sub-page, so both join
the host's own main group after Items: Projects at priority 40, Tasks at
50. A firm that installs this module lives in it all day, and a Modules
heading files it away as an add-on.

The registry keys stay separate, so menuFor('tasks-projects') still answers
with the module's primary entry and the host's module page lookup is
unaffected. lang/en/menu.php keeps `title` for whatever still reads it.

invoice-tasks now also depends on the host edit-invoice ability, because
invoicing a task ends on the host invoice edit page and a role that may
raise the invoice has to be allowed to open it.
POST tasks/bulk applies one action to a selection and answers with both
lists: the ids that went through and, for each one that did not, the reason
it refused. Each task runs in its own transaction through the same service
the single-task routes use, so one locked, invoiced or missing task never
takes the rest of the selection with it.

Only status and delete are bulk actions here. Starting and stopping timers
are not, because one running timer per user is a hard invariant of this
module and "start these twelve tasks" has no honest meaning. Invoicing is
not either, for the opposite reason: it turns the whole selection into one
host document and has to refuse mixed customers as a single failure, which
belongs in the billing endpoints.
@gdarko

gdarko commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #14, which carries this stack consolidated into three commits on top of main.

@gdarko gdarko closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant