# `PhoenixKitProjects.Projects`
[🔗](https://github.com/BeamLabEU/phoenix_kit_projects/blob/v0.14.0/lib/phoenix_kit_projects/projects.ex#L1)

Context for projects, tasks, assignments, and dependencies.

# `closure_node`

```elixir
@type closure_node() :: %{
  task: PhoenixKitProjects.Schemas.Task.t(),
  children: [closure_node()],
  cycle?: boolean(),
  already_in_project?: boolean()
}
```

A node in the task-template dependency closure tree.

- `:task` — the `Task` schema struct, with task-level preloads.
- `:children` — child trees (the tasks this one depends on,
  transitively).
- `:cycle?` — `true` if this node was reached via a cycle in the
  template graph and traversal stopped here. `TaskDependency` doesn't
  enforce acyclicity, so this flag lets the UI render a warning
  instead of spinning forever.
- `:already_in_project?` — `true` if an assignment for this task
  already exists in the target project. UI uses this to show the
  node as "already there" (won't be re-added on save) — applies to
  every node in the tree, including the root.

# `error_atom`

```elixir
@type error_atom() :: :not_found | :template_not_found | :task_not_found
```

Atom-shaped error returned for not-found / missing-resource cases.

# `reorder_strategy`

```elixir
@type reorder_strategy() ::
  :name_asc | :name_desc | :created_asc | :created_desc | :reverse
```

# `uuid`

```elixir
@type uuid() :: String.t() | &lt;&lt;_::128&gt;&gt;
```

UUIDv7 string or raw 16-byte binary (Ecto accepts either).

# `add_dependency`

```elixir
@spec add_dependency(uuid(), uuid(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.Dependency.t()}
  | {:error, Ecto.Changeset.t()}
```

Adds an assignment-level dependency and broadcasts `:dependency_added`.

Rejects any edge that would introduce a cycle — i.e., if `depends_on_uuid`
already (transitively) depends on `assignment_uuid`, the insert is
refused with a changeset error. The schema-level self-reference check
handles the `A == B` case; this function handles multi-hop cycles
(`A → B`, then `B → A`).

The cycle check + insert run inside a `:serializable` transaction.
Without this, two concurrent calls — `add_dependency(A, B)` and
`add_dependency(B, A)` — could each read an acyclic graph, both
pass the check, both insert, and produce a cycle (the unique pair
index doesn't catch this; it only rejects identical duplicate
edges). At `:serializable` Postgres aborts the loser with
`serialization_failure` (`SQLSTATE 40001`); we catch that and
return a friendly changeset error so the caller can retry.

When called from inside another transaction (e.g. via
`create_project_from_template/2` → `clone_template/2`), the inner
`repo().transaction/2` becomes a savepoint and Postgres ignores
the inner `isolation:` keyword — the protection only holds if the
outer transaction is itself opened at `:serializable` (which
`clone_template/2` does).

# `add_task_dependency`

```elixir
@spec add_task_dependency(uuid(), uuid()) ::
  {:ok, PhoenixKitProjects.Schemas.TaskDependency.t()}
  | {:error, Ecto.Changeset.t()}
```

Adds a template-level dependency from one task to another.

# `apply_template_dependencies`

```elixir
@spec apply_template_dependencies(PhoenixKitProjects.Schemas.Assignment.t()) ::
  :ok | {:ok, term()} | {:error, term()}
```

When an assignment is created, auto-create assignment-level dependencies
from the task template's defaults (linking to sibling assignments already
in the same project).

Idempotent: duplicate `(assignment_uuid, depends_on_uuid)` pairs return
a unique-constraint changeset, which we translate into a no-op (already
exists is the desired end state). All inserts run in a single
transaction so a partial failure rolls the batch back.

# `archive_project`

```elixir
@spec archive_project(PhoenixKitProjects.Schemas.Project.t()) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
```

Soft-hides the project by stamping `archived_at`. Idempotent — re-archiving rewrites the timestamp.

# `assignment_status_counts`

```elixir
@spec assignment_status_counts() :: %{optional(String.t()) =&gt; non_neg_integer()}
```

Counts of assignments by status across active non-template projects.
Returns a map like %{"todo" => 5, "in_progress" => 2, "done" => 10}.

Filters on `is_nil(p.archived_at)` to match the dashboard's intent —
assignments inside archived projects shouldn't inflate the workload
stats shown alongside `list_active_projects/0`.

# `available_dependencies`

```elixir
@spec available_dependencies(uuid(), uuid()) :: [
  PhoenixKitProjects.Schemas.Assignment.t()
]
```

Assignments in this project that the given assignment does NOT yet depend on.

# `available_projects_to_link`

```elixir
@spec available_projects_to_link(PhoenixKitProjects.Schemas.Project.t()) :: [
  PhoenixKitProjects.Schemas.Project.t()
]
```

Projects eligible to be linked as a sub-project of `parent` (V127): standalone
(not already a sub-project), same `is_template`, not archived, not the parent,
and not one of the parent's ancestors (cycle-safe). Ordered by name for a
picker.

# `available_task_dependencies`

```elixir
@spec available_task_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Task.t()]
```

Tasks that the given task does not yet depend on (for the dependency picker).

# `broadcast_project_updated`

```elixir
@spec broadcast_project_updated(PhoenixKitProjects.Schemas.Project.t()) :: :ok
```

Broadcasts a `:project_updated` event for a project (post-commit use).

# `change_assignment`

```elixir
@spec change_assignment(PhoenixKitProjects.Schemas.Assignment.t(), map()) ::
  Ecto.Changeset.t()
```

Returns a changeset for the given assignment.

# `change_project`

```elixir
@spec change_project(PhoenixKitProjects.Schemas.Project.t(), map(), keyword()) ::
  Ecto.Changeset.t()
```

Returns a changeset for the given project.

Accepts the same `opts` as `Project.changeset/3` — notably
`:enforce_scheduled_date_required`, which the project form passes as
`false` on `phx-change` events so the just-revealed date input doesn't
light up red before the user has had a chance to fill it.

# `change_task`

```elixir
@spec change_task(PhoenixKitProjects.Schemas.Task.t(), map()) :: Ecto.Changeset.t()
```

Returns a changeset for the given task.

# `comment_counts_for_assignments`

```elixir
@spec comment_counts_for_assignments([uuid()]) :: %{
  optional(String.t()) =&gt; non_neg_integer()
}
```

Returns `%{assignment_uuid => published_comment_count}` for the
given assignment uuids. Single grouped query (no N queries).

Returns `%{}` if the `phoenix_kit_comments` module isn't loaded
(host hasn't installed it) or if anything goes wrong at the query
level — the comments badge is purely informational, never blocking,
so any failure degrades silently.

# `complete_assignment`

```elixir
@spec complete_assignment(PhoenixKitProjects.Schemas.Assignment.t(), uuid() | nil) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()}
  | {:error, Ecto.Changeset.t()}
```

Marks an assignment done, stamping `completed_by_uuid` and `completed_at`.

# `count_projects`

```elixir
@spec count_projects(keyword()) :: non_neg_integer()
```

Count of projects matching the given filter opts. Defaults match
`list_projects/1` (excludes templates, excludes archived) so the
count is paired with the list — sized "Showing X of Y" copy stays
honest about both numbers.

Pass `include_templates: true` to count both kinds, or
`archived: :all` to drop the archived filter.

# `count_tasks`

```elixir
@spec count_tasks() :: non_neg_integer()
```

Total number of tasks in the library.

# `count_templates`

```elixir
@spec count_templates() :: non_neg_integer()
```

Total number of template projects.

# `create_assignment`

```elixir
@spec create_assignment(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()}
  | {:error, Ecto.Changeset.t()}
```

Inserts an assignment and broadcasts `:assignment_created`.

Pass `broadcast: false` to skip the broadcast — used by callers inserting
inside a larger transaction (template clone, closure-pull) that emit their
own event after the transaction commits.

# `create_assignment_from_template`

```elixir
@spec create_assignment_from_template(uuid(), map(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()}
  | {:error, :task_not_found | Ecto.Changeset.t()}
```

Creates an assignment pre-populated from the task template's defaults
(description, duration, default assignee). The caller's attrs override
any template values.

# `create_assignments_with_closure`

```elixir
@spec create_assignments_with_closure(uuid(), uuid(), map(), keyword()) ::
  {:ok,
   %{
     root: PhoenixKitProjects.Schemas.Assignment.t(),
     extras: [PhoenixKitProjects.Schemas.Assignment.t()]
   }}
  | {:error, term()}
```

Creates the root assignment AND any closure-pulled assignments in one
serializable transaction, then wires `Dependency` rows according to
the `TaskDependency` graph between the resulting assignments.

Drives the assignment-form's "this task pulls in N more" UX: the user
picks a task, the closure tree shows what'll be dragged in, the user
optionally prunes nodes via `excluded_task_uuids`, and on save this
function lands the kept set atomically.

## Parameters

  * `root_task_uuid` — the task the user explicitly picked.
  * `project_uuid` — target project.
  * `attrs` — the form's `assignment` params, used for the root
    assignment's description/duration/assignee/etc. overrides.
  * `opts`:
      - `:excluded_task_uuids` — `MapSet` of task uuids the user
        unticked in the closure tree. Excluded tasks are skipped, but
        their template-dep edges that touch *kept* tasks are still
        wired (so removing a leaf doesn't break upstream wiring).
        Defaults to `MapSet.new()`.

Returns `{:ok, %{root: assignment, extras: [assignment, ...]}}` on
success or `{:error, reason}` on failure. The transaction rolls back
cleanly on any failure — partial closure inserts won't leak.

Tasks already represented by an assignment in the project are
*reused* (not duplicated) when wiring deps; a closure node whose
task already has an assignment becomes a wiring target without
triggering an insert.

# `create_project`

```elixir
@spec create_project(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
```

Inserts a project and broadcasts `:project_created`.

Pass `broadcast: false` to skip the broadcast — used by callers that create
the project inside a larger transaction (template clone) and emit a single
event once the transaction commits, so a rollback can't leak a
`:project_created` for a project that never persisted.

# `create_project_from_template`

```elixir
@spec create_project_from_template(uuid(), map()) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()}
  | {:error, :template_not_found | Ecto.Changeset.t() | term()}
```

Creates a new project by cloning a template. Copies all assignments
(with their task links, descriptions, durations, assignees, weekends
settings) and re-creates dependencies between the cloned assignments.

# `create_subproject`

```elixir
@spec create_subproject(uuid(), map()) ::
  {:ok,
   %{
     child_project: PhoenixKitProjects.Schemas.Project.t(),
     assignment: PhoenixKitProjects.Schemas.Assignment.t()
   }}
  | {:error, :parent_not_found | Ecto.Changeset.t()}
```

Adds a **sub-project** to `parent_project_uuid`: creates a fresh child
project (named via `child_attrs`) and links it into the parent's timeline as
an assignment whose `child_project_uuid` points at the new child. The child
starts empty (immediate-start); add its own tasks by opening it. The linking
assignment behaves like any task — drag, dependencies — and its
status/progress/hours roll up from the child (see `child_project_rollup/1`).

The child **inherits the parent's `is_template` flag**: a sub-project added to
a template is itself a sub-template, and `create_project_from_template/2`
deep-clones the whole sub-template subtree into real child projects when the
parent template is instantiated.

Both inserts run in one transaction. Returns
`{:ok, %{child_project: Project.t(), assignment: Assignment.t()}}`.

Inline-only creation makes a cycle structurally impossible (the child is
brand-new and can't already be an ancestor), so no cycle guard runs here. A
future "link existing project as a sub-project" path MUST add an ancestor
check before assigning `child_project_uuid`.

# `create_task`

```elixir
@spec create_task(map()) ::
  {:ok, PhoenixKitProjects.Schemas.Task.t()} | {:error, Ecto.Changeset.t()}
```

Inserts a task and broadcasts `:task_created`.

# `delete_assignment`

```elixir
@spec delete_assignment(PhoenixKitProjects.Schemas.Assignment.t()) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()}
  | {:error, Ecto.Changeset.t() | term()}
```

Deletes an assignment and broadcasts `:assignment_deleted`.

For a **sub-project** linking row (`child_project_uuid` set, V127) this tears
the child project subtree down too — "removing the sub-project task removes
the sub-project", matching the boss's "same as a task" semantics. The linking
row is deleted first (the `child_project_uuid` FK is `ON DELETE RESTRICT`, so
the child can't be removed while it's still referenced), then the child tree
via `delete_project_tree_in_tx/1`. Both in one transaction.

# `delete_project`

```elixir
@spec delete_project(PhoenixKitProjects.Schemas.Project.t()) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()}
  | {:error, :still_a_subproject | term()}
```

Deletes a project and broadcasts `:project_deleted`.

Recursive over **sub-projects** (V127): any project embedded in `p`'s
timeline (and, transitively, in theirs) is torn down too. Deleting `p`
DB-cascades its own assignment rows (`project_uuid` is `ON DELETE CASCADE`),
including the sub-project linking rows — but the child projects those rows
pointed at would be left orphaned, so they're deleted explicitly here. The
whole subtree comes down in one transaction; `:project_deleted` is broadcast
for every project removed.

Refuses to delete a project that is itself **embedded as a sub-project** of
another project — its `child_project_uuid` FK is `ON DELETE RESTRICT`, so a
raw `repo().delete` would raise `Ecto.ConstraintError` rather than return a
tuple. Such a project is reached (and removed) through its parent's timeline,
or `detach_subproject/1` first; deleting it standalone returns
`{:error, :still_a_subproject}`.

# `delete_task`

```elixir
@spec delete_task(PhoenixKitProjects.Schemas.Task.t()) ::
  {:ok, PhoenixKitProjects.Schemas.Task.t()} | {:error, Ecto.Changeset.t()}
```

Deletes a task and broadcasts `:task_deleted`.

# `dependencies_met?`

```elixir
@spec dependencies_met?(uuid()) :: boolean()
```

Check if all dependencies of an assignment are done.

# `detach_subproject`

```elixir
@spec detach_subproject(PhoenixKitProjects.Schemas.Assignment.t()) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, term()}
```

Detaches a sub-project from its parent (V127) — the non-destructive inverse of
the cascade in `delete_assignment/1`. Deletes **only the linking assignment**,
leaving the child project + its whole subtree intact; it becomes a standalone
top-level project again. Recomputes the former parent (one fewer rolled-up
item).

# `flatten_closure`

```elixir
@spec flatten_closure(closure_node() | nil) :: %{
  optional(String.t()) =&gt; PhoenixKitProjects.Schemas.Task.t()
}
```

Flattens a `closure_node()` tree into a `%{task_uuid => %Task{}}` map.

Used by the save path to enumerate every task that *might* become an
assignment (before applying the user's exclusions). `Map` rather than
list because the same task can appear multiple times across branches
if two parents both depend on it — the map dedups by uuid.

# `get_assignment`

```elixir
@spec get_assignment(uuid()) :: PhoenixKitProjects.Schemas.Assignment.t() | nil
```

Fetches an assignment by uuid with related records preloaded, or `nil` if not found.

# `get_project`

```elixir
@spec get_project(uuid()) :: PhoenixKitProjects.Schemas.Project.t() | nil
```

Fetches a project by uuid, or `nil` if not found.

# `get_project!`

```elixir
@spec get_project!(uuid()) :: PhoenixKitProjects.Schemas.Project.t()
```

Fetches a project by uuid. Raises if not found.

# `get_project_with_assignee`

```elixir
@spec get_project_with_assignee(uuid()) ::
  PhoenixKitProjects.Schemas.Project.t() | nil
```

Fetches a project with its assignee associations preloaded (V128).

# `get_projects`

```elixir
@spec get_projects([uuid() | nil]) :: %{
  required(uuid()) =&gt; PhoenixKitProjects.Schemas.Project.t()
}
```

Fetches multiple projects by uuid in a single query, returning a
`%{uuid => Project.t()}` map. Missing uuids are silently dropped from
the result rather than raising — callers can detect them by checking
`Map.has_key?/2` or comparing the result's keys to the input list.

Avoids the per-row N+1 host apps would otherwise write to render a
list of records with linked projects (e.g. an orders index page
showing each row's project name). Nil + duplicate uuids in the input
are tolerated.

Returns an empty map for an empty list.

## Examples

    iex> %{name: name} = Projects.get_projects([uuid_a, uuid_b])[uuid_a]
    iex> name
    "Alpha"

    iex> Projects.get_projects([])
    %{}

# `get_task`

```elixir
@spec get_task(uuid()) :: PhoenixKitProjects.Schemas.Task.t() | nil
```

Fetches a task by uuid, or `nil` if not found.

# `get_task!`

```elixir
@spec get_task!(uuid()) :: PhoenixKitProjects.Schemas.Task.t()
```

Fetches a task by uuid. Raises if not found.

# `link_subproject`

```elixir
@spec link_subproject(uuid(), uuid()) ::
  {:ok,
   %{
     child_project: PhoenixKitProjects.Schemas.Project.t(),
     assignment: PhoenixKitProjects.Schemas.Assignment.t()
   }}
  | {:error,
     :not_found
     | :self_link
     | :kind_mismatch
     | :already_subproject
     | :would_create_cycle
     | Ecto.Changeset.t()}
```

Links an **existing** standalone project into `parent_project_uuid` as a
sub-project (V127) — the "nest this project" path, alongside the create-new
`create_subproject/2`. The child keeps its whole subtree, and drops off the
top-level list once linked.

Guards: the child must exist, not be the parent, match the parent's
`is_template`, not already be a sub-project (the single-parent unique index),
and not be an **ancestor** of the parent (which would create a cycle). Errors:
`:not_found`, `:self_link`, `:kind_mismatch`, `:already_subproject`,
`:would_create_cycle`.

# `list_active_projects`

```elixir
@spec list_active_projects() :: [PhoenixKitProjects.Schemas.Project.t()]
```

Running projects (started, not archived, not yet completed).

# `list_all_dependencies`

```elixir
@spec list_all_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Dependency.t()]
```

All dependencies across every assignment in a project (used when cloning templates).

# `list_assignments`

```elixir
@spec list_assignments(uuid()) :: [PhoenixKitProjects.Schemas.Assignment.t()]
```

Lists assignments within a project, ordered by position, with related records preloaded.

# `list_assignments_for_user`

```elixir
@spec list_assignments_for_user(uuid()) :: [PhoenixKitProjects.Schemas.Assignment.t()]
```

Assignments currently assigned to the given user's staff record.
Returns non-done assignments across all active projects, with project preloaded.

The `rescue [Postgrex.Error, DBConnection.ConnectionError, Ecto.QueryError]`
block at the bottom is **intentional**: a hard dep on
`phoenix_kit_staff` means a Staff outage (missing tables in early
install, sandbox-shutdown in tests, transient connection drop) would
otherwise take the Projects dashboard down. The rescue degrades
gracefully to "no assignments for this user" — the dashboard keeps
rendering for everyone else's data. Don't "clean it up" by
narrowing or removing.

# `list_dependencies`

```elixir
@spec list_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Dependency.t()]
```

Dependencies declared on a single assignment.

# `list_projects`

```elixir
@spec list_projects(keyword()) :: [PhoenixKitProjects.Schemas.Project.t()]
```

Lists projects.

Options:
  * `:archived` — `false` (default) hides archived; `true` shows only
    archived; `:all` returns both.
  * `:include_templates` — default `false`.

# `list_recently_completed_projects`

```elixir
@spec list_recently_completed_projects(pos_integer()) :: [
  PhoenixKitProjects.Schemas.Project.t()
]
```

Completed projects (all tasks done), most recently completed first.

# `list_setup_projects`

```elixir
@spec list_setup_projects() :: [PhoenixKitProjects.Schemas.Project.t()]
```

Projects not yet started, in setup (immediate mode, not scheduled).

# `list_task_dependencies`

```elixir
@spec list_task_dependencies(uuid()) :: [
  PhoenixKitProjects.Schemas.TaskDependency.t()
]
```

Template-level dependencies declared on the given task.

# `list_task_groups`

```elixir
@spec list_task_groups() :: %{
  trees: [closure_node()],
  standalone: [PhoenixKitProjects.Schemas.Task.t()]
}
```

Returns task-library "groups" — rooted dependency trees.

Each group has one root (a task that nothing else depends on,
i.e. has no incoming `depends_on_task_uuid` edge) plus its full
transitive closure of task-template deps. Tasks shared across
multiple roots **appear in each group** — duplication is the point
of the grouped view: it surfaces "this task is reused across N
workflows."

Tasks with no deps either way (no incoming and no outgoing edges)
are returned in `:standalone` instead of being padded out as
one-task groups, so the group view doesn't look like 50 tiny cards
for unrelated singletons.

Returns `%{trees: [closure_node()], standalone: [Task.t()]}` where
each `closure_node()` has `:task`, `:children`, `:cycle?`,
`:already_in_project?` (the last is `false` here — this view isn't
scoped to a project; the field is kept for shape compatibility with
`task_closure/2`).

# `list_tasks`

```elixir
@spec list_tasks(keyword()) :: [PhoenixKitProjects.Schemas.Task.t()]
```

Lists all task-library entries, preloaded with defaults.

Order: `position ASC, inserted_at ASC`. Date-added is the secondary
sort (NOT title) so renaming a task doesn't shuffle it in the list
— a complaint we got from the boss with the prior `title`-secondary
ordering. After a reorder, dragged tasks claim `1..N` and appear
above any still-zero ones; among the still-zero tasks, creation
order wins.

# `list_tasks_with_deps`

Returns the flat task list with a `%{task_uuid => [Task]}` map of
the directed `TaskDependency` edges (`task → depends_on_task`) for
badge rendering. Used by the task-library list view (the default
view mode).

# `list_templates`

```elixir
@spec list_templates() :: [PhoenixKitProjects.Schemas.Project.t()]
```

Lists projects that are templates, in `position`-then-date-added
order. Date-added (not name) is the secondary sort so renaming a
template doesn't shuffle it in the list. After a manual drag,
templates with explicit positions land at the top in the user's
order; un-touched templates fall to the bottom by date-added.
`uuid` tiebreaks within the same `inserted_at` second.

# `list_upcoming_projects`

```elixir
@spec list_upcoming_projects() :: [PhoenixKitProjects.Schemas.Project.t()]
```

Scheduled projects waiting to start.

# `next_assignment_position`

```elixir
@spec next_assignment_position(uuid() | nil) :: integer()
```

Next available `position` for a new assignment within the given
project — one past the current per-project max, falling back to
`1` on an empty project.

# `next_project_position`

```elixir
@spec next_project_position(boolean()) :: integer()
```

Next available `position` within the given `is_template` scope —
one past the per-bucket max, falling back to `1` on an empty
bucket. Projects (`is_template = false`) and templates (`true`)
share the column but order independently.

# `next_task_position`

```elixir
@spec next_task_position() :: integer()
```

Next available `position` for a new task — one past the current
max, falling back to `1` on an empty table. New tasks should be
inserted with this value so they land at the bottom of the
user's manual order.

# `project_summaries`

```elixir
@spec project_summaries([PhoenixKitProjects.Schemas.Project.t()]) :: [map()]
```

Batched summaries for many projects — loads all their assignments in
one query, then groups in memory. Preserves the input project order.

# `project_summary`

```elixir
@spec project_summary(PhoenixKitProjects.Schemas.Project.t()) :: map() | nil
```

Summary of an active project for the overview dashboard.
Returns a map with progress stats.

# `project_tree_summary`

```elixir
@spec project_tree_summary(PhoenixKitProjects.Schemas.Project.t()) :: map()
```

Recursive **tree summary** for a project: its own task breakdown plus a nested
summary for each embedded sub-project, all the way down (V127). Powers the
hierarchical dashboard card.

Each node has the shape:

    %{
      project: %Project{},
      task_total: integer,            # direct tasks (assignments with a task)
      task_done / task_in_progress / task_todo: integer,
      subproject_count: integer,      # direct sub-projects
      total: integer,                 # task_total + subproject_count
      progress_pct: integer,          # tasks (done=100) + non-empty children
      total_hours: float,
      planned_end: DateTime.t() | nil,
      children: [node, ...]           # one per sub-project, same shape
    }

`total` + `progress_pct` + `planned_end` are kept so the existing dashboard
tier/sort helpers read a node like the flat `project_summaries` map. The
progress average counts each real task (done = 100, else its slider) and each
**non-empty** child's rolled progress — empty sub-projects are neutral.

One `list_assignments/1` per node; depth is bounded by the (acyclic) tree, so
it's fine for the handful of running projects on the dashboard.

# `recompute_project_completion`

```elixir
@spec recompute_project_completion(uuid()) ::
  :ok
  | {:completed, PhoenixKitProjects.Schemas.Project.t()}
  | {:reopened, PhoenixKitProjects.Schemas.Project.t()}
  | {:unchanged, PhoenixKitProjects.Schemas.Project.t()}
  | {:error, term()}
```

Called after an assignment status change. Checks whether all assignments
in the project are done. If so, sets `completed_at`. If not (e.g., a task
was reopened), clears it. Returns the (possibly updated) project.

# `remove_dependency`

```elixir
@spec remove_dependency(uuid(), uuid()) ::
  {:ok, PhoenixKitProjects.Schemas.Dependency.t()}
  | {:error, :not_found | Ecto.Changeset.t()}
```

Removes an assignment-level dependency and broadcasts `:dependency_removed`.

# `remove_task_dependency`

```elixir
@spec remove_task_dependency(uuid(), uuid()) ::
  {:ok, PhoenixKitProjects.Schemas.TaskDependency.t()}
  | {:error, :not_found | Ecto.Changeset.t()}
```

Removes a template-level dependency. Returns `{:error, :not_found}` if missing.

# `reopen_assignment`

```elixir
@spec reopen_assignment(PhoenixKitProjects.Schemas.Assignment.t()) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()}
  | {:error, Ecto.Changeset.t()}
```

Reverts an assignment to `todo` and clears its completion fields.

# `reorder_assignments`

```elixir
@spec reorder_assignments(uuid(), [uuid()], keyword()) ::
  :ok | {:error, :too_many_uuids | :not_in_project | term()}
```

Re-indexes the supplied assignment uuids into positions `1..N`
within the given project. Used by the project-show timeline DnD
handler.

All uuids must belong to `project_uuid` — UUIDs that exist in
another project (or don't exist) abort the whole batch with
`{:error, :not_in_project}`. Duplicates in the input are deduped
last-write-wins.

Two-pass write inside a transaction (negatives → positives) so
any future unique index on `(project_uuid, position)` would be
honoured. Returns `:ok` / `{:error, :too_many_uuids}` /
`{:error, :not_in_project}` / `{:error, term()}`. Audit rows are
written for every outcome.

The `@reorder_max_uuids` cap is checked against the **raw input
list length**, before dedup — a payload over the cap signals a
misbehaving client (real users can't drag 1000+ rows in one
batched event), so the rejection is a guard, not a real-user
constraint. Same shape as `reorder_tasks/2`.

# `reorder_projects`

```elixir
@spec reorder_projects(
  [uuid()],
  keyword()
) :: :ok | {:error, :too_many_uuids | :wrong_scope}
```

Re-indexes the supplied project uuids into positions `1..N`.
Used by the project list-view DnD handler.

Scope: `is_template = false`. UUIDs that resolve to templates (or
to no row at all) abort the whole batch with
`{:error, :wrong_scope}`. Duplicates dedup last-write-wins.
Two-pass write (negatives → positives) inside a transaction.

The `@reorder_max_uuids` cap is checked against the **raw input
list length**, before dedup — a payload over the cap signals a
misbehaving client (real users can't drag 1000+ rows in one
batched event), so the rejection is a guard, not a real-user
constraint. Same shape as `reorder_tasks/2`.

# `reorder_projects_by`

```elixir
@spec reorder_projects_by(reorder_strategy(), :all | [uuid()], keyword()) ::
  :ok | {:error, :wrong_scope | :too_many_uuids | term()}
```

Bulk-reorder projects by a sort strategy.

  * `scope = :all` — load every non-template project, sort by
    strategy, write contiguous 1..N positions via the existing
    `Reorder.reorder` primitive.
  * `scope = [uuid, ...]` — load only those projects, validate they
    are all non-templates, permute them within the slots they
    already occupy (their existing `position` values). Untouched
    projects are not re-indexed.

Returns `:ok`, `{:error, :wrong_scope}` if any uuid maps to a
template or unknown row, `{:error, :too_many_uuids}` past the cap,
or `{:error, term}` on DB failure. Audit row logged on success.

# `reorder_tasks`

```elixir
@spec reorder_tasks(
  [uuid()],
  keyword()
) :: :ok | {:error, :too_many_uuids | term()}
```

Re-indexes the supplied task uuids into positions `1..N`. Used by
the task-library list-view DnD handler.

No parent scope — the task library is a flat collection. UUIDs not
found in the table are dropped silently (the LV may have a stale
view of the page when the user dragged). Duplicates in the input
list are deduped last-write-wins.

Two-pass write inside a transaction: pass 1 stamps `position =
-idx`, pass 2 stamps `position = idx`. Sidesteps any future unique
index on `position` and stays atomic.

The `@reorder_max_uuids` cap is checked against the **raw input
list length**, before dedup — a payload over the cap signals a
misbehaving client (real users can't drag 1000+ rows in one
batched event), so the rejection is a guard, not a real-user
constraint.

Returns `:ok` on success, `{:error, :too_many_uuids}` past the cap,
or `{:error, reason}` on a DB failure. Audit rows are written for
every outcome (success carries the count + first-uuid; rejection
paths log via `log_reorder_rejected/3` shape).

# `reorder_tasks_by`

```elixir
@spec reorder_tasks_by(reorder_strategy(), :all | [uuid()], keyword()) ::
  :ok | {:error, :too_many_uuids | term()}
```

Bulk-reorder tasks by strategy. Strategy `:name_asc` / `:name_desc`
sorts by `title` (tasks don't have a `name` field).

# `reorder_templates`

```elixir
@spec reorder_templates(
  [uuid()],
  keyword()
) :: :ok | {:error, :too_many_uuids | :wrong_scope}
```

Same as `reorder_projects/2` but scoped to `is_template = true`.
Audit rows use `template.reordered` so the activity feed
distinguishes the two.

# `reorder_templates_by`

```elixir
@spec reorder_templates_by(reorder_strategy(), :all | [uuid()], keyword()) ::
  :ok | {:error, :wrong_scope | :too_many_uuids | term()}
```

Bulk-reorder templates by strategy. Same shape as
`reorder_projects_by/3` but scoped to `is_template = true`.

# `scoped_assignments`

```elixir
@spec scoped_assignments([uuid()], uuid()) :: [
  PhoenixKitProjects.Schemas.Assignment.t()
]
```

Fetches the assignments among `uuids` that belong to `project_uuid`, in a
single query. Used to authorize multi-endpoint operations (e.g. dependency
removal, which must confirm BOTH endpoints live in the viewed project)
without one round-trip per uuid. No preloads — callers only check scope.

# `set_current_status_slug`

```elixir
@spec set_current_status_slug(
  PhoenixKitProjects.Schemas.Project.t(),
  String.t() | nil
) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
```

Sets the project's current workflow-status slug and broadcasts
`:project_status_changed`. Uses the dedicated, server-owned
`Project.current_status_changeset/2` (never the form changeset) — go
through `PhoenixKitProjects.Statuses.set_current_status/3` rather than
calling this directly, so the slug is validated against the project's
status list first. `nil` clears the selection.

# `start_project`

```elixir
@spec start_project(PhoenixKitProjects.Schemas.Project.t(), DateTime.t() | nil) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
```

Stamps `started_at` on the project and broadcasts `:project_started`.

`started_at` defaults to `DateTime.utc_now()`. Pass a `%DateTime{}` to
backdate (the user picked an earlier date in the start-project modal)
or future-date (the project is being prepared but the actual start
is later than today).

# `task_closure`

```elixir
@spec task_closure(uuid(), uuid()) :: closure_node() | nil
```

Builds the task-template dependency closure rooted at `root_task_uuid`.

Traverses `TaskDependency` edges (`task → depends_on_task`) outward
from the root, returning a tree the UI can render with checkboxes
for pruning. `project_uuid` is used to mark which nodes already have
an assignment in the target project — those are skipped on save
(the assignment-form's "drag in closure" flow won't duplicate them).

Returns `nil` if `root_task_uuid` doesn't resolve to a task. Cycles
in the template graph are detected and short-circuited; the cycle
node has `cycle?: true` and no children.

# `unarchive_project`

```elixir
@spec unarchive_project(PhoenixKitProjects.Schemas.Project.t()) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
```

Restores an archived project by clearing `archived_at`.

# `update_assignment_form`

```elixir
@spec update_assignment_form(
  PhoenixKitProjects.Schemas.Assignment.t(),
  map(),
  keyword()
) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()}
  | {:error, Ecto.Changeset.t()}
```

Form-safe update for user-submitted attrs. Does NOT apply completion
fields (`completed_by_uuid`, `completed_at`) even if they appear in
`attrs` — they are silently dropped by `Assignment.changeset/2`.

Use `update_assignment_status/2` instead when updating from server
code that legitimately owns those fields (completion transitions,
progress updates).

The `_form` suffix is a deliberate smell: if you reach for this
function, double-check whether your caller is really a form handler.

# `update_assignment_status`

```elixir
@spec update_assignment_status(PhoenixKitProjects.Schemas.Assignment.t(), map()) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()}
  | {:error, Ecto.Changeset.t()}
```

Server-trusted update that additionally casts `completed_by_uuid` and
`completed_at`. Only call from server code (never pass raw form attrs),
since the caller vouches for those fields.

# `update_project`

```elixir
@spec update_project(PhoenixKitProjects.Schemas.Project.t(), map(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
```

Updates a project and broadcasts `:project_updated`.

Pass `broadcast: false` to skip the broadcast — used by callers that run
the update inside a larger transaction (e.g.
`PhoenixKitProjects.Statuses.update_project_with_statuses/2`) and must
defer the broadcast until after commit, so a later rollback can't leak a
phantom `:project_updated` to subscribers. Such callers fire the event
themselves via `broadcast_project_updated/1` once the transaction commits.

# `update_task`

```elixir
@spec update_task(PhoenixKitProjects.Schemas.Task.t(), map(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.Task.t()} | {:error, Ecto.Changeset.t()}
```

Updates a task and broadcasts `:task_updated`.

Pass `broadcast: false` to skip the broadcast — used by callers that write
inside their own transaction (e.g. the AI-translation adapter) and don't
want a `:task_updated` to fire before commit / masquerade as a user edit.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
