defmodule PhoenixKitProjects.Web.OverviewLive do @moduledoc "Projects module dashboard." use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitProjects.Gettext use PhoenixKitProjects.Web.Components alias PhoenixKitProjects.{Activity, L10n, Paths, Projects} alias PhoenixKitProjects.PubSub, as: ProjectsPubSub alias PhoenixKitProjects.Schemas.{Project, Task} alias PhoenixKitProjects.Web.Helpers, as: WebHelpers require Logger # How many "Running" projects to show on the dashboard. The count # badge on "View all →" reveals when the total exceeds this cap. @running_display_limit 10 # Fallback "late" threshold (days since `started_at`) when a project # has no estimated durations — without sum-of-durations we can't # compute a real planned_end. Projects with durations use planned_end # directly (started_at + total estimated hours), per the same logic # the project show page uses. @late_fallback_days 14 # Progress percentage (>=) for the "near done" tier on the dashboard. @near_done_threshold_pct 75 # Default wrapper class for the standalone admin page. Embedders can # override via `live_render(... session: %{"wrapper_class" => "..."})`. @default_wrapper_class "flex flex-col w-full px-4 py-6 gap-6" @impl true def mount(_params, session, socket) do WebHelpers.maybe_put_locale(session) if connected?(socket), do: ProjectsPubSub.subscribe(ProjectsPubSub.topic_all()) wrapper_class = Map.get(session, "wrapper_class", @default_wrapper_class) # Load on both the disconnected HTTP render AND the connected # WebSocket mount so the first paint already has real content (no # empty-skeleton pop-in). The skeleton assigns below stay as defensive # defaults — `reload/1` overwrites them on the same socket — but the # render path never actually sees them. `handle_params/3` is # intentionally absent: Phoenix LV refuses to mount a LV exporting it # outside a router live route, which would block embedding via # `live_render`. See dev_docs/embedding_audit.md. socket = socket |> assign( user_uuid: Activity.actor_uuid(socket), page_title: gettext("Projects"), wrapper_class: wrapper_class, task_count: 0, project_count: 0, template_count: 0, active_count: 0, active_summaries: [], running_display_limit: @running_display_limit, completed_projects: [], upcoming_projects: [], setup_projects: [], any_projects?: false, my_assignments: [], status_counts: %{} ) |> WebHelpers.assign_embed_state(session) |> WebHelpers.attach_open_embed_hook() {:ok, reload(socket)} end defp reload(socket) do user_uuid = socket.assigns[:user_uuid] active_projects = Projects.list_active_projects() completed_projects = Projects.list_recently_completed_projects() upcoming_projects = Projects.list_upcoming_projects() setup_projects = Projects.list_setup_projects() any_projects? = active_projects != [] or completed_projects != [] or upcoming_projects != [] or setup_projects != [] {top_summaries, total_active} = active_projects |> Enum.map(&Projects.project_tree_summary/1) |> prioritize_running() assign(socket, task_count: Projects.count_tasks(), project_count: Projects.count_projects(), template_count: Projects.count_templates(), active_count: total_active, active_summaries: top_summaries, completed_projects: completed_projects, upcoming_projects: upcoming_projects, setup_projects: setup_projects, any_projects?: any_projects?, my_assignments: if(user_uuid, do: Projects.list_assignments_for_user(user_uuid), else: []), status_counts: Projects.assignment_status_counts() ) end # Sorts running-project summaries into four importance tiers and # caps to @running_display_limit. Returns {capped_list, total_count}. # # Tier 0 ("late"): started ≥ @late_threshold_days ago AND progress < 100. # Within tier, oldest-started first (most stalled). # Tier 1 ("near done"): progress ≥ @near_done_threshold_pct, not Tier 0. # Within tier, highest progress first. # Tier 2 ("rest"): has tasks, not late, not near-done. Most-recently-started first. # Tier 3 ("empty"): total == 0 tasks. Sinks to the bottom regardless of age — # these projects can't show meaningful progress and would # otherwise outrank real work via the recency sort. defp prioritize_running(summaries) do today = Date.utc_today() sorted = summaries |> Enum.map(&{running_sort_key(&1, today), &1}) |> Enum.sort_by(fn {key, _} -> key end) |> Enum.map(fn {_, s} -> s end) {Enum.take(sorted, @running_display_limit), length(summaries)} end # Template-facing tier label for a summary row. Late = past # `planned_end` (sum of estimated durations from started_at) with # progress < 100. When a project has no durations we fall back to # the 14-day age heuristic since there's no real budget to compare # against. defp running_tier(summary) do %{project: project, progress_pct: pct, total: total, planned_end: planned_end} = summary now = DateTime.utc_now() cond do total == 0 -> :empty late?(planned_end, project, now, pct) -> :late pct >= @near_done_threshold_pct -> :near_done true -> :on_track end end defp late?(_planned_end, _project, _now, pct) when pct >= 100, do: false defp late?(%DateTime{} = planned_end, _project, now, _pct), do: DateTime.compare(now, planned_end) == :gt defp late?(nil, %{started_at: %DateTime{} = started_at}, now, _pct), do: DateTime.diff(now, started_at, :second) / 86_400 >= @late_fallback_days defp late?(_, _, _, _), do: false defp running_sort_key(summary, today) do %{project: project, progress_pct: pct, total: total} = summary tier = running_tier(summary) days_running = case project.started_at do %DateTime{} = dt -> Date.diff(today, DateTime.to_date(dt)) _ -> 0 end case tier do :late -> # Tier 0: most overdue first. Use seconds-past-planned_end when # available, else fall back to age. Negated for ascending sort. overdue_seconds = overdue_seconds(summary) {0, -overdue_seconds, project.uuid} :near_done -> # Tier 1: highest progress first. {1, -pct, project.uuid} :on_track -> # Tier 2: most-recently-started first. {2, days_running, project.uuid} :empty -> # Tier 3: empty projects sink to the bottom. _ = total {3, days_running, project.uuid} end end defp overdue_seconds(%{planned_end: %DateTime{} = planned_end}) do DateTime.diff(DateTime.utc_now(), planned_end, :second) end defp overdue_seconds(%{project: %{started_at: %DateTime{} = started_at}}) do DateTime.diff(DateTime.utc_now(), started_at, :second) end defp overdue_seconds(_), do: 0 @impl true def handle_info({:projects, _event, _payload}, socket) do {:noreply, reload(socket)} end def handle_info(msg, socket) do Logger.debug("[OverviewLive] unexpected handle_info: #{inspect(msg)}") {:noreply, socket} end # Accepts either a `Date` or a `DateTime` — `scheduled_start_date` # was retyped to `:utc_datetime` in V112; this helper preserves the # daily-cadence comparison by collapsing datetimes to their date # portion. defp days_until(%DateTime{} = dt), do: days_until(DateTime.to_date(dt)) defp days_until(%Date{} = date), do: Date.diff(date, Date.utc_today()) defp relative_day(days) do cond do days == 0 -> gettext("today") days == 1 -> gettext("tomorrow") days == -1 -> gettext("yesterday") days > 1 and days < 14 -> ngettext("in %{count} day", "in %{count} days", days) days > 1 -> gettext("in %{n} weeks", n: Float.round(days / 7, 1)) days < 0 and days > -14 -> ngettext("%{count} day ago", "%{count} days ago", abs(days)) true -> gettext("%{n} weeks ago", n: Float.round(abs(days) / 7, 1)) end end @impl true def render(assigns) do ~H"""
<.page_header title={gettext("Projects")} description={gettext("Overview of active work, upcoming projects, and your assignments.")} > <:actions> <.smart_link navigate={Paths.new_project()} emit={{PhoenixKitProjects.Web.ProjectFormLive, %{"live_action" => "new"}}} embed_mode={@embed_mode} class="btn btn-primary btn-sm" > <.icon name="hero-plus" class="w-4 h-4" /> {gettext("New project")} <.smart_link navigate={Paths.new_task()} emit={{PhoenixKitProjects.Web.TaskFormLive, %{"live_action" => "new"}}} embed_mode={@embed_mode} class="btn btn-ghost btn-sm" > <.icon name="hero-plus" class="w-4 h-4" /> {gettext("New task")} <%!-- Stats row --%>
<.stat_tile label={gettext("Running")} value={@active_count} /> <.stat_tile label={gettext("Tasks in progress")} value={Map.get(@status_counts, "in_progress", 0)} value_class="text-warning" /> <.stat_tile label={gettext("Tasks todo")} value={Map.get(@status_counts, "todo", 0)} /> <.stat_tile label={gettext("Tasks done")} value={Map.get(@status_counts, "done", 0)} value_class="text-success" />
<%!-- Left: Active projects (span 2) --%>

