Skip to content

feat(ui): task-centric Tasks screen, task page and time log (rework PR 2) - #9

Closed
gdarko wants to merge 13 commits into
feat/task-centric-backendfrom
feat/task-centric-ui
Closed

gdarko wants to merge 13 commits into
feat/task-centric-backendfrom
feat/task-centric-ui

Conversation

@gdarko

@gdarko gdarko commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Second of four PRs reworking the module around Invoice Ninja's task-centric
model. This one is the frontend minus invoicing: the Tasks screen, the task
page and the time log. It is written against the API PR 1 is delivering in
parallel and is merged after it.

What changes

Tasks becomes the module root. One screen at modules/tasks-projects with
three children: List (''), Board (board) and Week (week). The header, the
filters and the pickers load once, so a view switch costs one request for the
rows and none for the pickers. The filters live in the query string, which is
what makes a view switch, a reload, a back button and a pasted link describe
the same screen.

Projects moves to modules/tasks-projects/projects, with the detail page
and its four tabs behind it. The host sidebar highlights the longest matching
prefix, so the two entries light independently.

A task has a page. tasks/:id carries the number, the project and customer
as links, the status as a picker that writes straight through, the estimate
against the logged time, the unbilled amount, the invoiced badge and the run
control, with the time-log grid below it: start and end date and time,
duration, description, billable, member. A running row counts up; an invoiced
row is read only; "Add item" waits for the caller's own timer to stop.

Start and stop are everywhere, through one TaskRunControl on rows, cards
and the task page. One timer per user is a hard invariant, so a task that is
not the one running shows a disabled play naming what is, with "stop and start"
beside it. Another member's clock renders as their initials and a running time,
with no button.

One clock for the page. useNow() hands out a single ref ticked once a
second and counts its subscribers, so a board of running cards costs one
wake-up and only the rows reading it re-render.

/time forwards to the Week view for one release, so bookmarks keep
working.

Deleted

pages/BoardPage.vue, pages/TimePage.vue, components/TaskDrawer.vue (now
TaskFormModal.vue with a compact create mode) and registrations/board.ts
(now registrations/tasks.ts and registrations/projects.ts).

Invoicing

PR 3. Every affordance is rendered disabled with a "Coming with invoicing"
tooltip rather than left out, so the row menu, the bulk bar, the task header
and the project overview keep the shape they will have. pages/BillingPage.vue
is untouched apart from its breadcrumb, which had to follow the move.

API assumptions beyond the agreed contract

  • POST tasks/{id}/start accepts an empty body when no description is given.
  • POST tasks/{id}/move and PUT tasks/{id} answer with the same TaskResource
    the list does, time block included. A response without one keeps the value
    already on the row.
  • POST tasks/bulk answers { updated, failed } with HTTP 200 even when part
    of the selection was refused.
  • GET tasks/{id}/time-log is not paginated and is capped server-side.

Verification

pnpm run lint, pnpm exec tsc --noEmit and pnpm run build are clean; the
bundle has exactly one window.__invoiceshelf_vue destructuring and no
from "vue". composer run test is unchanged at 247 tests, 978 assertions.
No browser pass from this worktree: it serves a sibling checkout, so every
payload read is guarded and the components are kept small for the orchestrator's
pass after PR 1 is merged in.

https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E

Every screen that shows a task now shows how long it has taken, what is still
unbilled and who has a clock running on it, so `TaskResource` carries a `time`
block and the rows carry it with them. Every read of that block goes through
`taskTime()`, which merges the payload over an empty summary: a server that
predates the block still renders a list, it just reports nothing logged.

Two pieces of shared state come with it. `taskVersion` is bumped by every
write, including a timer that started or stopped, and every list, board and
grid watches it; without it a clock started on the task page would leave the
list behind it showing yesterday's total. `patchTime` is what makes pressing
play feel instant: the row the button sits in came from a request that will not
be repeated for a second, so the running entry is written locally and replaced
when the fresh answer lands.

The per-second reactivity is one interval for the whole page. A board can show
a dozen running cards and a time log a dozen running rows; `useNow()` hands out
one ref and counts its subscribers, so the cost is one wake-up a second and
only the components reading it re-render. The store holds the same subscription
while the caller's own timer runs, which is what keeps the header chip ticking
on a screen with no rows at all.

`startOnTask` and `stopOnTask` use the new per-task routes. They name the task,
so a stale row cannot stop a timer that has since moved: the server answers
`timer_mismatch` and the row reloads. `errorCode()` reads the
`{ message, error }` shape structurally, because the sentence is translated and
the key is not.

