Audience: Internal engineering + PM. π’ Built & solid β with one honest friction point in the AI-draft flow. Tasks are the fast "small things that need doing" layer, deliberately kept apart from the Gantt schedule. The core (create, assign, checklist, RSVP, complete, recur) is real and code-grounded. The AI task-drafting flow works and gates everything behind a review-before-save step, but the drafts it produces are thin (title + priority only) β flagged below. Cross-links: Jobs CC Β· Scheduling & Gantt.
Badges: π’ built & solid Β· π‘ built but partial / friction Β· βͺ optional / best-effort Β· β not built.
A Task is a lightweight, assignable to-do / checklist item β Buildertrend "Tasks"/To-Dos parity. It carries multi-assignee (team Users and subs, each with their own RSVP), a checklist of sub-items, priority, due date, type, tags, and a status. It can link to a schedule item / PO / CO / RFI / selection, but it is not a schedule bar: no OpenProject sync, no dependency engine, no date cascade (app/models/task.py:1, app/services/tasks.py:1). If you want dependency-driven bars on a Gantt, that is a different object entirely β see below.
These are two separate models that people constantly conflate. Keep them straight:
| Task (to-do) | ScheduleItem (Gantt) | |
|---|---|---|
| Purpose | Fast action item / checklist | Planned work with dates & dependencies |
| Model | app/models/task.py:32 |
app/models/schedule.py |
| Dates | Single due_date (+ optional start_date), no cascade |
Start/finish computed from durations + dependency graph |
| Dependencies | None | All four relation types, critical path |
| Backend sync | None (hub-only) | Mirrored to OpenProject work packages |
| Assignees | Multi (Users + subs), per-person RSVP | Trade / assignee field |
| Where | Jobs CC β Tasks folder + cross-job hub | Scheduling & Gantt |
A Task can point at a schedule item via schedule_item_id ("this to-do supports that scheduled work"), but that link is informational only β completing the to-do does nothing to the schedule bar (app/models/task.py:47).
Four tables, all tenant-scoped (app/models/task.py):
Task (:32) β job_id nullable: a null job means an org-level "General" task not tied to any Job (:35). Fields: title, description, status (open | in_progress | blocked | done), priority (low | normal | high | urgent), task_type (free category), tags (JSON, capped 12 Γ 32 chars), due_date, start_date, schedule_item_id, generic linked_type/linked_id (po | co | rfi | selection, no DB FK β resolved in the service), recurrence (daily | weekly | biweekly | monthly), source (manual | ai | trigger | recurrence), completed_at, sort_order.TaskAssignee (:85) β one row per assignee, a team User or a Sub. Each carries its own RSVP: confirmation (pending β confirmed / declined) + decline_reason. This is what makes a multi-assignee task track acceptance per person.TaskItem (:106) β a checklist sub-item (text, done, done_at, sort_order).TaskComment (:121) β body + @mentions (team-user IDs stored for notification; matched by first name in the service).Three entry points, all under the /portal prefix and gated to PM / office roles (app/routers/tasks.py:25):
POST /portal/jobs/{job_id}/tasks (app/routers/tasks.py:250). The rich path: title, description, priority, type, due date, schedule_item_id, a generic link, tags, recurrence, a checklist, and multi-assignee (users + subs). This is what the Jobs CC Tasks folder posts to.POST /portal/tasks/new/create (:93). Optionally tie to a Job; if no job, it becomes a "General" task.All creation runs through task_svc.create() (app/services/tasks.py:26), which validates enum fields, clamps lengths, wires assignees + checklist, and returns the task. The router commits (not the service) so it can also fire notifications with a public base URL.
Assign to any mix of team Users and subs. _set_assignees() reconciles the assignee set and returns only the newly added rows so we notify just those people (app/services/tasks.py:147). On assignment, notify_new_assignees() (:176) pushes:
The assignee then confirms or declines via rsvp() (:223), which flips confirmation, records a decline reason, and notifies the office. All notification paths are wrapped best-effort β a failed send never blocks assignment or completion (:219, :248). βͺ
Comments support @mentions matched to active team users by first name; mentioned users plus the task's team assignees (minus the author) get notified (app/services/tasks.py:270).
POST /portal/tasks/{id}/state with status_value=done (app/routers/tasks.py:320) β set_status() stamps completed_at (app/services/tasks.py:64).spawn_recurrence() clones it (copying assignees + checklist), advances the due date by the rule, and resets status to open (:71, :93). Wrapped so a recurrence failure can never block the completion itself.toggle_item(), :264).β οΈ No coupling between the checklist and the parent status. Checking every
TaskItemdoes not auto-complete the task, and marking the task done does not auto-check its items. Thechecklist_done/checklist_totalproperties (app/models/task.py:76) are display-only. Intended? Worth confirming.
Two surfaces read the same task store:
Cross-job hub β GET /portal/tasks β tasks_hub (app/routers/tasks.py:73), backed by for_user() (app/services/tasks.py:519). Pulls every task across the org into tasks.html and filters client-side. Ships counts n_mine / n_open / n_overdue, the team + subs + active-jobs lists for the assign dropdowns, and saved views (named filter+view combos persisted on user.nav_prefs, max 8 β POST /portal/tasks/views/save, :234). Route ordering matters: /tasks and the AI/create routes are registered before /tasks/{task_id} so the literal paths win (:72).
Per-job Tasks folder in Jobs CC β for_job() (app/services/tasks.py:593) builds richer cards (checklist items, comments, attached docs, resolved link labels) and is merged into the command-center context at app/portal/router.py:2303. It also computes tasks_by_link (app/services/tasks.py:375) so a PO/CO/RFI/selection detail pane can show the to-dos hanging off it, in reverse.
Field surfaces βͺ β the same tasks appear as a field worker's "My Day" (for_field_user, :547; app/routers/field.py:692) and on the sub crew wall (for_sub, :569; app/routers/sub_portal.py:172). Both are walled: a field user / sub sees only tasks assigned to them.
The AI never writes a task directly. Every draft path returns suggestions and commits nothing until the user reviews and confirms. Three draft endpoints exist:
| Endpoint | Source | Returns | Line |
|---|---|---|---|
POST /portal/tasks/ai/generate |
Free-text note | Full-page review screen (tasks_review.html) |
app/routers/tasks.py:131 |
POST /portal/tasks/ai/from-job |
The Job's existing activity (logs, RFIs, punch, schedule, selections, submittals) | Full-page review screen | :148 |
POST /portal/tasks/ai/draft |
mode=note or mode=activity |
JSON for the inline CC review modal | :163 |
_review() screen (:122) β nothing saved yet./ai/draft endpoint powers the command-center inline modal and returns typed reason codes when it produces nothing: no_job, no_activity, ai_failed, empty_note (:177β:196) so the UI can explain the empty state.The save step β POST /portal/tasks/ai/confirm (:199) is the only committing path. It walks the kept checkboxes (keep_{i}), takes the user's edited title + priority, and creates only those, tagged source="ai" (:217β:225).
Generation internals (app/services/tasks.py:406): calls DeepSeek (app/integrations/ai/deepseek.chat) in JSON mode, temp 0.3, max 700 tokens, one retry on error, returns [] on hard failure (callers read [] as ai_failed). Two system prompts: _GEN_SYSTEM for a note (3β8 tasks) and _GEN_SYSTEM_JOB for job activity (4β8, biased toward overdue/blocking follow-ups) β both instructed to stay faithful and never invent scope, prices, or names (:389). The job-activity digest is built by job_activity_summary() (:443): last 8 daily logs, open RFIs (with age + ball-in-court), open punch items, schedule tasks due within 14 days or overdue, selections awaiting a client choice, and pending submittals.
The review gate itself is light and correct β one keep/edit pass, no accidental writes, good empty-state reasons. The friction is upstream: the AI only ever emits title + priority. It sets no assignee, no due date, no checklist, no link β and the confirm step passes only those two fields to create() (app/routers/tasks.py:224). So every AI-drafted task lands unassigned and undated, requiring a second manual edit on each one before it is actually actionable. For the "self-creating job" story this is the weak link: the drafts are suggestions of what, never who or when.
TaskItems auto-complete the parent task (or vice-versa)? Today they're fully decoupled (app/models/task.py:76) β is that a product decision or an omission?source="trigger" tasks. The model documents a trigger source ("self-creating story," app/models/task.py:58) but no endpoint or service writes it β is auto-triggered task creation built anywhere, or is that column aspirational?for_user() loads every task in the org and filters client-side (app/services/tasks.py:519). At what task count does that page need server-side pagination/filtering?spawn_recurrence() always spawns the next instance on completion with no count/until cap (app/services/tasks.py:93) β intended to run forever, or should there be a stop rule?app/routers/tasks.py:27), yet field users and subs see and (via RSVP) act on their tasks. Can a field user mark their own assigned task done, or is completion office-only? Confirm the intended field write surface.