Portal v3 Task Completion
Bring customer task completion into the v3 portal's widget layer and replace the legacy-forked task page with a v3-native one — on the exact API surface v2 uses, so existing customers see no behavior change.
🎨 Design reference — the agreed UX lives in an interactive prototype: Portal v3 — Task Completion UX Prototype (both views, light/dark, toggle "Design notes" for per-pattern rationale).
TL;DR
Zero-step tasks complete inline, in one tap.
The checklist and next-step widgets get a real completion control — optimistic, with undo — cloned from the favorites write pattern. Step-ful tasks keep navigating.
Same steps, same endpoints, new chrome.
The task detail route drops its legacy @cp/pages/private imports for portal-renderer chrome, preserving both completion entry points and every step type.
No new endpoints, no task-schema changes, additive contract only.
v3 calls exactly what v2 calls, so every side effect (dependencies, notifications, CRM sync, auto-complete) behaves identically. v2 code paths are untouched.
Motivation
Everything Portal Studio can author renders read-only. The only write path in the widget layer is favorites; every task-bearing widget (task_checklist, next_step, timeline, conversations) only navigates. A customer can complete a task in v3 solely on the task detail route (app/ui-customer/pages/v3/private/pages/project/projectTask/ProjectTask.vue), which is a fork of the legacy page composing v1 components from @cp/pages/private/… — functional, but off-theme and outside the config-driven surface.
Competitor consensus (Rocketlane, GuideCX, Arrows, EverAfter, Dock, Motion.io) is unambiguous: the completion control lives on the task row, the work to complete a rich task is embedded in the task, completion semantics are per task type (upload completes on file receipt, form on submit, e-sign on signature), and the portal always answers "what's next." OnRamp's step-type model already provides the per-type semantics; the gap is purely surface.
Current state (what a build must respect)
| Fact | Where |
|---|---|
| Completion endpoint is a URL-encoded state transition, empty body | POST /projects/{id}/tasks/{id}/updatestate/TASK_FINISHED → app/api/task/routes.py |
Two completion entry points: explicit button only when totalStepsInAnswerPath === 0; implicit on answering the last step | pages/v2/.../ProjectTask.vue, TaskStepFooter.vue |
| All task-interaction variety lives in step types (13), not task types | app/ui-customer/components/StepTypes/, app/api/utils/constants/step_types.py |
| Server is already the validator | assert_task_completable, is_task_workable_by_user (app/api/task/services_old.py, helpers.py) |
| Completion side effects hang off the one endpoint: dependency unlock, module/project progress, health, emails, webhooks, CRM queues, project auto-complete, activity, automations | app/api/task/routes.py (updatestate handler) |
| Widget writes have a proven optimistic pattern | favorites: portalRenderer/data/createLiveAdapter.js + api/favorites/favorites.js |
Preview safety is centralized: writes are inert unless adapter.mode === 'live' | portalRenderer/data/portalActions.js |
| Task ids are encoded in some endpoints and decoded in others; not interchangeable | stores/task.store.js, stores/project.store.js |
| The answer-path/branching algorithm exists twice (client + server) and must not be forked a third time | task.store.js ↔ validate_task_can_be_completed |
Locked UX decisions
Agreed on the prototype (2026-08-05):
- Row-level completion only where it's honest. Zero-step tasks get a tap-to-complete circle on the row; step-ful tasks show a step-progress pill and open the task page. No checkbox that lies.
- Optimistic, with undo anchored to the control that acted. Complete applies instantly and progress animates; rollback on API failure (favorites pattern). Undo is offered on the row itself for a short window rather than in a global toast — portal widgets render in two hosts (the customer app and the Studio canvas) and only one of them has a toast sink, and with several completions in quick succession a stack of identical toasts cannot say which one it would take back.
- The next-step spotlight keeps one action, and it stays the authored one. It drives to the task; it does not offer to complete it. Swapping its button to "Mark done" whenever the task happened to have no steps discarded the vendor's own
cta_labeland flipped the card's primary action based on something the customer cannot see. Checking things off belongs to the list widgets. - Quiet completion acknowledgment — no confetti. The control celebrates itself (spring check draw-in, strikethrough settle), progress ticks up, toast confirms. The task page finishes with an animated check-circle panel. The v2 confetti (
transitionToFeedback) is deliberately not ported. The v2 micro-feedback prompt (emoji row) stays. - Per-type completion semantics carry over unchanged from v2's step types: instructions/presentations auto-complete on view, uploads on file receipt (with the "nothing to provide" skip), forms on submit, e-sign on signature.
- "Waiting on you" — a small aggregate widget of everything pending on the customer, with the same inline controls.
- "I'm stuck" is Phase 4 only (needs new backend state); it appears in the prototype for directional alignment, badged "Later phase".
Plan
Phase 0 — Foundations
- Teach the list payload whether a task has steps. The portal home DTO carried no step signal at all —
completed_percentageis0both for a task with no steps and for one with five unanswered ones — so the gate below could not be evaluated client-side.TaskForPortalHomePageDTO.steps_countis added last with a0default (additive, positional callers unaffected), fed from the existing deferredORTask.steps_countcolumn property, and undeferred in the portal-home query only so it costs one subquery per page rather than one per task. portalRenderer/data/taskCompletion.jsis the single home for the rule:canCompleteInline(task)plus theusePortalTaskCompletion()control state. The predicate is deliberately narrower than the server's own and never wider — no steps, not the vendor's, not blocked, not finished, and not assignee-restricted (the payload carries restriction ids but not who they point at, so a restricted task opens rather than guessing).- Extend the portal adapter (
portalRenderer/data/createLiveAdapter.js) withcompleteTask(task)/reopenTask(task), both delegating to the project store so a widget row and the task page complete a task the same way. Widgets route the call throughusePortalActions().run(), so Studio preview/canvas stays inert with no per-widget work; a host that supplies neither method (the account home) shows no control at all. - Contract bump (
app/api/portal_studio/contracts/portal_studio_contract.json), additive only: new optional widget props with defaults —task_checklist.allow_inline_complete(defaulttrue),next_step.allow_inline_complete(defaulttrue). Published configs missing the props resolve to defaults and render byte-identically. - Close two authz gaps found in the audit (independent of this feature but on the path):
GET /api/portal/task/{id}gains theassert_project_readablemembership check the other portal reads use;GET /tasks/{id}/dependenciesgains vendor scoping. - Tests: adapter gating matrix (internal / assignee-restricted / blocked / completed / step-ful), preview inertness, contract resolution defaults, authz regressions.
Phase 1 — Inline completion (checklist + next-step)
PortalTaskRow.vue: render the completion circle whencanCompleteInline(task); otherwise keep the current row. Optimistic, with rollback on failure and an undo offer on the row itself.WidgetTaskChecklist.vue: module counts and progress recompute from store state (already reactive viamodulesToDisplay).WidgetNextStep.vueis deliberately untouched — see the locked decision above.- Wire:
POST /projects/{projectId}/tasks/{taskIdDecoded}/updatestate/TASK_FINISHEDvia the existingprojectStore.completeProjectTask(keeps thependingCompletionsin-flight guard and decoded-id discipline). Undo maps toupdatestate/TASK_IN_PROGRESS. - Studio inspector:
allow_inline_completesurfaces as a toggle on each list widget (Valuecase-style vendor control), and the canvas shows the control inert so an author can see what the toggle does. - Tests: widget unit tests for both states, one Playwright spec (complete inline → row settles → progress updates → undo restores).
Phase 2 — v3 task page: the completion moment
Split from the wider page rebuild because the two carry very different risk. Done here:
- v3 stops firing confetti. The
playConfettiAnimationwatcher and theuseShowSuccessConfettiimport are gone from the v3 page; the store flag itself is untouched, so v1/v2 keep their behaviour exactly. - v3 gets its own
TaskCompletePanel.vuein place of the legacyTaskFeedbackimport: a check that draws itself in, a line saying the vendor has been notified, and the existingSimplifiedFeedbackrating below it. Enter-to-continue is carried across. The legacy panel is untouched and still serves v1/v2.
Deliberately not done: rebuilding the page on PortalLayoutShell and migrating the TaskOverview/TaskStep/StepControl family. Those are protected runtime internals, the page is the only route that can complete a step-ful task, and the change is not provable by unit tests alone — it wants the golden seed and a browser pass. It stays a tracked follow-up rather than a blind rewrite.
Phase 3 — "Waiting on you"
- New widget (contract +
WidgetWaitingOnYou.vue+ registry) aggregating the customer's open work across every stage, soonest-due first, capped by an authored limit that says how much it is holding back rather than just stopping. ReusesPortalTaskRow, so the completion control, the star and the preview policy are the same objects the checklist uses. Data derives from the modules already in the project payload; no new endpoint. - No task-kind chip (upload / sign / form). Step types are not in the list payload, and labelling a row "Upload" without knowing that is fabricated data. Adding it means widening the DTO the way
steps_countwas widened — worth doing, deliberately not done blind.
Phase 4 — Differentiators (separate proposal before build)
- "I'm stuck" customer signal and vendor "Request changes" reopen loop. Both need new backend state/semantics and vendor-side UI — out of the backwards-compat envelope, so deliberately not in this plan's scope.
Backwards compatibility
- No new endpoints and no task-schema changes. The one payload change is an additive DTO field with a
0default, appended last. Every side effect of completion fires from the same handler v2 calls today; undo postsupdatestate/TASK_IN_PROGRESS, a transition the endpoint already supports. - Contract changes are additive with defaults. Existing published configs carry no
allow_inline_complete, so they resolve to the component default — which istrue. That is the intended behaviour change and the reason the whole feature still sits behind theportal-studioflag: portals gain the control, nothing else about how they render moves. - v2 and v1 code paths untouched. The feature rides the existing
portal-studioflag and the hardcoded-layout fallback stays intact; vendors without the flag see nothing. - Preview stays inert via the existing
portalActionslive-mode gate — the Studio canvas can never complete a real task. - Server remains the validator. The inline control is gating UX only;
is_task_workable_by_user+assert_task_completablestill decide.
Risks & open questions
The branch-resolution algorithm exists in task.store.js and validate_task_can_be_completed. Phase 2 must reuse the store's copy verbatim.
Mitigation: wrap, don't rewrite, the step components that consume it; parity tests per step type.
updatestate/TASK_IN_PROGRESS after a completion has already fired emails, webhooks, and dependency unlocks; undo does not un-send them.
Mitigation: short undo window (toast lifetime); completion emails already batch per transition; acceptable noise matches v2 behavior when a task is manually reopened.
The updatestate handler permits BLOCKED → FINISHED. The inline control hides on blocked tasks, but the endpoint allows it.
Mitigation: canCompleteInline excludes blocked; no endpoint change in this plan (matches v2 behavior).
Open questions
Should undo exist at all for tasks with vendor notifications? Ship Phase 1 with it (matches the prototype); revisit if vendors report noise.
Does "Waiting on you" need step-level granularity (e.g. "2 fields left on the intake form") or is task-level enough for v1? Task-level assumed.
Should the list payload carry step types, not just a count? It would buy the task-kind chip (upload / sign / form) and let the checklist say what a task wants before you open it — at the cost of another DTO widening.
Verification
Unit and component level, all green: 55 new frontend specs covering the predicate, the control, undo, preview inertness and the new widget (2778 frontend tests pass overall), plus backend specs pinning the DTO default, field order and the undefer. The backend suites were diffed against a stashed baseline — 149 pre-existing testcontainer errors before and after, so nothing regressed — and a production vite build compiles every new SFC.
Not yet browser-verified. Seeing the control against real data needs a portal-studio vendor, a project carrying a zero-step customer task, and a PIN session; the local checkout has no seed accounts and another session held the dev server. That pass is outstanding before this ships.