`support/filters.ts` makes the address bar the filter state. All four values
are strings, which is what a query string holds, so a view switch, a reload, a
back button and a pasted link all describe the same screen with no conversion
but the one at the endpoint. `PATHS` and `ROUTES` in `support/page.ts` stop each
screen from spelling the module prefix out again.
…eens

The slices changed shape, so the bundles follow them: `messages/tasks.ts` holds
what the Tasks screen, its three views and the task page render, and what was
`messages/board.ts` keeps the project detail alone under its own name. A slice
owning one file is what stops two of them editing the same lines, and the host
merges every bundle recursively, so nothing about the `tasks_projects`
namespace changes.

The new strings are the ones the rework needs a word for: the view switcher,
the bulk bar and what it reports when the server refuses part of a selection,
the invoiced and unbilled badges, the task page's own labels, the time-log
columns, and the timer's new answers, including the tooltip that names the task
a caller's clock is already on and the "stop and start" that gets out of it.

`settings` gains a read-only summary of the new toggles, so the module's
settings page can say what the company has chosen without offering a second
place to change it.
… card

`TaskRunControl` is the one place start and stop are spelled out, so a row, a
card and the task page all behave the same. One timer per user is a hard
invariant, so a task that is not the one running shows a disabled play with a
tooltip naming what is running and a "stop and start" beside it, which is the
only honest way to offer the thing people actually want. Another member's clock
renders as their initials and a running time with no button: their time is
theirs to stop.

`TaskFilters` puts project, member, status and search in one row and hands them
up as a whole. "Uninvoiced" and "Invoiced" sit in the status picker rather than
in a control of their own: it is a question people ask of a task list, it cuts
across the columns rather than being one of them, and a second picker is one
nobody would find. `ViewSwitcher` pushes `{ name, query }` so a view change
carries the filters rather than resetting them.

`BulkActionBar` offers a status change and a delete. Start, stop and invoice
are not here: the first two would break the one-timer invariant, and invoicing
arrives in its own slice, so its button is rendered disabled rather than left
out, because a menu that changes shape under people is worse than one with a
greyed row in it.

`TaskDrawer` becomes `TaskFormModal` with a compact mode. Creating a task
should cost a name and a column; the description, the estimate and the rate
override are things people come back to fill in, so they sit behind "All
fields". A refusal carrying `task_locked` says so in words instead of failing
generically, because the invoice, not a bug, is in the way.

`TimeEntryModal` gains `lockTask` and a preset task: opened from a task's own
log, moving the entry to another task is a way to lose it, and the log it would
move to is one click away.
…one filter

The three ways of reading the work were three destinations before: a Tasks page
with no inbound links, a Board behind a button, and a Time page nothing pointed
at. They are now three children of one screen. The header, the filters and the
pickers are loaded once by `TasksPage`, so a view switch costs one request for
the rows and none for the pickers, and the filters live in the query string, so
List, Board and Week are three readings of the same question rather than three
places to ask it again.

The list gains what the screen was missing: selection and a bulk bar, a name
that leads to the task rather than opening a drawer over it, time logged, money
still unbilled, an invoiced badge and a run control on every row. Sorting keeps
only the columns the endpoint can order by.

The board keeps its drag and drop and its per-column "+", which now presets the
column and the filtered project. Two rules hide cards: the company's
`hide_invoiced_on_board` setting and the invoiced status filter. A drag reports
positions in the rendered list, so every index is translated back through the
column's own array before anything moves, otherwise a hidden card would make
the drop land a row out.

The week view is the old timesheet under its new home, honouring the shared
member and project filters. `AllTimeTable` loses its own member and project
pickers for the same reason: two pairs of controls over one list can disagree,
and the pair on the header is the one every view shares.
"Where do I log time" had no answer before: a task opened a drawer of form
fields and closed again, and the entries it had collected were only visible on
a separate screen that did not link back. A task now has a page of its own at
`tasks/:id`, reached from every row and from the header chip, and the time log
is on it.

The header carries what a task is: its number, its project and the customer the
work bills to, both as links, the priority, the due date, what was estimated
against what has been logged, what is still unbilled, and the run control. The
status is a picker that writes straight through rather than a field inside a
form, because moving a task along is the edit people make over and over; it is
applied locally, sent, and rolled back if the server refuses.