<.icon name="hero-play" class="w-5 h-5 text-success" /> {gettext("Running")}

{gettext("Started and not yet completed.")}

<.smart_link navigate={Paths.projects()} emit={{PhoenixKitProjects.Web.ProjectsLive, %{}}} embed_mode={@embed_mode} class="link link-hover text-sm shrink-0 mt-1" > <%= if @active_count > @running_display_limit do %> {gettext("View all (%{count}) →", count: @active_count)} <% else %> {gettext("View all →")} <% end %>
<%= if @active_summaries == [] do %> <%= if @any_projects? do %> <.empty_state icon="hero-clipboard-document-list" title={gettext("Nothing running right now.")} description={gettext("Open a project and click Start to begin.")} class="py-10" > <:cta> <.smart_link navigate={Paths.projects()} emit={{PhoenixKitProjects.Web.ProjectsLive, %{}}} embed_mode={@embed_mode} class="btn btn-ghost btn-xs" > <.icon name="hero-clipboard-document-list" class="w-3.5 h-3.5" /> {gettext("View projects")} <% else %> <.empty_state icon="hero-clipboard-document-list" title={gettext("No projects yet.")} description={gettext("Create one to get started.")} class="py-10" > <:cta> <.smart_link navigate={Paths.new_project()} emit={{PhoenixKitProjects.Web.ProjectFormLive, %{"live_action" => "new"}}} embed_mode={@embed_mode} class="btn btn-primary btn-xs" > <.icon name="hero-plus" class="w-3.5 h-3.5" /> {gettext("New project")} <% end %> <% else %>
<.running_card :for={s <- @active_summaries} node={s} tier={running_tier(s)} embed_mode={@embed_mode} lang={L10n.current_content_lang()} />
<% end %>
<%!-- Right: My tasks + upcoming + recently completed --%>
<%!-- My assignments --%>

