Current section

Files

Jump to
phoenix_kit_projects lib phoenix_kit_projects web overview_live.ex
Raw

lib/phoenix_kit_projects/web/overview_live.ex

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, CalendarDisplay, 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: %{},
today: Date.utc_today(),
tz_offset: "0",
calendar_events: [],
# Which VIEW of the running projects is shown: :list (vertical list,
# default) or :calendar (the month view), toggled by tabs. The calendar is
# lazy-mounted on first switch, then kept (hidden) so its paged month
# survives toggling.
overview_tab: :list,
calendar_seen?: false
)
|> WebHelpers.assign_embed_state(session)
|> WebHelpers.assign_embed_user(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 != []
# One `now` (UTC instant — "has the deadline passed" is timezone-independent)
# and one `today` (the viewer's local calendar day — for every day/date
# display). Computing both once keeps the whole Overview internally
# consistent.
now = DateTime.utc_now()
offset = resolve_offset(socket)
today = to_local_date(now, offset)
# Compute every active project's tree summary once and tag its lifecycle
# tier (with that single `now`), so the Running cards, the sort order, and
# the calendar's late-blink all agree on which projects are late. The
# capped/sorted slice feeds the cards; the full list feeds the calendar.
all_summaries =
active_projects
|> Enum.map(&Projects.project_tree_summary/1)
|> Enum.map(fn s ->
tier = running_tier(s, now)
s |> Map.put(:tier, tier) |> Map.put(:late, tier == :late)
end)
{top_summaries, total_active} = prioritize_running(all_summaries, today, now)
overdue_anim = CalendarDisplay.read_animation()
calendar_events =
CalendarDisplay.events(
all_summaries,
completed_projects,
upcoming_projects,
L10n.current_content_lang(),
today,
offset
)
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(),
today: today,
tz_offset: offset,
calendar_events: calendar_events,
# The overdue-animation <style>, generated from the settings on
# /admin/settings/projects (mode/speed/brightness), plus the mode itself so
# the calendar's info popover can describe it accurately. Read once here so a
# page (re)load reflects the latest config.
overdue_style: CalendarDisplay.animation_style(overdue_anim),
overdue_mode: overdue_anim.mode,
overdue_pattern: overdue_anim.pattern
)
end
# The viewer's timezone offset. Precedence matches the rest of PhoenixKit
# (`Utils.Date.get_user_timezone/1`): the current user's own `user_timezone`,
# else the website's `time_zone` setting, else UTC ("0").
defp resolve_offset(socket) do
case socket.assigns[:phoenix_kit_current_user] do
%{} = user -> PhoenixKit.Utils.Date.get_user_timezone(user)
_ -> PhoenixKit.Settings.get_setting("time_zone", "0")
end
rescue
_ -> "0"
end
# A stored UTC datetime as a calendar Date in the viewer's timezone, so every
# day/date display on the Overview shares the same (local) basis as `today`.
# The module otherwise works in UTC, but day boundaries are where a viewer
# notices the difference — at 00:30 in UTC+3 it's already tomorrow locally.
defp to_local_date(%DateTime{} = dt, offset) do
dt |> PhoenixKit.Utils.Date.shift_to_offset(offset) |> DateTime.to_date()
end
# The calendar info-popover sentence describing the overdue indicator, worded
# to match the configured animation mode (set on /admin/settings/projects).
defp overdue_legend("solid") do
gettext(
"When a project runs past its planned end, that overdue stretch is filled with the inverse of its colour — the longer it is, the more overdue the project."
)
end
defp overdue_legend(_stripes) do
gettext(
"When a project runs past its planned end, that overdue stretch is marked with diagonal stripes — the longer the striped part, the more overdue the project."
)
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, today, now) do
sorted =
summaries
|> Enum.map(&{running_sort_key(&1, today, now), &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, now) do
%{project: project, progress_pct: pct, total: total, planned_end: planned_end} = summary
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, now) do
%{project: project, progress_pct: pct, total: total, tier: 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, now)
{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}, now) do
DateTime.diff(now, planned_end, :second)
end
defp overdue_seconds(%{project: %{started_at: %DateTime{} = started_at}}, now) do
DateTime.diff(now, started_at, :second)
end
defp overdue_seconds(_, _now), do: 0
# Running card tabs. The calendar is lazy-mounted on first open (then kept
# hidden when inactive, so its paged month survives toggling back and forth).
@impl true
def handle_event("switch_overview_tab", %{"tab" => "calendar"}, socket) do
{:noreply, assign(socket, overview_tab: :calendar, calendar_seen?: true)}
end
def handle_event("switch_overview_tab", _params, socket) do
{:noreply, assign(socket, overview_tab: :list)}
end
@impl true
def handle_info({:projects, _event, _payload}, socket) do
{:noreply, reload(socket)}
end
# A project bar on the calendar was clicked — open that project (navigate in
# standalone, emit an :opened intent when embedded).
def handle_info({:calendar_open_project, uuid}, socket) when is_binary(uuid) do
{:noreply,
WebHelpers.navigate_or_open(socket,
to: Paths.project(uuid),
open: {PhoenixKitProjects.Web.ProjectShowLive, %{"id" => uuid}}
)}
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, today, offset),
do: days_until(to_local_date(dt, offset), today, offset)
defp days_until(%Date{} = date, today, _offset), do: Date.diff(date, 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"""
<div class={@wrapper_class}>
<.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>
<.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")}
</.smart_link>
</:actions>
</.page_header>
<%!-- Stats row --%>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<.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"
/>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<%!-- Left: Active projects (span 2) --%>
<div class="lg:col-span-2 card bg-base-100 shadow">
<%!-- Tighter body padding on phones so the 7-column calendar isn't squeezed. --%>
<div class="card-body max-sm:p-3">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="card-title text-lg">
<.icon name="hero-play" class="w-5 h-5 text-success" /> {gettext("Running")}
</h2>
<p class="text-xs text-base-content/50 mt-0.5">
{gettext("Started and not yet completed.")}
</p>
</div>
<.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 %>
</.smart_link>
</div>
<%!-- Same running projects, two views to choose from: a vertical list
(default) and the month calendar. --%>
<.nav_tabs
active_tab={to_string(@overview_tab)}
on_change="switch_overview_tab"
tabs={[
%{id: "list", label: gettext("List"), icon: "hero-list-bullet"},
%{id: "calendar", label: gettext("Calendar"), icon: "hero-calendar-days"}
]}
class="mt-3"
/>
<%!-- List view --%>
<div class={["mt-2", if(@overview_tab != :list, do: "hidden")]}>
<%= 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")}
</.smart_link>
</:cta>
</.empty_state>
<% 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")}
</.smart_link>
</:cta>
</.empty_state>
<% end %>
<% else %>
<div class="flex flex-col gap-2 mt-2">
<.running_card
:for={s <- @active_summaries}
node={s}
tier={s.tier}
embed_mode={@embed_mode}
lang={L10n.current_content_lang()}
/>
</div>
<% end %>
</div>
<%!-- Calendar tab. Lazy-mounted on first open, then kept (hidden when
inactive) so its paged month + animation state survive toggling.
The overdue <style> + SyncAnimations wrapper come along with it. --%>
<div class={["mt-2", if(@overview_tab != :calendar, do: "hidden")]}>
<%= if @calendar_seen? do %>
{Phoenix.HTML.raw(@overdue_style)}
<div id="overview-calendar-sync" phx-hook="SyncAnimations">
<.live_component
module={PhoenixLiveCalendar.CalendarComponent}
id="projects-overview-calendar"
events={@calendar_events}
views={[:month]}
date={@today}
today={@today}
fixed_weeks={false}
expand_cells={true}
info_label={gettext("About this calendar")}
on_event_click={fn id -> send(self(), {:calendar_open_project, id}) end}
>
<:info>
<p class="mb-1 text-sm font-semibold text-base-content">
{gettext("Reading the calendar")}
</p>
<p>{gettext("Each project is an ongoing line across the month.")}</p>
<p class="mt-1.5">{overdue_legend(@overdue_pattern)}</p>
<p class="mt-1.5 text-base-content/50">
{gettext("Late projects are grouped at the top.")}
</p>
</:info>
</.live_component>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Right: My tasks + upcoming + recently completed --%>
<div class="flex flex-col gap-4">
<%!-- My assignments --%>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-lg">
<.icon name="hero-user" class="w-5 h-5" /> {gettext("My tasks")}
</h2>
<%= if @my_assignments == [] do %>
<p class="text-sm text-base-content/50 py-2">
{gettext("Nothing assigned to you right now.")}
</p>
<% else %>
<div class="flex flex-col gap-2 mt-2">
<.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" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{Task.localized_title(a.task, L10n.current_content_lang())}</div>
<div class="text-xs text-base-content/60 truncate">{Project.localized_name(a.project, L10n.current_content_lang())}</div>
</div>
</.smart_link>
<%= if length(@my_assignments) > 6 do %>
<div class="text-xs text-base-content/50 text-center pt-1">
{gettext("+%{count} more", count: length(@my_assignments) - 6)}
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<%!-- Recently completed --%>
<%= if @completed_projects != [] do %>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-lg">
<.icon name="hero-trophy" class="w-5 h-5 text-success" /> {gettext("Recently completed")}
</h2>
<div class="flex flex-col gap-1 mt-2">
<.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" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{Project.localized_name(p, L10n.current_content_lang())}</div>
<div class="text-xs text-base-content/60">
{relative_day(Date.diff(to_local_date(p.completed_at, @tz_offset), @today))}
</div>
</div>
</.smart_link>
</div>
</div>
</div>
<% end %>
<%!-- Upcoming & Setup --%>
<%= if @upcoming_projects != [] or @setup_projects != [] do %>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-lg">
<.icon name="hero-calendar" class="w-5 h-5 text-info" /> {gettext("Upcoming")}
</h2>
<%= if @setup_projects != [] do %>
<div class="text-xs text-base-content/50 uppercase tracking-wide mt-2">{gettext("In setup")}</div>
<.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" />
<span class="text-sm font-medium truncate flex-1">{Project.localized_name(p, L10n.current_content_lang())}</span>
</.smart_link>
<% end %>
<%= if @upcoming_projects != [] do %>
<div class="text-xs text-base-content/50 uppercase tracking-wide mt-2">{gettext("Scheduled")}</div>
<.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" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{Project.localized_name(p, L10n.current_content_lang())}</div>
<div class="text-xs text-base-content/60">
{L10n.format_datetime(p.scheduled_start_date)}
· {relative_day(days_until(p.scheduled_start_date, @today, @tz_offset))}
</div>
</div>
</.smart_link>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Bottom navigation row --%>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<.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"
>
<div class="card-body p-4">
<div class="flex items-center gap-2 text-base-content/70">
<.icon name="hero-clipboard-document-list" class="w-5 h-5" />
<span class="text-sm font-medium">{gettext("Projects")}</span>
</div>
<div class="text-xl font-bold">{@project_count}</div>
</div>
</.smart_link>
<.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"
>
<div class="card-body p-4">
<div class="flex items-center gap-2 text-base-content/70">
<.icon name="hero-rectangle-stack" class="w-5 h-5" />
<span class="text-sm font-medium">{gettext("Task Library")}</span>
</div>
<div class="text-xl font-bold">{@task_count}</div>
</div>
</.smart_link>
<.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"
>
<div class="card-body p-4">
<div class="flex items-center gap-2 text-base-content/70">
<.icon name="hero-document-duplicate" class="w-5 h-5" />
<span class="text-sm font-medium">{gettext("Templates")}</span>
</div>
<div class="text-xl font-bold">{@template_count}</div>
</div>
</.smart_link>
<.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"
>
<div class="card-body p-4">
<div class="flex items-center gap-2 text-base-content/70">
<.icon name="hero-plus" class="w-5 h-5" />
<span class="text-sm font-medium">{gettext("New template")}</span>
</div>
<div class="text-xs text-base-content/50">{gettext("Blueprint from scratch")}</div>
</div>
</.smart_link>
</div>
</div>
"""
end
end