`TimeLogGrid` lists every interval with its start and end date and time, its
duration, its description, whether it is billable and who logged it. A running
row counts up on the shared tick and cannot be edited out from under the clock.
"Add item" waits for the caller's own timer to stop, because logging by hand
while it runs would describe the same minutes twice. An invoiced row is read
only and says so where its menu would be: that time belongs to its invoice.

Invoicing arrives in its own slice, so "Invoice task" is rendered disabled
rather than left out, and the header keeps the shape it will have.
…work

Projects has its own path now, `modules/tasks-projects/projects`, so every
breadcrumb, header link and row link that pointed at the module root had to
follow. The host sidebar highlights the longest matching prefix, which is what
lets the two entries light independently.

The Tasks tab is the same list component the Tasks screen uses, with the
project locked, so a row on a project behaves exactly like a row anywhere else:
selection, a bulk bar, a live clock and a link to the task. Its filters stay
out of the address bar, because a project link that carried somebody else's
search would be a bad thing to share.

The Time tab stops being a read-only table. Entries can be added, edited and
deleted from it through the same modal the week grid uses, the member who
logged each one is named, and a running row counts up rather than saying
"Running" forever.

The Overview keeps its budget, logged and unbilled cards and swaps the link
into the wizard for the "Invoice project" button that replaces it, rendered
disabled until the invoicing slice lands. The project header's Board button now
opens the board on this project, since the board lives under Tasks and reads
its project from the query.

The Reports page and the wizard's breadcrumb follow the same move; the wizard
itself is left alone, because the invoicing slice replaces it whole.
… screens

`registrations/board.ts` registered a board, a task list and a project detail
that no longer describe the module, so it is replaced by two files that do:
`tasks.ts` owns the Tasks screen with its List, Board and Week children and the
task page, `projects.ts` owns the project index and detail. `BoardPage.vue`,
`TimePage.vue` and `TaskDrawer.vue` go with it: their contents live in the
views and the modal that replaced them.

`/time` stays registered for one release and forwards to the Week view, so
bookmarks and the release notes keep working instead of landing on a page the
host would blame the module for.

`registrations/time.ts` keeps only what has no screen of its own, and `init.ts`
is five lines of slices with the page wrapper shared from `support/page.ts`
rather than copied into two files.

The header chip now opens the running task rather than the timesheet: what
someone wants when they look at a running clock is the thing it is running on,
with its log and its stop button beside it. The quick-start launcher's link
opens the Week view, which is where the timesheet went.

The settings page reads the new toggles back. They are declared in the module's
PHP schema and edited by the host's generic form, so mirroring the inputs would
give the company two places to write one value; showing the current answers
still belongs here, because this is where people come to ask what the module is
doing.
The package ships compiled, so `dist/` travels with the source change and the
module installs without a build step on the target system.
@gdarko
gdarko changed the base branch from feat/ui-billing to feat/task-centric-backend September 15, 2026 07:48
The task list carried eleven columns at the host table's `px-6`, which made
it some three hundred pixels wider than the content area on a 1280 screen:
the row menu sat past the right edge, behind a horizontal scrollbar nobody
goes looking for, so editing or deleting a task from the list was
unreachable at the width most people run the app at.

The due date moves under the task name, where it is a detail of the task
rather than a column that is empty on most rows, and the remaining cells
use half the host's horizontal padding. The table now fits the content area
at 1280 and 1440, and the row menu is where the eye expects it.
The three "Invoice task" and "Invoice project" placeholders are disabled
until the invoicing slice lands, but the host button renders a disabled
button exactly like a live one: same border, same colour, full opacity. A
person reading the task page, the bulk bar or a project's Unbilled card saw
an ordinary action and got nothing when they clicked it.

The wrapper that already carries the "coming with invoicing" tooltip now
dims the button and shows the not-allowed cursor, so the affordance reads
as the promise it is.
The task form's Customer field printed the raw contact id, so editing a
task showed "#22" where every other screen in the module shows "Acme Corp".
The company's contact map already exists for exactly this, and the form is
now the screen that asks for it when a task carries a customer: opening the
form from the list, where nothing had loaded the map yet, fills the field
in rather than leaving a number behind.
At `w-72` the board needs 1200 pixels for the four statuses a company starts
with, which is eighty more than the content area offers on a 1440 screen:
the Done column arrived sliced down the middle, its badge cut in half, which
reads as a broken screen rather than as a board to scroll.

Columns are `w-64` now, so the default board fits the page and a company
that adds statuses still gets the sideways scroll the layout was built for.
@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