defmodule PhoenixKitProjects.Web.ProjectShowLive do
@moduledoc """
Show a project with a vertical timeline of assignments.
Supports inline status changes, duration editing, dependency
management, and tracks who completed each task.
## List / Timeline tabs
The page has two views — the vertical task list and the embedded
`ProjectGanttLive` Timeline — toggled by tabs under the shared header.
Switching is instant (an assign flip) and the gantt is lazily mounted on first
open. **The tabs render in every mount context, embedded `live_render` renders
included** (only templates stay list-only). Note this means the Timeline tab is
a nested `live_render` of `ProjectGanttLive`, which is itself a nested LV when
the show page is embedded — deliberate, server-rendered, so the chart shows
even before any JS loads.
Keeping the URL in sync (the trailing `/gantt` segment + browser back/forward)
is **optional and off by default**: it's only on when `@tab_url_sync?` is true,
which the router-mounted standalone admin page sets (so its deep-linking keeps
working) and an embed can opt into via `session["tab_url_sync"]`. When on, the
`ProjectTabsUrl` JS hook pushes/reads `history` state. With it off the tabs
still switch fully — they just never touch the host page's URL (the right
default for an embed, which must not rewrite the host's address bar).
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitProjects.Gettext
use PhoenixKitProjects.Web.Components
# Forwards the comment composer's {:leaf_changed, …} process message into
# CommentsComponent.forward_leaf_event/2 via a :handle_info lifecycle hook
# (halts only :leaf_changed, passes everything else through); without it
# "Post Comment" silently submits empty content. comments is a hard dep here.
use PhoenixKitComments.Embed
alias PhoenixKitProjects.{Activity, L10n, Paths, Projects, Statuses}
alias PhoenixKitProjects.PubSub, as: ProjectsPubSub
alias PhoenixKitProjects.Schemas.{Assignment, Project}
alias PhoenixKitProjects.Schemas.Task, as: TaskSchema
alias PhoenixKitProjects.Web.Components.AssignmentStatusBadge
alias PhoenixKitProjects.Web.Helpers, as: WebHelpers
# Schedule/assignee helpers shared with ProjectGanttLive — imported so the
# template's bare calls resolve. See PhoenixKitProjects.Web.Helpers.
import PhoenixKitProjects.Web.Helpers,
only: [assignee_label: 1, task_counts_weekends?: 2, assignment_hours: 2]
require Logger
# Default wrapper class for the standalone admin page. Embedders can
# override via `live_render(... session: %{"wrapper_class" => "..."})`
# to drop `mx-auto max-w-4xl` and fill a wider host layout.
@default_wrapper_class "flex flex-col w-full px-4 py-6 gap-4"
# Embedded entry: when nested via `live_render`, params arrives as
# `:not_mounted_at_router` and `session` carries the project id (plus
# any `wrapper_class` override). Delegate to the router clause so the
# mount logic stays single-sourced.
@impl true
def mount(:not_mounted_at_router, %{"id" => id} = session, socket) do
WebHelpers.maybe_put_locale(session)
# Reuse the router mount, then adjust for the embed context. The List/Timeline
# tabs DO render in embeds now (the gantt is a nested `live_render`, which is
# fine — it's server-rendered). Two things differ from the router mount:
# * `router_mounted?: false` — purely informational now (a few comments key
# off it); the tab bar no longer gates on it.
# * `tab_url_sync?` — OFF by default in an embed (an embed must not rewrite
# the host's URL); a host can opt back in with `session["tab_url_sync"]`.
# `live_action` is nil here, so `active_tab` already defaults to `:list`.
{:ok, socket} = mount(%{"id" => id}, session, socket)
{:ok,
assign(socket,
router_mounted?: false,
gantt_mounted?: false,
# Strict `== true` so only a real boolean opt-in turns it on (a stray
# string would otherwise read as truthy and re-enable URL rewriting).
tab_url_sync?: Map.get(session, "tab_url_sync", false) == true
)}
end
# Fail-closed: emit-session lacking `"id"` lands here. Without this
# clause the mount/3 dispatch raises `FunctionClauseError`. Render
# placeholders + flash + close so the host pops the modal.
#
# `maybe_put_locale/1` first so the "Project not found." flash
# renders in the host's locale — matches every other LV's mount/3
# contract and avoids an English flash on a misrouted modal.
def mount(:not_mounted_at_router, session, socket) do
WebHelpers.maybe_put_locale(session)
socket =
socket
|> WebHelpers.assign_embed_state(session)
|> WebHelpers.assign_embed_user(session)
{:ok,
socket
|> assign(
page_title: "",
project: %Project{},
is_template: false,
wrapper_class: Map.get(session, "wrapper_class", @default_wrapper_class),
router_mounted?: false,
tab_url_sync?: false,
active_tab: :list,
gantt_mounted?: false,
assignments: [],
deps_by_assignment: %{},
total_tasks: 0,
done_tasks: 0,
progress_pct: 0,
schedule: nil,
editing_duration_uuid: nil,
start_modal_open: false,
start_form: to_form(%{"start_at" => default_start_at_local()}),
comments_resource: nil,
comments_enabled: false,
project_comment_count: 0,
assignment_comment_counts: %{},
statuses_available: false,
current_status: nil,
status_options: [],
expanded_subprojects: MapSet.new(),
subproject_summaries: %{},
subproject_child_tasks: %{}
)
|> put_flash(:error, gettext("Project not found."))
|> WebHelpers.close_or_navigate(Paths.projects())}
end
def mount(%{"id" => id}, session, socket) do
# Locale first so any error flashes / placeholders render in the
# right language. Embed state second so the not-found path can
# honor emit mode (broadcasting `:closed` instead of push_navigate).
# Embed user third: when this LV is rendered via `live_render` the
# `:phoenix_kit_ensure_admin` on_mount hook never runs, so the host
# supplies the current user via `session["current_user_uuid"]`. On the
# router path the hook already set the scope and this is a no-op.
WebHelpers.maybe_put_locale(session)
socket =
socket
|> WebHelpers.assign_embed_state(session)
|> WebHelpers.assign_embed_user(session)
# `get_project/1` stays in mount/3 because the not-found path
# has to redirect before render, and the per-project PubSub
# topic needs the project.uuid to subscribe to. The heavier
# assignment/comment loads sit at the tail of mount/3 (not
# `handle_params/3`) because Phoenix LiveView refuses to mount a
# LV that exports `handle_params/3` outside a router live route,
# which would block embedding via `live_render`.
case Projects.get_project_with_assignee(id) do
nil ->
{:ok,
socket
|> assign(
page_title: "",
project: %Project{},
is_template: false,
wrapper_class: @default_wrapper_class,
router_mounted?: false,
# Must be assigned: the tab bar now renders on `not @is_template`
# (true here), so the render reads `@tab_url_sync?`. Router-mount
# context → true (matches the success branch); the embedded wrapper
# overrides it to the session value when this path is reached via an
# off-router mount with an unknown id.
tab_url_sync?: true,
active_tab: :list,
gantt_mounted?: false,
assignments: [],
deps_by_assignment: %{},
total_tasks: 0,
done_tasks: 0,
progress_pct: 0,
schedule: nil,
editing_duration_uuid: nil,
start_modal_open: false,
start_form: to_form(%{"start_at" => default_start_at_local()}),
comments_resource: nil,
comments_enabled: false,
project_comment_count: 0,
assignment_comment_counts: %{},
statuses_available: false,
current_status: nil,
status_options: [],
expanded_subprojects: MapSet.new(),
subproject_summaries: %{},
subproject_child_tasks: %{}
)
|> put_flash(:error, gettext("Project not found."))
|> WebHelpers.close_or_navigate(Paths.projects())}
project ->
if connected?(socket) do
# Per-project topic covers assignment/dependency events for this
# project; the tasks topic covers library-level task renames so
# the visible assignment rows don't go stale.
ProjectsPubSub.subscribe(ProjectsPubSub.topic_project(project.uuid))
ProjectsPubSub.subscribe(ProjectsPubSub.topic_tasks())
end
is_template = project.is_template
lang = L10n.current_content_lang()
wrapper_class = Map.get(session, "wrapper_class", @default_wrapper_class)
# Which tab the page opens on, straight from the route's live_action
# (`/list/:id/gantt` → `:gantt`, everything else → `:list`). Server-side
# so a direct/bookmarked `/gantt` load renders the gantt before any JS.
# Templates have no tabs/gantt (both are `not @is_template`), so a template
# uuid reached via the `/list/:id/gantt` route falls back to the list —
# otherwise both the list and the gantt would render hidden (blank page).
active_tab = tab_for_action(socket, is_template)
# Resolve the workflow-status list once (read-only — nothing is
# provisioned or seeded here; an unset shared default simply yields
# an empty list). `current_status` is derived from the same list so
# we don't resolve twice.
statuses_available = Statuses.available?()
status_options = if statuses_available, do: Statuses.statuses_for(project), else: []
current_status =
Enum.find(status_options, &(&1.slug == project.current_status_slug))
socket =
socket
|> assign(
page_title: Project.localized_name(project, lang),
statuses_available: statuses_available,
status_options: status_options,
current_status: current_status,
project: project,
is_template: is_template,
wrapper_class: wrapper_class,
# Tab state. The tab bar renders in every context now (only
# templates stay list-only); `router_mounted?` is kept as an
# informational flag a few comments key off. URL sync is ON here —
# this is the standalone admin page, which owns a real `/gantt` URL
# to deep-link; embeds default it off (see the embed mount clause).
# The gantt is lazy-mounted: it only `live_render`s once its tab is
# first opened, then stays mounted so its zoom/expand survive.
router_mounted?: true,
tab_url_sync?: true,
active_tab: active_tab,
gantt_mounted?: active_tab == :gantt,
editing_duration_uuid: nil,
start_modal_open: false,
start_form: to_form(%{"start_at" => default_start_at_local()}),
# Comments drawer state. `comments_resource` is `nil` when
# closed; a `%{type, uuid, title}` map when open. The
# `CommentsComponent` is keyed on `{type, uuid}` so opening
# different resources doesn't reuse stale state.
comments_resource: nil,
comments_enabled: comments_available?(),
project_comment_count: 0,
assignment_comment_counts: %{},
# Skeleton defaults overwritten by the load_* helpers below;
# they keep the assigns coherent if either helper short-circuits.
assignments: [],
deps_by_assignment: %{},
total_tasks: 0,
done_tasks: 0,
progress_pct: 0,
schedule: nil,
# Sub-project UI state (V127). `expanded_subprojects` holds the
# linking-assignment uuids whose child task list is revealed;
# `subproject_*` maps are keyed by linking-assignment uuid and
# filled lazily (summaries in load_assignments, child tasks on
# first expand).
expanded_subprojects: MapSet.new(),
subproject_summaries: %{},
subproject_child_tasks: %{}
)
|> WebHelpers.attach_open_embed_hook()
{:ok, socket |> load_assignments() |> load_comment_counts()}
end
end
# ── PubSub reactivity ─────────────────────────────────────────
# Catch-all handle_info avoids crashes on unexpected messages.
@impl true
def handle_info({:projects, event, _payload}, socket)
when event in [
:assignment_created,
:assignment_updated,
:assignment_deleted,
:dependency_added,
:dependency_removed,
# Task-library renames change displayed assignment titles.
:task_updated,
:task_deleted
] do
{:noreply, load_assignments(socket)}
end
def handle_info({:projects, event, _payload}, socket)
when event in [
:project_updated,
:project_completed,
:project_reopened,
:project_started,
:project_status_changed
] do
# Must re-preload the assignee: the render derefs @project.assigned_person.user
# (assignee_label/1), so a plain get_project/1 here leaves it NotLoaded and the
# re-render crashes when the project has an assignee.
case Projects.get_project_with_assignee(socket.assigns.project.uuid) do
nil ->
{:noreply, socket}
p ->
{:noreply, socket |> assign(project: p) |> refresh_status_state() |> load_assignments()}
end
end
def handle_info({:projects, :project_deleted, _payload}, socket) do
{:noreply,
socket
|> put_flash(:info, gettext("This project was deleted."))
|> WebHelpers.close_or_navigate(Paths.projects())}
end
# `CommentsComponent` notifies its parent LV after every create /
# delete so the button badges can refresh without an extra round
# trip. We reload the full count map regardless of which resource
# changed — both project and assignment counts cost a single
# query each, and the message carries an `action` (`:created |
# :deleted`) that we don't need to discriminate on here.
def handle_info({:comments_updated, _payload}, socket) do
{:noreply, load_comment_counts(socket)}
end
def handle_info(msg, socket) do
Logger.debug("[ProjectShowLive] unexpected handle_info: #{inspect(msg)}")
{:noreply, socket}
end
defp load_assignments(socket) do
project_uuid = socket.assigns.project.uuid
assignments = Projects.list_assignments(project_uuid)
expanded = socket.assigns[:expanded_subprojects] || MapSet.new()
expanded_subs =
Enum.filter(
assignments,
&(Assignment.subproject?(&1) and MapSet.member?(expanded, &1.uuid))
)
# Parent deps PLUS deps for every expanded child project — the inset child
# tasks render through the same `<.task_body>`, which reads
# `deps_by_assignment` keyed by the (globally-unique) assignment uuid. Load
# parent-only and a child task's dependency badges never render.
deps_by_assignment =
[project_uuid | Enum.map(expanded_subs, & &1.child_project_uuid)]
|> Enum.flat_map(&Projects.list_all_dependencies/1)
|> Enum.group_by(& &1.assignment_uuid)
total = length(assignments)
done = Enum.count(assignments, &(&1.status == "done"))
# Rollup is the average of all assignments' `progress_pct` values
# — a half-finished task contributes 50%, not 0%. Auto-completion
# (`recompute_project_completion/1`) stays binary: a project only
# auto-flags as completed when every assignment is `status: "done"`,
# not when the rollup happens to hit 100% by other means.
progress_sum = Enum.reduce(assignments, 0, &(&1.progress_pct + &2))
schedule = calculate_schedule(socket.assigns.project, assignments)
# Sub-project rollup display data (V127): one batched summary query over
# all embedded child projects, keyed by the linking-assignment uuid so the
# row can show the child's task count + progress. Expanded rows also get a
# refreshed child task list so a change in the child reflects immediately.
subproject_summaries = load_subproject_summaries(assignments)
subproject_child_tasks =
Map.new(expanded_subs, fn a -> {a.uuid, Projects.list_assignments(a.child_project_uuid)} end)
# Mirror `Projects.project_summaries/1`: an EMPTY sub-project (its child has
# no assignments of its own yet) is neutral in the progress average — keep
# it in `total` for the task-count display but drop it from the progress
# denominator so it doesn't drag the project's % down before it holds any
# work. Without this the header % here and the dashboard card disagree.
empty_subs =
Enum.count(assignments, fn a ->
Assignment.subproject?(a) and empty_subproject?(Map.get(subproject_summaries, a.uuid))
end)
progress_total = max(total - empty_subs, 0)
assign(socket,
assignments: assignments,
deps_by_assignment: deps_by_assignment,
total_tasks: total,
done_tasks: done,
progress_pct: if(progress_total > 0, do: round(progress_sum / progress_total), else: 0),
schedule: schedule,
subproject_summaries: subproject_summaries,
subproject_child_tasks: subproject_child_tasks
)
end
# `%{linking_assignment_uuid => child_summary}` for every sub-project row.
defp load_subproject_summaries(assignments) do
subs = Enum.filter(assignments, &Assignment.subproject?/1)
children = Enum.map(subs, & &1.child_project) |> Enum.reject(&is_nil/1)
summaries_by_child =
children
|> Projects.project_summaries()
|> Map.new(fn s -> {s.project.uuid, s} end)
Map.new(subs, fn a -> {a.uuid, Map.get(summaries_by_child, a.child_project_uuid)} end)
end
# A sub-project whose child has no assignments of its own — `total == 0` in
# the child's summary (matches `project_summaries/1`'s "empty" definition). A
# missing summary (nil) is treated as empty too: no child rows to summarize.
defp empty_subproject?(%{total: 0}), do: true
defp empty_subproject?(nil), do: true
defp empty_subproject?(_), do: false
# Recomputes the workflow-status list + current selection from the
# (possibly just-reloaded) project. Re-resolves against the live catalog
# pre-start and the cemented local rows post-start, so a `:project_started`
# broadcast naturally flips the source. No-op when entities is unavailable.
defp refresh_status_state(socket) do
if socket.assigns.statuses_available do
project = socket.assigns.project
options = Statuses.statuses_for(project)
current = Enum.find(options, &(&1.slug == project.current_status_slug))
assign(socket, status_options: options, current_status: current)
else
socket
end
end
# Updates the assignment, logs the activity on success, and returns a tuple
# `{:ok, socket}` for the maybe_sync_and_reload pipeline.
# The `complete`/`start_task`/`reopen`/`update_progress` handlers go
# through the server-trusted `update_assignment_status/2` because their
# attrs include `completed_by_uuid` / `completed_at`, which the
# form-safe `update_assignment_form/2` intentionally drops.
defp update_assignment_with_activity(socket, a, attrs, action_name, opts) do
case Projects.update_assignment_status(a, attrs) do
{:ok, _} ->
Activity.log(action_name,
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a.uuid,
metadata: Keyword.get(opts, :metadata, %{})
)
recompute_owning_subproject(socket, a)
{:ok, socket}
{:error, cs} ->
Activity.log_failed(action_name,
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a.uuid,
metadata: Keyword.get(opts, :metadata, %{})
)
{:error, socket, error_summary(cs, gettext("Could not update task."))}
end
end
defp maybe_sync_and_reload({:ok, socket}) do
{:noreply, socket |> sync_project_completion() |> load_assignments()}
end
defp maybe_sync_and_reload({:error, socket, msg}) do
{:noreply, put_flash(socket, :error, msg)}
end
# Translates Ecto validator messages through the gettext "errors"
# domain — Ecto emits English literals like `"is invalid"` /
# `"must be greater than 0"` from `validate_*` plus interpolation
# bindings; `Gettext.dngettext/6` is the canonical translator for
# those (matches the Phoenix scaffolding pattern). Without this,
# the inline error summary (e.g. on a failed `complete` from a
# validation-rejected status transition) renders English regardless
# of the user's locale — Phase 1 PR #1 review item #15, deferred
# then to Phase 2 C3 + closed in this re-validation batch.
#
# Named `translate_validator_error/1` (not `translate_error/1`) to
# avoid shadowing `PhoenixKitWeb.Components.Core.Input.translate_error/1`
# which is auto-imported by `use PhoenixKitWeb, :live_view`.
defp error_summary(%Ecto.Changeset{errors: errors}, fallback) do
case errors do
[] ->
fallback
errs ->
Enum.map_join(errs, ", ", fn {k, {msg, opts}} ->
"#{humanize_field(k)}: #{translate_validator_error({msg, opts})}"
end)
end
end
# Renders an Ecto field name like `:estimated_duration` as
# `"Estimated duration"` for the cross-field flash summary. The
# per-field input component already humanizes its own label, so this
# only matters for the multi-error fallback.
defp humanize_field(field) do
field
|> to_string()
|> String.replace("_", " ")
|> String.capitalize()
end
defp translate_validator_error({msg, opts}) do
if count = opts[:count] do
Gettext.dngettext(PhoenixKitWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(PhoenixKitWeb.Gettext, "errors", msg, opts)
end
end
# When a mutated assignment belongs to an embedded sub-project (its row is
# rendered inset in the expand panel, V127), recompute that child's project so
# the rollup climbs to the shown project's linking row. A no-op for normal
# tasks (`sync_project_completion/1` already recomputes the shown project).
defp recompute_owning_subproject(socket, %{project_uuid: pid}) do
if pid != socket.assigns.project.uuid do
Projects.recompute_project_completion(pid)
end
:ok
end
defp sync_project_completion(socket) do
case Projects.recompute_project_completion(socket.assigns.project.uuid) do
{:completed, project} ->
Activity.log("projects.project_completed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"name" => project.name}
)
socket
|> assign(project: project)
|> put_flash(:info, gettext("🎉 All tasks done — project completed!"))
{:reopened, project} ->
Activity.log("projects.project_reopened",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"name" => project.name}
)
assign(socket, project: project)
{:unchanged, _} ->
socket
_ ->
socket
end
end
# Looks up an assignment and verifies it belongs to the project the
# user is currently viewing. Prevents an admin on project A from
# mutating assignments in project B by crafting event params.
# The single task-card row, shared by the main timeline and the inset
# sub-project task lists (V127) — "no reason to make them different".
# `@draggable` gates the sortable wiring + drag handle (off for inset child
# tasks, which reorder on their own page). Child-task events work because
# `scoped_assignment/2` accepts any displayed assignment and
# `update_assignment_with_activity/5` recomputes the assignment's own project.
attr(:a, :map, required: true)
attr(:draggable, :boolean, default: true)
attr(:is_template, :boolean, required: true)
attr(:project, :map, required: true)
attr(:embed_mode, :atom, required: true)
attr(:editing_duration_uuid, :string, default: nil)
attr(:comments_enabled, :boolean, default: false)
attr(:assignment_comment_counts, :map, default: %{})
attr(:deps_by_assignment, :map, default: %{})
defp task_body(assigns) do
~H"""
<%= for dep <- deps do %>
<.icon name="hero-arrow-right-circle" class="w-3 h-3" />
{gettext("depends on:")} {Assignment.label(dep.depends_on, L10n.current_content_lang())}
<% end %>
"""
end
# Accepts any assignment currently displayed on the page: one belonging to the
# shown project, or a task inside an expanded sub-project (V127). Child-task
# rows render the same `task_card` and their events flow through here.
defp scoped_assignment(socket, uuid) do
case Projects.get_assignment(uuid) do
%{project_uuid: pid} = a when pid == socket.assigns.project.uuid ->
a
%{uuid: id} = a ->
if displayed_child_task?(socket, id), do: a, else: nil
_ ->
nil
end
end
defp displayed_child_task?(socket, uuid) do
socket.assigns
|> Map.get(:subproject_child_tasks, %{})
|> Map.values()
|> Enum.any?(fn tasks -> Enum.any?(tasks, &(&1.uuid == uuid)) end)
end
# ── Events ──────────────────────────────────────────────────────
# Switch the List/Timeline tab. Instant (an assign flip, no navigation) and
# the gantt's nested LV stays mounted, so its zoom/expand survive. The gantt
# is lazy-mounted the first time its tab opens and never unmounted. We push
# the URL change to the `ProjectTabsUrl` hook — except when URL sync is off
# (the default for embeds), or when the switch itself CAME from the URL
# (browser back/forward), to avoid a push/popstate loop. With sync off the
# `ProjectTabsUrl` hook isn't even attached, so the event would no-op anyway;
# gating it server-side keeps the intent explicit.
@impl true
def handle_event("switch_tab", %{"tab" => tab} = params, socket) do
active = if tab == "gantt", do: :gantt, else: :list
socket =
socket
|> assign(active_tab: active)
|> assign(gantt_mounted?: socket.assigns.gantt_mounted? or active == :gantt)
socket =
if not socket.assigns.tab_url_sync? or params["source"] == "history",
do: socket,
else: push_event(socket, "project_tab_url", %{tab: tab})
{:noreply, socket}
end
def handle_event("complete", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
attrs = %{
status: "done",
progress_pct: 100,
completed_by_uuid: Activity.actor_uuid(socket),
completed_at: DateTime.utc_now()
}
socket
|> update_assignment_with_activity(a, attrs, "projects.assignment_completed",
metadata: %{"task" => Assignment.label(a), "project" => socket.assigns.project.name}
)
|> maybe_sync_and_reload()
end
end
def handle_event("start_task", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
new_pct = if a.progress_pct == 100, do: 0, else: a.progress_pct
attrs = %{status: "in_progress", progress_pct: new_pct}
socket
|> update_assignment_with_activity(a, attrs, "projects.assignment_started",
metadata: %{"task" => Assignment.label(a)}
)
|> maybe_sync_and_reload()
end
end
def handle_event("reopen", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
attrs = %{
status: "todo",
progress_pct: 0,
completed_by_uuid: nil,
completed_at: nil
}
socket
|> update_assignment_with_activity(a, attrs, "projects.assignment_reopened",
metadata: %{"task" => Assignment.label(a)}
)
|> maybe_sync_and_reload()
end
end
def handle_event("edit_duration", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
_a ->
# Just flip the assign — the template sources the prefilled
# values directly from the assignment row via the `:for=` loop
# variable, so there's nothing to stage into a form here.
{:noreply, assign(socket, editing_duration_uuid: uuid)}
end
end
def handle_event("cancel_edit_duration", _params, socket) do
{:noreply, assign(socket, editing_duration_uuid: nil)}
end
def handle_event(
"save_duration",
%{"estimated_duration" => dur, "estimated_duration_unit" => unit},
socket
) do
uuid = socket.assigns.editing_duration_uuid
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
old_dur = "#{a.estimated_duration} #{a.estimated_duration_unit}"
attrs = %{estimated_duration: dur, estimated_duration_unit: unit}
case Projects.update_assignment_form(a, attrs) do
{:ok, _} ->
Activity.log("projects.assignment_duration_changed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{
"task" => Assignment.label(a),
"from" => old_dur,
"to" => "#{dur} #{unit}"
}
)
recompute_owning_subproject(socket, a)
{:noreply,
socket
|> assign(editing_duration_uuid: nil)
|> load_assignments()}
{:error, cs} ->
Activity.log_failed("projects.assignment_duration_changed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{
"task" => Assignment.label(a),
"from" => old_dur,
"to" => "#{dur} #{unit}"
}
)
{:noreply,
socket
|> assign(editing_duration_uuid: nil)
|> put_flash(:error, error_summary(cs, gettext("Could not update duration.")))}
end
end
end
def handle_event("remove_assignment", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
case Projects.delete_assignment(a) do
{:ok, _} ->
Activity.log("projects.assignment_removed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{"task" => Assignment.label(a)}
)
recompute_owning_subproject(socket, a)
{:noreply,
socket
|> WebHelpers.notify_deleted(:assignment, uuid)
|> put_flash(:info, gettext("Task removed."))
|> sync_project_completion()
|> load_assignments()}
{:error, _} ->
Activity.log_failed("projects.assignment_removed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{"task" => Assignment.label(a)}
)
{:noreply, put_flash(socket, :error, gettext("Could not remove task."))}
end
end
end
# ── Sub-projects (V127) ──────────────────────────────────────────
def handle_event("toggle_subproject", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
%Assignment{child_project_uuid: child_uuid} when is_binary(child_uuid) ->
expanded = socket.assigns.expanded_subprojects
if MapSet.member?(expanded, uuid) do
{:noreply, assign(socket, expanded_subprojects: MapSet.delete(expanded, uuid))}
else
child_tasks = Projects.list_assignments(child_uuid)
# Merge the child's dependencies so the inset child tasks' badges
# render (they read `@deps_by_assignment`, keyed by assignment uuid).
child_deps =
child_uuid |> Projects.list_all_dependencies() |> Enum.group_by(& &1.assignment_uuid)
{:noreply,
assign(socket,
expanded_subprojects: MapSet.put(expanded, uuid),
subproject_child_tasks:
Map.put(socket.assigns.subproject_child_tasks, uuid, child_tasks),
deps_by_assignment: Map.merge(socket.assigns.deps_by_assignment, child_deps)
)}
end
_ ->
{:noreply, socket}
end
end
def handle_event("detach_subproject", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
%Assignment{child_project_uuid: child_uuid} = a when is_binary(child_uuid) ->
case Projects.detach_subproject(a) do
{:ok, _} ->
Activity.log("projects.subproject_detached",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: child_uuid,
metadata: %{"parent_project_uuid" => socket.assigns.project.uuid}
)
{:noreply,
socket
|> put_flash(:info, gettext("Sub-project is now a standalone project."))
|> sync_project_completion()
|> load_assignments()}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Could not detach the sub-project."))}
end
_ ->
{:noreply, socket}
end
end
def handle_event("update_progress", %{"uuid" => uuid, "progress_pct" => pct_str}, socket) do
case scoped_assignment(socket, uuid) do
nil -> {:noreply, socket}
a -> do_update_progress(socket, a, parse_pct(pct_str))
end
end
def handle_event("toggle_tracking", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
new_value = not a.track_progress
case Projects.update_assignment_form(a, %{track_progress: new_value}) do
{:ok, _} ->
Activity.log("projects.assignment_tracking_toggled",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{"task" => Assignment.label(a), "track_progress" => new_value}
)
recompute_owning_subproject(socket, a)
{:noreply, load_assignments(socket)}
{:error, _} ->
Activity.log_failed("projects.assignment_tracking_toggled",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{"task" => Assignment.label(a), "track_progress" => new_value}
)
{:noreply, put_flash(socket, :error, gettext("Could not toggle tracking."))}
end
end
end
def handle_event("remove_dependency", %{"assignment" => a_uuid, "depends_on" => d_uuid}, socket) do
# Both assignments must belong to the currently-viewed project —
# prevents an admin on project A from unlinking deps in project B.
# Cross-project mismatches are silent noops (UI never offers them).
# An actual `remove_dependency/2` failure is rare but logged via
# `log_failed` so a Postgres outage doesn't erase the click.
# Both endpoints are scope-checked in a single query (distinct uuids →
# exactly two rows when both live in this project); a missing or
# cross-project endpoint yields <2 rows and is a silent no-op.
case Projects.scoped_assignments([a_uuid, d_uuid], socket.assigns.project.uuid) do
[_, _] ->
case Projects.remove_dependency(a_uuid, d_uuid) do
{:ok, _} ->
Activity.log("projects.dependency_removed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a_uuid,
target_uuid: d_uuid,
metadata: %{}
)
{:noreply, load_assignments(socket)}
{:error, _} ->
Activity.log_failed("projects.dependency_removed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a_uuid,
target_uuid: d_uuid,
metadata: %{}
)
{:noreply, put_flash(socket, :error, gettext("Could not remove dependency."))}
end
_ ->
{:noreply, socket}
end
end
# Opens the start-project modal pre-filled with today's date. The
# actual DB write happens in `confirm_start_project` so users can
# backdate (project was already running before the system was set up)
# or future-date (preparing the project but actual start is later).
# Falls through to a no-op for projects already started — defensive
# against double-clicks racing the LV's render of the now-hidden
# button.
def handle_event("open_start_modal", _params, socket) do
if socket.assigns.project.started_at do
{:noreply, socket}
else
{:noreply,
assign(socket,
start_modal_open: true,
start_form: to_form(%{"start_at" => default_start_at_local()})
)}
end
end
def handle_event("close_start_modal", _params, socket) do
{:noreply, assign(socket, start_modal_open: false)}
end
def handle_event("confirm_start_project", %{"start_at" => datetime_str}, socket) do
case parse_start_at(datetime_str) do
{:ok, started_at} ->
do_start_project(socket, started_at)
{:error, msg} ->
{:noreply, put_flash(socket, :error, msg)}
end
end
def handle_event("change_workflow_status", %{"status_slug" => slug}, socket) do
slug = if slug in [nil, ""], do: nil, else: slug
project = socket.assigns.project
previous = project.current_status_slug
case Statuses.set_current_status(project, slug, actor_uuid: Activity.actor_uuid(socket)) do
{:ok, updated} ->
Activity.log("projects.project_status_changed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: updated.uuid,
metadata: %{
"name" => updated.name,
"status_slug" => slug,
"previous_status_slug" => previous
}
)
{:noreply, socket |> assign(project: updated) |> refresh_status_state()}
{:error, _reason} ->
Activity.log_failed("projects.project_status_changed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"status_slug" => slug}
)
{:noreply, put_flash(socket, :error, gettext("Could not change the status."))}
end
end
def handle_event("archive_project", _params, socket) do
case Projects.archive_project(socket.assigns.project) do
{:ok, project} ->
Activity.log("projects.project_archived",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"name" => project.name}
)
{:noreply,
assign(socket, project: project) |> put_flash(:info, gettext("Project archived."))}
{:error, _} ->
Activity.log_failed("projects.project_archived",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: socket.assigns.project.uuid,
metadata: %{"name" => socket.assigns.project.name}
)
{:noreply, put_flash(socket, :error, gettext("Could not archive project."))}
end
end
# Comments drawer. Opening sets `comments_resource` to the
# `(type, uuid, title)` triple of the target so the drawer header
# can show context and the embedded `CommentsComponent` is keyed
# uniquely per resource. Closing clears the assign — the
# component unmounts and any in-flight reply state is dropped
# (intended: drawer-close is a "step away" affordance).
def handle_event("open_comments", %{"type" => type, "uuid" => uuid} = params, socket)
when type in ["project", "assignment"] do
title = Map.get(params, "title", "")
{:noreply, assign(socket, comments_resource: %{type: type, uuid: uuid, title: title})}
end
def handle_event("close_comments", _params, socket) do
{:noreply, assign(socket, comments_resource: nil)}
end
# SortableGrid drop handler. Validates the new order against the
# project's assignments, applies positions atomically, and pushes a
# `sortable:flash` back so the dragged card flashes green/red. The
# LV reload happens via the assignment_updated PubSub fan-out
# triggered by the position writes — no explicit `load_assignments`
# needed here.
def handle_event("reorder_assignments", %{"ordered_ids" => ordered_ids} = params, socket)
when is_list(ordered_ids) do
moved_id = params["moved_id"]
project_uuid = socket.assigns.project.uuid
case Projects.reorder_assignments(project_uuid, ordered_ids,
actor_uuid: Activity.actor_uuid(socket)
) do
:ok ->
{:noreply,
socket
|> push_event("sortable:flash", %{uuid: moved_id, status: "ok"})
|> load_assignments()}
{:error, :too_many_uuids} ->
{:noreply,
socket
|> put_flash(:error, gettext("Too many tasks to reorder at once."))
|> push_event("sortable:flash", %{uuid: moved_id, status: "error"})
|> load_assignments()}
{:error, :not_in_project} ->
# Stale view — a concurrent change moved an assignment out of
# this project. Snap back to the persisted state.
{:noreply,
socket
|> put_flash(:error, gettext("Tasks have changed; please try again."))
|> push_event("sortable:flash", %{uuid: moved_id, status: "error"})
|> load_assignments()}
{:error, _reason} ->
{:noreply,
socket
|> put_flash(:error, gettext("Could not reorder tasks."))
|> push_event("sortable:flash", %{uuid: moved_id, status: "error"})
|> load_assignments()}
end
end
def handle_event("unarchive_project", _params, socket) do
case Projects.unarchive_project(socket.assigns.project) do
{:ok, project} ->
Activity.log("projects.project_unarchived",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"name" => project.name}
)
{:noreply,
assign(socket, project: project) |> put_flash(:info, gettext("Project unarchived."))}
{:error, _} ->
Activity.log_failed("projects.project_unarchived",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: socket.assigns.project.uuid,
metadata: %{"name" => socket.assigns.project.name}
)
{:noreply, put_flash(socket, :error, gettext("Could not unarchive project."))}
end
end
# ── Helpers ─────────────────────────────────────────────────────
# `` posts `YYYY-MM-DDTHH:mm` (no
# seconds, no timezone). Treat as UTC — what the user typed is what
# gets stored. Pad seconds when missing so `NaiveDateTime` accepts it.
defp parse_start_at(value) when is_binary(value) do
with_seconds = if String.length(value) == 16, do: value <> ":00", else: value
case NaiveDateTime.from_iso8601(with_seconds) do
{:ok, ndt} ->
{:ok, DateTime.from_naive!(ndt, "Etc/UTC")}
{:error, _} ->
{:error, gettext("Invalid date — please pick a valid date and time.")}
end
end
defp parse_start_at(_),
do: {:error, gettext("Invalid date — please pick a valid date and time.")}
# True only when the `phoenix_kit_comments` module is loaded AND
# admin-enabled. Off-by-default `enabled?/0` rescues any error
# (missing tables, sandbox-down) and returns false, so this stays
# safe in early-install or test environments.
defp comments_available? do
Code.ensure_loaded?(PhoenixKitComments) and PhoenixKitComments.enabled?()
end
# Refreshes both the project-level and per-assignment comment
# counts. Called from mount + after `:comments_updated` so the
# button badges stay in sync with reality. Cheap: project count is
# one query, assignment counts are one batched query keyed by
# uuid — no N+1 even with long timelines.
defp load_comment_counts(socket) do
if socket.assigns[:comments_enabled] do
project_uuid = socket.assigns.project.uuid
# Rescue narrowed to the shapes we actually expect: comments are
# optional (UndefinedFunctionError when the module is absent
# mid-install / mid-Hex-bump) and DB transients shouldn't break
# the badge. Anything else surfaces and gets fixed.
project_count =
try do
PhoenixKitComments.count_comments("project", project_uuid, status: "published")
rescue
UndefinedFunctionError -> 0
Postgrex.Error -> 0
DBConnection.OwnershipError -> 0
catch
:exit, _reason -> 0
end
assignment_uuids = Enum.map(socket.assigns.assignments, & &1.uuid)
assignment_counts = Projects.comment_counts_for_assignments(assignment_uuids)
assign(socket,
project_comment_count: project_count,
assignment_comment_counts: assignment_counts
)
else
socket
end
end
# Default value for ``: today at the
# current hour:minute, formatted `YYYY-MM-DDTHH:mm` (the format the
# browser expects). Built from UTC so the prefilled value matches
# what'll be persisted when the user clicks Start without changing
# anything.
defp default_start_at_local do
DateTime.utc_now()
|> DateTime.truncate(:second)
|> DateTime.to_naive()
|> NaiveDateTime.to_iso8601()
|> String.slice(0, 16)
end
# The active tab, derived from the route's live_action. `:gantt` is the gantt
# tab; `:show`, `:show_template`, and the embedded (nil live_action) mount all
# default to the list. Templates never expose the gantt, so they pin to `:list`
# even on the `/gantt` route (the tab bar + gantt are `not @is_template`).
defp tab_for_action(_socket, true), do: :list
defp tab_for_action(socket, _is_template) do
case Map.get(socket.assigns, :live_action) do
:gantt -> :gantt
_ -> :list
end
end
defp do_start_project(socket, started_at) do
case Projects.start_project(socket.assigns.project, started_at) do
{:ok, project} ->
Activity.log("projects.project_started",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{
"name" => project.name,
"started_at" => DateTime.to_iso8601(started_at)
}
)
{:noreply,
socket
|> assign(project: project, start_modal_open: false)
|> put_flash(:info, gettext("Project started!"))}
{:error, _} ->
Activity.log_failed("projects.project_started",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: socket.assigns.project.uuid,
metadata: %{
"name" => socket.assigns.project.name,
"started_at" => DateTime.to_iso8601(started_at)
}
)
{:noreply, put_flash(socket, :error, gettext("Could not start project."))}
end
end
defp parse_pct(pct_str) do
case Integer.parse(pct_str) do
{n, _} -> max(0, min(n, 100))
:error -> 0
end
end
defp progress_attrs(100, socket),
do: %{
progress_pct: 100,
status: "done",
completed_by_uuid: Activity.actor_uuid(socket),
completed_at: DateTime.utc_now()
}
defp progress_attrs(0, _socket),
do: %{progress_pct: 0, status: "todo", completed_by_uuid: nil, completed_at: nil}
defp progress_attrs(pct, _socket),
do: %{progress_pct: pct, status: "in_progress", completed_by_uuid: nil, completed_at: nil}
defp progress_action(100, prev_status) when prev_status != "done",
do: "projects.assignment_completed"
defp progress_action(pct, "todo") when pct > 0, do: "projects.assignment_started"
defp progress_action(0, prev_status) when prev_status != "todo",
do: "projects.assignment_reopened"
defp progress_action(_pct, _prev_status), do: "projects.assignment_progress_updated"
defp do_update_progress(socket, a, pct) do
attrs = progress_attrs(pct, socket)
# Uses the server-trusted path because `progress_attrs/2` sets
# `completed_by_uuid` / `completed_at` on the 100% and 0% branches.
case Projects.update_assignment_status(a, attrs) do
{:ok, _} ->
Activity.log(progress_action(pct, a.status),
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a.uuid,
metadata: %{"task" => Assignment.label(a), "progress_pct" => pct}
)
recompute_owning_subproject(socket, a)
socket =
if attrs.status != a.status, do: sync_project_completion(socket), else: socket
{:noreply, load_assignments(socket)}
{:error, cs} ->
Activity.log_failed(progress_action(pct, a.status),
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a.uuid,
metadata: %{"task" => Assignment.label(a), "progress_pct" => pct}
)
{:noreply,
put_flash(socket, :error, error_summary(cs, gettext("Could not update progress.")))}
end
end
defp assignee_type(a) do
cond do
a.assigned_person_uuid -> gettext("Person")
a.assigned_team_uuid -> gettext("Team")
a.assigned_department_uuid -> gettext("Dept")
true -> nil
end
end
# ── Schedule calculation ─────────────────────────────────────────
defp calculate_schedule(%{started_at: nil}, _), do: nil
defp calculate_schedule(_project, []), do: nil
defp calculate_schedule(project, assignments) do
{total_hours, effective_done} = sum_hours(project, assignments)
if total_hours == 0 do
nil
else
build_schedule(project, total_hours, effective_done)
end
end
defp sum_hours(project, assignments) do
{total, done, progress} =
Enum.reduce(assignments, {0, 0, 0}, fn a, {total, done, progress} ->
accumulate_hours(a, project, total, done, progress)
end)
{total, done + progress}
end
defp accumulate_hours(%{status: "done"} = a, project, total, done, progress) do
hours = assignment_hours(a, project)
{total + hours, done + hours, progress}
end
defp accumulate_hours(
%{track_progress: true, progress_pct: pct} = a,
project,
total,
done,
progress
)
when pct > 0 do
hours = assignment_hours(a, project)
{total + hours, done, progress + hours * pct / 100}
end
defp accumulate_hours(a, project, total, done, progress) do
{total + assignment_hours(a, project), done, progress}
end
defp build_schedule(project, total_hours, effective_done) do
now = DateTime.utc_now()
calendar_hours = DateTime.diff(now, project.started_at, :second) / 3600
# `planned_end_for/2` honors `counts_weekends`: for weekday-only
# projects weekend days don't consume the work budget. We keep it
# internal (drives the overdue/expected math) — it isn't displayed
# to the user; the started-at date + the per-row durations + the
# ETA below already tell the same story.
planned_end = Project.planned_end_for(project, total_hours)
remaining_hours = max(total_hours - effective_done, 0)
past_planned_end? = DateTime.compare(now, planned_end) == :gt
done? = remaining_hours <= 0
# Planned-elapsed = work hours under the project's schedule rules.
planned_elapsed_hours =
if project.counts_weekends,
do: calendar_hours,
else: work_hours_elapsed(project.started_at, now)
# Cap expected at 100% once calendar time has blown past the
# planned end. Otherwise a project with all weekends elapsed and a
# short total can report "0% expected" — which then makes a 0%-done
# project look "on time" instead of overdue.
raw_expected_pct = planned_elapsed_hours / total_hours * 100
expected_pct =
if past_planned_end? and not done? do
100.0
else
min(raw_expected_pct, 100)
end
# Cap defensively: nothing rendered should exceed 100% even if
# `effective_done` somehow drifts past `total_hours` (e.g. task
# durations edited downward after work was logged).
actual_pct = min(effective_done / total_hours * 100, 100)
delta_pct = actual_pct - expected_pct
overdue? = past_planned_end? and not done?
# When overdue, report calendar lateness ("1 day overdue") rather
# than work-hours-equivalent — for a 52-minute task that's 2 days
# late, "< 1 hour behind" reads as nearly-on-time, which is wrong.
delta_label =
if overdue? do
delta_days(now, planned_end)
else
{v, u} = humanize_hours(abs(delta_pct / 100 * total_hours))
"#{v} #{u}"
end
%{
total_hours: total_hours,
done_hours: effective_done,
remaining_hours: remaining_hours,
elapsed_hours: planned_elapsed_hours,
expected_pct: round(expected_pct),
actual_pct: round(actual_pct),
delta_pct: round(delta_pct),
ahead?: delta_pct >= 0 and not overdue?,
overdue?: overdue?,
delta_label: delta_label,
projected_end: projected_end(project, now, remaining_hours)
}
end
# ETA = "if work continues at the original pace from now, this is
# when you'll finish". For completed projects, return the actual
# completion time. For unfinished projects, anchor on `now` and walk
# `remaining_hours` forward through the project's weekday/weekend
# rules — same machinery `planned_end_for/2` uses, just with a
# different start anchor (see `Project.eta_from/3`).
defp projected_end(%{completed_at: %DateTime{} = at}, _now, _remaining), do: at
defp projected_end(_project, now, remaining) when remaining <= 0, do: now
defp projected_end(project, now, remaining), do: Project.eta_from(project, now, remaining)
# Mirrors `Project.planned_end_for/2`'s weekday-only model: each
# weekday's calendar time contributes work hours at the 3:1 ratio
# (24 calendar hours = 8 work hours); weekend days contribute zero.
# Walks the calendar day-by-day clipping the start/end days.
defp work_hours_elapsed(%DateTime{} = from, %DateTime{} = to) do
if DateTime.compare(from, to) != :lt do
0
else
sum_weekday_calendar_hours(from, to) / 3.0
end
end
defp sum_weekday_calendar_hours(from, to) do
from_date = DateTime.to_date(from)
to_date = DateTime.to_date(to)
Date.range(from_date, to_date)
|> Enum.reduce(0.0, fn date, acc ->
if Date.day_of_week(date) <= 5 do
acc + weekday_calendar_hours_on(date, from, to)
else
acc
end
end)
end
defp weekday_calendar_hours_on(date, from, to) do
sod = DateTime.new!(date, ~T[00:00:00.000], from.time_zone)
eod = DateTime.new!(date, ~T[23:59:59.999], from.time_zone)
window_start = if DateTime.compare(from, sod) == :gt, do: from, else: sod
window_end = if DateTime.compare(to, eod) == :lt, do: to, else: eod
if DateTime.compare(window_start, window_end) == :lt do
DateTime.diff(window_end, window_start, :second) / 3600
else
0
end
end
defp delta_days(later, earlier) do
seconds = DateTime.diff(later, earlier, :second)
hours = seconds / 3600
days = hours / 24
cond do
hours < 1 -> gettext("< 1 hour")
days < 1 -> ngettext("%{count} hour", "%{count} hours", round(hours))
days < 2 -> gettext("1 day")
days < 14 -> ngettext("%{count} day", "%{count} days", round(days))
days < 60 -> gettext("%{n} weeks", n: Float.round(days / 7, 1))
true -> gettext("%{n} months", n: Float.round(days / 30, 1))
end
end
# Calendar-scale boundaries (1h, 24h, 7d, 30d) so unit transitions
# land at intuitive points. Previously transitioned at 8h/40h which
# came from "1 workday = 8h" and produced jarring jumps like
# 7.9h → "8 hours", 8h → "1.0 days".
defp humanize_hours(h) when h < 1, do: {gettext("< 1"), gettext("hour")}
defp humanize_hours(h) when h < 24, do: {round(h), gettext("hours")}
defp humanize_hours(h) when h < 24 * 7, do: {Float.round(h / 24, 1), gettext("days")}
defp humanize_hours(h) when h < 24 * 30, do: {Float.round(h / (24 * 7), 1), gettext("weeks")}
defp humanize_hours(h), do: {Float.round(h / (24 * 30), 1), gettext("months")}
defp format_duration(a) do
dur = a.estimated_duration
unit = a.estimated_duration_unit
if dur && unit do
TaskSchema.format_duration(dur, unit)
else
TaskSchema.format_duration(a.task.estimated_duration, a.task.estimated_duration_unit)
end
end
defp duration_unit_options do
[
{gettext("Minutes"), "minutes"},
{gettext("Hours"), "hours"},
{gettext("Days"), "days"},
{gettext("Weeks"), "weeks"},
{gettext("Fortnights"), "fortnights"},
{gettext("Months"), "months"},
{gettext("Years"), "years"}
]
end
# ── Render ──────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<%!-- Title + status badges. Min-width: 0 lets the h1 truncate
instead of pushing siblings around. Long project names
wrap rather than overflowing the column. --%>
<%= if @is_template do %>
{gettext("Template")}
<% end %>
<%= if @project.completed_at do %>
<.icon name="hero-check-circle" class="w-3.5 h-3.5" /> {gettext("Completed")}
<% end %>
<%= if @project.archived_at do %>
<.icon name="hero-archive-box" class="w-3.5 h-3.5" /> {gettext("Archived")}
<% end %>
<%!-- User-defined workflow status (entities-backed), alongside
the computed lifecycle badges above. Renders nothing when
unset or when the entities module is unavailable. --%>
<.workflow_status_badge :if={@statuses_available} status={@current_status} />
<%!-- Description sits directly under the title, above the buttons —
title + subtitle as a stacked pair before the action row. --%>
<% desc = Project.localized_description(@project, L10n.current_content_lang()) %>
{desc}
<%!-- Assignee (V128) — who the project is assigned to. Reuses the same
assignee helpers the task rows use; a Project carries the same
polymorphic assignee fields. --%>
<%!-- Action buttons. Separate row so a long title never crowds
them out; `flex-wrap` keeps the row tidy on narrow viewports. --%>
<.smart_link
navigate={Paths.new_assignment(@project.uuid)}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "new", "project_id" => @project.uuid}}}
embed_mode={@embed_mode}
class="btn btn-primary btn-sm"
>
<.icon name="hero-plus" class="w-4 h-4" /> {gettext("Add task")}
<%!-- Add a sub-project via the same add page tasks use
(`AssignmentFormLive` in sub-project mode, V127) — name +
assignee + dependencies. Template sub-projects deep-clone on
instantiation. --%>
<.smart_link
navigate={Paths.new_assignment(@project.uuid) <> "?kind=subproject"}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "new", "project_id" => @project.uuid, "kind" => "subproject"}}}
embed_mode={@embed_mode}
class="btn btn-outline btn-sm gap-1"
>
<.icon name="hero-folder-plus" class="w-4 h-4" /> {gettext("Add sub-project")}
<%!-- The Gantt/timeline view is now the "Timeline" tab below the
header (router-mounted only), so no separate link here. --%>
<%!-- Inline workflow-status picker (the current value). The
status-list *source* is chosen on the new/edit form (and the
global default in Settings), not here. Hidden when no
statuses exist for the project's list. --%>
<%!-- Edit + (Un)archive go into a kebab dropdown to match the
per-row action pattern used elsewhere in the module. --%>
<.table_row_menu id={"project-header-menu-#{@project.uuid}"}>
<.smart_menu_link
navigate={if @is_template, do: Paths.edit_template(@project.uuid), else: Paths.edit_project(@project.uuid)}
emit={
if @is_template,
do:
{PhoenixKitProjects.Web.TemplateFormLive,
%{"live_action" => "edit", "id" => @project.uuid}},
else:
{PhoenixKitProjects.Web.ProjectFormLive,
%{"live_action" => "edit", "id" => @project.uuid}}
}
embed_mode={@embed_mode}
icon="hero-pencil"
label={gettext("Edit")}
/>
<%= if not @is_template do %>
<.table_row_menu_divider />
<%= if @project.archived_at do %>
<.table_row_menu_button
phx-click="unarchive_project"
phx-disable-with={gettext("Unarchiving…")}
icon="hero-arrow-uturn-left"
label={gettext("Unarchive")}
/>
<% else %>
<.table_row_menu_button
phx-click="archive_project"
phx-disable-with={gettext("Archiving…")}
data-confirm={gettext("Archive this project? It will be hidden from the main lists but kept in the database.")}
icon="hero-archive-box"
label={gettext("Archive")}
/>
<% end %>
<% end %>
<%!-- Start mode / template bar --%>
<%= cond do %>
<% @is_template -> %>
<.icon name="hero-document-duplicate" class="w-5 h-5 text-info" />
{gettext("This is a template — set up tasks, then create projects from it.")}
<.smart_link
navigate={Paths.new_project() <> "?template=#{@project.uuid}"}
emit={{PhoenixKitProjects.Web.ProjectFormLive, %{"live_action" => "new", "template" => @project.uuid}}}
embed_mode={@embed_mode}
class="btn btn-primary btn-xs ml-auto"
>
<.icon name="hero-plus" class="w-4 h-4" /> {gettext("Create project from this template")}
<% @project.completed_at -> %>
<.icon name="hero-trophy" class="w-5 h-5 text-success" />
{gettext("Completed %{when}", when: L10n.format_datetime(@project.completed_at))}
<%= if @project.started_at do %>
·
{gettext("took %{duration}", duration: delta_days(@project.completed_at, @project.started_at))}
<% end %>
<% @project.started_at -> %>
<.icon name="hero-play" class="w-5 h-5 text-success" />
{gettext("Started %{when}", when: L10n.format_datetime(@project.started_at))}
<%= if @schedule do %>
·
<%= cond do %>
<% @schedule.overdue? -> %>
<.icon name="hero-exclamation-triangle" class="w-3 h-3" />
{gettext("%{delta} overdue", delta: @schedule.delta_label)}
<% @schedule.ahead? -> %>
<.icon name="hero-arrow-trending-up" class="w-3 h-3" />
{gettext("%{delta} ahead", delta: @schedule.delta_label)}
<% true -> %>
<.icon name="hero-arrow-trending-down" class="w-3 h-3" />
{gettext("%{delta} behind", delta: @schedule.delta_label)}
<% end %>
{gettext("(%{actual}% done vs %{expected}% expected)", actual: @schedule.actual_pct, expected: @schedule.expected_pct)}
<% end %>
<% @project.start_mode == "scheduled" -> %>
<.icon name="hero-calendar" class="w-5 h-5 text-info" />
{gettext("Scheduled for %{when}", when: L10n.format_datetime(@project.scheduled_start_date))}
<% true -> %>
<.icon name="hero-clock" class="w-5 h-5 text-warning" />
{gettext("Not started — set up tasks, then start")}
<% end %>
<%!-- Schedule summary + progress as ONE card: the progress bar is the
card's bottom edge (a thin flush strip), so the two read as a unit. --%>
<% show_schedule = @project.started_at != nil and @schedule != nil %>
<% show_progress = @total_tasks > 0 and not @is_template %>
<%= if show_schedule or show_progress do %>
<%= if show_schedule do %>
<% {rem_v, rem_u} = humanize_hours(@schedule.remaining_hours) %>
<% end %>
<%!-- Progress bar — the card's bottom border (not for templates). --%>
<% end %>
<%!-- View tabs (List / Timeline). Rendered in every context (templates
excepted). The `ProjectTabsUrl` phx-hook — which syncs the URL on
push/popstate — is attached ONLY when `@tab_url_sync?` (the standalone
admin page; off by default for embeds so they never touch the host's
URL). The tabs switch instantly with or without the hook. --%>
<%!-- Timeline --%>
<%= if @assignments == [] do %>
<.empty_state icon="hero-rectangle-stack" title={gettext("No tasks in this project yet.")}>
<:cta>
<.smart_link
navigate={Paths.new_assignment(@project.uuid)}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "new", "project_id" => @project.uuid}}}
embed_mode={@embed_mode}
class="link link-primary text-sm"
>
{gettext("Add one from the task library")}
<% else %>
<%!-- Vertical connector line --%>
<%!-- SortableGrid hook lives on the inner flex container —
the absolute-positioned vertical line is a sibling
outside it so it doesn't get included in the sortable
item set. The drag handle on each card's title row is
the only initiator (`.pk-drag-handle`), so clicks
anywhere else on the card still trigger the existing
status / duration / dep handlers. --%>
<%= for {a, idx} <- Enum.with_index(@assignments) do %>
<%!-- Status dot on the timeline --%>
<%= if a.status == "done" do %>
<.icon name="hero-check" class="w-5 h-5" />
<% else %>
{idx + 1}
<% end %>
<%!-- This sub-project's own dependencies (on siblings) --%>
<% sp_deps = Map.get(@deps_by_assignment, a.uuid, []) %>
<%= for dep <- sp_deps do %>
<.icon name="hero-arrow-right-circle" class="w-3 h-3" />
{gettext("depends on:")} {Assignment.label(dep.depends_on, sp_lang)}
<% end %>
<%!-- Expanded: the child's own task list + add-dependency --%>
<% child_tasks = Map.get(@subproject_child_tasks, a.uuid, []) %>
<%= if child_tasks == [] do %>
<%= for {ct, ci} <- Enum.with_index(child_tasks) do %>
<%= if Assignment.subproject?(ct) do %>
<%!-- A nested sub-project: a compact link (open it to drill in). --%>
<%!-- Gantt tab — rendered in every context (templates excepted),
lazy-mounted on first activation and then kept (so its own zoom/expand
survive switching back). It's a nested LiveView with its own
PubSub/state — when the show page is itself embedded this is a
nested-within-nested `live_render`, which is fine (server-rendered
SVG; only the popover/auto-scroll need the gantt JS hooks in the
host). `headless` drops its back-link since the tabs replace it. --%>
<%= if @gantt_mounted? do %>
{live_render(@socket, PhoenixKitProjects.Web.ProjectGanttLive,
id: "project-gantt-live-#{@project.uuid}",
session: %{
"id" => @project.uuid,
"headless" => true,
# No wrapper padding — the show page already pads this content area;
# the gantt's own `px-4 py-6` would double it and push the chart down.
"wrapper_class" => "",
"locale" => L10n.current_content_lang(),
# Forward the viewer so the nested gantt reconstructs the same
# user/scope across this second `live_render` hop (see
# `WebHelpers.assign_embed_user/2`). Bracket access (not `@`) so
# an off-router mount missing the assign degrades to nil rather
# than raising — matches the module's no-bang-form convention.
"current_user_uuid" =>
assigns[:phoenix_kit_current_user] && assigns[:phoenix_kit_current_user].uuid
})}
<% end %>
<%!-- Start-project modal — date editable so the user can backdate
an already-running project or queue a future start. The
form's `phx-change="noop"` prevents the LV from rebuilding
the changeset on each keystroke (no live validation needed
for a single date input); submit goes via `phx-submit`. --%>
<%= if @start_modal_open do %>
<% end %>
<%!-- Slide-in comments drawer. Right-side fixed panel that
hosts `PhoenixKitComments.Web.CommentsComponent` for either
the project or one of its assignments. The component is
keyed on `{type, uuid}` so opening a different resource
re-mounts with its own state instead of leaking the
previous resource's reply-in-progress / pagination.
Esc + backdrop click both fire `close_comments`. The
component's "comments_updated" message is unhandled here
(we don't need to react to project-level comment counts
in the timeline yet) — the catch-all `handle_info` clause
logs it at debug and moves on. --%>
<%!-- z-[60] / z-[70] so we paint over the admin header
(`fixed top-0 z-50` in the layout wrapper). At z-40 the
backdrop sat behind the header and looked broken. --%>
<%= if @comments_resource do %>
<% end %>