<.icon name="hero-user" class="w-5 h-5" /> {gettext("My tasks")}

<%= if @my_assignments == [] do %>

{gettext("Nothing assigned to you right now.")}

<% else %>
<.smart_link :for={a <- Enum.take(@my_assignments, 6)} navigate={Paths.project(a.project.uuid)} emit={{PhoenixKitProjects.Web.ProjectShowLive, %{"id" => a.project.uuid}}} embed_mode={@embed_mode} class="flex items-start gap-2 p-2 rounded hover:bg-base-200 transition" > <.assignment_status_badge status={a.status} size="xs" class="mt-1" />
{Task.localized_title(a.task, L10n.current_content_lang())}
{Project.localized_name(a.project, L10n.current_content_lang())}
<%= if length(@my_assignments) > 6 do %>
{gettext("+%{count} more", count: length(@my_assignments) - 6)}
<% end %>
<% end %>
<%!-- Recently completed --%> <%= if @completed_projects != [] do %>

<.icon name="hero-trophy" class="w-5 h-5 text-success" /> {gettext("Recently completed")}

<.smart_link :for={p <- @completed_projects} navigate={Paths.project(p.uuid)} emit={{PhoenixKitProjects.Web.ProjectShowLive, %{"id" => p.uuid}}} embed_mode={@embed_mode} class="flex items-center gap-2 p-2 rounded hover:bg-base-200 transition" > <.icon name="hero-check-circle" class="w-4 h-4 text-success shrink-0" />
{Project.localized_name(p, L10n.current_content_lang())}
{relative_day(Date.diff(DateTime.to_date(p.completed_at), Date.utc_today()))}
<% end %> <%!-- Upcoming & Setup --%> <%= if @upcoming_projects != [] or @setup_projects != [] do %>

<.icon name="hero-calendar" class="w-5 h-5 text-info" /> {gettext("Upcoming")}

<%= if @setup_projects != [] do %>
{gettext("In setup")}
<.smart_link :for={p <- @setup_projects} navigate={Paths.project(p.uuid)} emit={{PhoenixKitProjects.Web.ProjectShowLive, %{"id" => p.uuid}}} embed_mode={@embed_mode} class="flex items-center gap-2 p-2 rounded hover:bg-base-200 transition" > <.icon name="hero-clock" class="w-4 h-4 text-warning shrink-0" /> {Project.localized_name(p, L10n.current_content_lang())} <% end %> <%= if @upcoming_projects != [] do %>
{gettext("Scheduled")}
<.smart_link :for={p <- @upcoming_projects} navigate={Paths.project(p.uuid)} emit={{PhoenixKitProjects.Web.ProjectShowLive, %{"id" => p.uuid}}} embed_mode={@embed_mode} class="flex items-center gap-2 p-2 rounded hover:bg-base-200 transition" > <.icon name="hero-calendar" class="w-4 h-4 text-info shrink-0" />
{Project.localized_name(p, L10n.current_content_lang())}
{L10n.format_datetime(p.scheduled_start_date)} · {relative_day(days_until(p.scheduled_start_date))}
<% end %>
<% end %>
<%!-- Bottom navigation row --%>
<.smart_link navigate={Paths.projects()} emit={{PhoenixKitProjects.Web.ProjectsLive, %{}}} embed_mode={@embed_mode} class="card bg-base-100 shadow-sm hover:shadow-md transition border border-base-200" >
<.icon name="hero-clipboard-document-list" class="w-5 h-5" /> {gettext("Projects")}
{@project_count}
<.smart_link navigate={Paths.tasks()} emit={{PhoenixKitProjects.Web.TasksLive, %{}}} embed_mode={@embed_mode} class="card bg-base-100 shadow-sm hover:shadow-md transition border border-base-200" >
<.icon name="hero-rectangle-stack" class="w-5 h-5" /> {gettext("Task Library")}
{@task_count}
<.smart_link navigate={Paths.templates()} emit={{PhoenixKitProjects.Web.TemplatesLive, %{}}} embed_mode={@embed_mode} class="card bg-base-100 shadow-sm hover:shadow-md transition border border-base-200" >
<.icon name="hero-document-duplicate" class="w-5 h-5" /> {gettext("Templates")}
{@template_count}
<.smart_link navigate={Paths.new_template()} emit={{PhoenixKitProjects.Web.TemplateFormLive, %{"live_action" => "new"}}} embed_mode={@embed_mode} class="card bg-base-100 shadow-sm hover:shadow-md transition border border-base-200" >
<.icon name="hero-plus" class="w-5 h-5" /> {gettext("New template")}
{gettext("Blueprint from scratch")}
""" end end