Conversation
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.
Contributor
Author
|
Superseded by #14, which carries this stack consolidated into three commits on top of main. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 1 of the task-centric rework, on top of
feat/ui-billing. Backend andsidebar only: no file under
resources/jsis touched, so the existing UI keepsworking 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
timeon every task.TaskResourcenow 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.phpfills it in for a whole response at oncethrough three grouped reads over
tp_time_entries: logged minutes, the billablemoney with the unbilled split and the stamped count from a
CASEthat 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}/stopanswers 200 with the completed entry, or 409timer_mismatch(newTimerMismatch) when the caller's clock runs elsewhere ornowhere, leaving the real timer alone. The
timer/*routes are unchanged.GET tasks/{id}/time-logreturns{ data: TimeEntryResource[] }, runningrows first then newest first, capped at 500, narrowed to the caller's own rows
unless they hold
view-all-timeor the company opens the timesheet. Totals stayvisible to anyone with
view-task; only the rows follow that rule.GET tasks?invoiced=0|1through portablewhereExistssubqueries, andPOST tasks/bulk(statusordelete) 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_boardand the six invoice linetoggles;
rounding_incrementsbecomes[1, 5, 6, 15, 30, 60]. Every switchlives in one table in
ModuleSettings, so the host schema, the typed getters,the settings response and
DataCleanupcannot drift apart (a test pins that).Behaviour.
auto_start_tasksstarts the creator's clock on a new task onlywhen they have no timer running; the create succeeds either way.
lock_invoiced_tasksmakes a fully invoiced task refuse edit, move and deletewith 422
task_locked, through the newTaskLock.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,billableortask_id. Postingthe 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'smaingroup, after Items. The registry keys stay separate, so
menuFor('tasks-projects')still answers with the primary entry.invoice-tasksnow also depends on the hostedit-invoiceability, becauseinvoicing a task ends on the host invoice edit page.
Verification
vendor/bin/pint,composer run lint,composer run test(293 tests, 1235assertions, up from 247),
validate-moduleandvalidate-packageall green.No migration changed: the existing
(company_id, task_id)index already coversthe summary and the filter.
Checked against the running dev stack (company 2): the bootstrap
maingrouplists Dashboard, Customers, Items, Projects, Tasks;
GET taskscarries thetimeblock;tasks/3/startreturned 201,tasks/4/stopreturned 409timer_mismatch,tasks/3/stopreturned 200, and the smoke entry was removed,leaving no timer running.
https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E