Packages
Projects module for PhoenixKit — projects, reusable tasks, assignments, and dependencies.
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_projects/schemas/project.ex
defmodule PhoenixKitProjects.Schemas.Project do
@moduledoc """
A project container. Can start immediately (set up tasks first, then
mark as started) or be scheduled for a future date.
## Soft-hide / archive
`archived_at` is the soft-hide flag — null = visible, non-null =
archived. Mirrors the workspace's `trashed_at` convention used by
publishing posts and core files.
The legacy `status` string column (V86 / V94) is **kept in the table
but no longer read or written** by application code. See
`phoenix_kit_projects/AGENTS.md` for the deprecation note.
"""
use Ecto.Schema
use Gettext, backend: PhoenixKitProjects.Gettext
import Ecto.Changeset
alias PhoenixKitProjects.{L10n, Schemas.Assignment}
@primary_key {:uuid, UUIDv7, autogenerate: true}
@foreign_key_type UUIDv7
@start_modes ~w(immediate scheduled)
@typedoc """
JSONB map of secondary-language overrides for translatable fields.
Shape: `%{"es-ES" => %{"name" => "...", "description" => "..."}}`.
Primary-language values live in the dedicated `name`/`description`
columns; this map only carries overrides for non-primary languages.
Missing/empty overrides fall back to the primary value at render time.
"""
@type translations_map :: %{optional(String.t()) => %{optional(String.t()) => String.t()}}
@type t :: %__MODULE__{
uuid: UUIDv7.t() | nil,
name: String.t() | nil,
description: String.t() | nil,
is_template: boolean() | nil,
counts_weekends: boolean() | nil,
start_mode: String.t() | nil,
scheduled_start_date: DateTime.t() | nil,
started_at: DateTime.t() | nil,
completed_at: DateTime.t() | nil,
archived_at: DateTime.t() | nil,
position: integer() | nil,
translations: translations_map(),
assignments: [Assignment.t()] | Ecto.Association.NotLoaded.t(),
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}
@translatable_fields ~w(name description)
schema "phoenix_kit_projects" do
field(:name, :string)
field(:description, :string)
field(:is_template, :boolean, default: false)
field(:counts_weekends, :boolean, default: false)
field(:start_mode, :string, default: "immediate")
# Promoted from `:date` to `:utc_datetime` in V112 so the form +
# start-modal can carry hour-and-minute precision. Column name kept
# `scheduled_start_date` to avoid a churn pass through every call
# site; treat the trailing "_date" as historical baggage.
field(:scheduled_start_date, :utc_datetime)
field(:started_at, :utc_datetime)
field(:completed_at, :utc_datetime)
field(:archived_at, :utc_datetime)
field(:position, :integer, default: 0)
field(:translations, :map, default: %{})
has_many(:assignments, Assignment, foreign_key: :project_uuid, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
@required ~w(name start_mode)a
@optional ~w(description is_template counts_weekends scheduled_start_date started_at completed_at archived_at position translations)a
def changeset(project, attrs, opts \\ []) do
project
|> cast(attrs, @required ++ @optional)
|> validate_required(@required)
|> validate_length(:name, min: 1, max: 255)
|> validate_inclusion(:start_mode, @start_modes)
|> validate_translations_shape()
|> maybe_require_date(opts)
end
# Guards the `translations` JSONB against garbage from programmatic
# callers (the form layer cleans inputs via
# `Web.Helpers.merge_translations_attrs/3`, but seeds / migrations /
# future direct writers don't). The shape contract lives on
# `L10n.valid_translations_shape?/1` so all three schemas with this
# column enforce it identically.
defp validate_translations_shape(changeset) do
case get_change(changeset, :translations) do
nil ->
changeset
val ->
if L10n.valid_translations_shape?(val) do
changeset
else
add_error(changeset, :translations, "is not a valid translations map")
end
end
end
# `enforce_scheduled_date_required: false` lets the form's `phx-change`
# validate the rest of the changeset without flagging the just-revealed
# date field as required before the user has had a chance to fill it.
# The save path passes the default (true) so submitting without a date
# still surfaces the inline error.
defp maybe_require_date(changeset, opts) do
enforce? = Keyword.get(opts, :enforce_scheduled_date_required, true)
if enforce? and get_field(changeset, :start_mode) == "scheduled" do
validate_required(changeset, [:scheduled_start_date],
message: gettext("required for scheduled projects")
)
else
changeset
end
end
def start_modes, do: @start_modes
@typedoc """
Human-meaningful lifecycle state derived from the persisted fields.
Combines the `archived_at` soft-hide flag, completion timestamps,
start mode, and the scheduled date into the label that's actually
meaningful in the UI.
"""
@type derived_state ::
:archived | :template | :completed | :running | :overdue | :scheduled | :setup
@doc """
Lifecycle state for this project, in priority order:
* `:archived` — soft-hidden (`archived_at` is set)
* `:template` — `is_template: true`
* `:completed` — `completed_at` is set
* `:running` — `started_at` is set and not yet completed
* `:overdue` — scheduled, the scheduled_start_date has passed, not started
* `:scheduled` — scheduled, start date still in the future, not started
* `:setup` — immediate start mode, not yet started
`now` is injected so callers can pin "now" for tests. The
scheduled-overdue check compares full timestamps, not just dates —
a project scheduled for today at 09:00 is `:overdue` by 17:00 the
same day.
"""
@spec derived_status(t(), DateTime.t()) :: derived_state()
def derived_status(%__MODULE__{} = p, now \\ DateTime.utc_now()) do
cond do
p.archived_at -> :archived
p.is_template -> :template
p.completed_at -> :completed
p.started_at -> :running
scheduled_overdue?(p, now) -> :overdue
p.start_mode == "scheduled" -> :scheduled
true -> :setup
end
end
defp scheduled_overdue?(
%__MODULE__{start_mode: "scheduled", scheduled_start_date: %DateTime{} = dt},
%DateTime{} = now
),
do: DateTime.compare(dt, now) == :lt
defp scheduled_overdue?(_, _), do: false
@doc """
Calendar `DateTime` when this project would be done if work consumes
`total_hours` of estimated work, starting from `started_at`.
For `counts_weekends: true` projects this is simple calendar add.
For weekday-only projects (`counts_weekends: false`) weekend days
contribute zero work hours: the calendar walks forward but only
weekday hours count toward the budget, scaled at the convention
`PhoenixKitProjects.Schemas.Task.to_hours/3` uses (8 work hours per
24-hour weekday). Starting
on a weekend skips to Monday before any budget is consumed.
Returns `nil` when the project hasn't started or has no estimated
work.
"""
@spec planned_end_for(t(), number()) :: DateTime.t() | nil
def planned_end_for(%__MODULE__{started_at: nil}, _hours), do: nil
def planned_end_for(_, hours) when hours <= 0, do: nil
def planned_end_for(%__MODULE__{started_at: %DateTime{} = start} = project, hours),
do: eta_from(project, start, hours)
@doc """
Calendar end-time for `hours` of work starting from `from`, honoring
the project's `counts_weekends` rule.
Sibling of `planned_end_for/2` anchored on an arbitrary datetime
instead of `started_at`. Used for the "if work continues at planned
pace from now, ETA is …" projection shown on the project page —
`eta_from(project, DateTime.utc_now(), remaining_hours)`.
Returns `nil` when `hours <= 0` (nothing left to schedule).
"""
@spec eta_from(t(), DateTime.t(), number()) :: DateTime.t() | nil
def eta_from(_project, _from, hours) when hours <= 0, do: nil
def eta_from(%__MODULE__{counts_weekends: true}, %DateTime{} = from, hours),
do: DateTime.add(from, round(hours * 3600), :second)
def eta_from(%__MODULE__{counts_weekends: false}, %DateTime{} = from, hours),
do: consume_weekday_budget(from, hours * 1.0)
# Walks calendar time forward from `current`, treating 24 calendar
# hours on a weekday as 8 work hours (a 3:1 calendar-to-work ratio).
# Weekend days advance the calendar without consuming budget.
defp consume_weekday_budget(current, hours_remaining) when hours_remaining <= 0,
do: current
defp consume_weekday_budget(%DateTime{} = current, hours_remaining) do
if weekday?(DateTime.to_date(current)) do
seconds_to_eod = seconds_until_end_of_day(current)
work_hours_today = seconds_to_eod / 3600 / 3.0
if hours_remaining <= work_hours_today do
DateTime.add(current, round(hours_remaining * 3.0 * 3600), :second)
else
next_day_start = next_calendar_day_start(current)
consume_weekday_budget(next_day_start, hours_remaining - work_hours_today)
end
else
consume_weekday_budget(next_calendar_day_start(current), hours_remaining)
end
end
defp weekday?(%Date{} = d), do: Date.day_of_week(d) <= 5
defp seconds_until_end_of_day(%DateTime{} = dt) do
time = DateTime.to_time(dt)
86_400 - (time.hour * 3600 + time.minute * 60 + time.second)
end
defp next_calendar_day_start(%DateTime{} = dt) do
next_date = Date.add(DateTime.to_date(dt), 1)
DateTime.new!(next_date, ~T[00:00:00.000], dt.time_zone)
end
@doc """
The list of fields that participate in `translations` JSONB storage.
Used by the form layer to drive `merge_translatable_params/4` and by
reads to know which keys to look up under each language code.
"""
@spec translatable_fields() :: [String.t()]
def translatable_fields, do: @translatable_fields
@doc """
Returns the project's name in the requested language, falling back to
the primary `name` column when the language has no override (or the
override is empty).
`lang` may be `nil` (e.g. when multilang is disabled) — in that case
the primary column is returned directly.
"""
@spec localized_name(t(), String.t() | nil) :: String.t() | nil
def localized_name(%__MODULE__{} = p, lang), do: localized_field(p, "name", lang)
@doc """
Returns the project's description in the requested language, with the
same primary-fallback semantics as `localized_name/2`.
"""
@spec localized_description(t(), String.t() | nil) :: String.t() | nil
def localized_description(%__MODULE__{} = p, lang), do: localized_field(p, "description", lang)
defp localized_field(p, field, lang) do
primary = Map.get(p, String.to_existing_atom(field))
case lookup_translation(p.translations, lang, field) do
nil -> primary
"" -> primary
val -> val
end
end
defp lookup_translation(translations, lang, field)
when is_map(translations) and is_binary(lang) do
case Map.get(translations, lang) do
%{} = lang_map -> Map.get(lang_map, field)
_ -> nil
end
end
defp lookup_translation(_translations, _lang, _field), do: nil
end