# `PhoenixKitProjects.Schemas.Project`
[🔗](https://github.com/BeamLabEU/phoenix_kit_projects/blob/v0.14.0/lib/phoenix_kit_projects/schemas/project.ex#L1)

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.

# `derived_state`

```elixir
@type derived_state() ::
  :archived | :template | :completed | :running | :overdue | :scheduled | :setup
```

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.

# `t`

```elixir
@type t() :: %PhoenixKitProjects.Schemas.Project{
  __meta__: term(),
  archived_at: DateTime.t() | nil,
  assigned_department:
    PhoenixKitStaff.Schemas.Department.t()
    | Ecto.Association.NotLoaded.t()
    | nil,
  assigned_department_uuid: UUIDv7.t() | nil,
  assigned_person:
    PhoenixKitStaff.Schemas.Person.t() | Ecto.Association.NotLoaded.t() | nil,
  assigned_person_uuid: UUIDv7.t() | nil,
  assigned_team:
    PhoenixKitStaff.Schemas.Team.t() | Ecto.Association.NotLoaded.t() | nil,
  assigned_team_uuid: UUIDv7.t() | nil,
  assignments:
    [PhoenixKitProjects.Schemas.Assignment.t()] | Ecto.Association.NotLoaded.t(),
  completed_at: DateTime.t() | nil,
  counts_weekends: boolean() | nil,
  current_status_slug: String.t() | nil,
  description: String.t() | nil,
  external_id: String.t() | nil,
  inserted_at: DateTime.t() | nil,
  is_template: boolean() | nil,
  name: String.t() | nil,
  position: integer() | nil,
  scheduled_start_date: DateTime.t() | nil,
  settings: map(),
  start_mode: String.t() | nil,
  started_at: DateTime.t() | nil,
  status_entity_uuid: UUIDv7.t() | nil,
  translations: translations_map(),
  updated_at: DateTime.t() | nil,
  uuid: UUIDv7.t() | nil
}
```

# `translations_map`

```elixir
@type translations_map() :: %{
  optional(String.t()) =&gt; %{optional(String.t()) =&gt; String.t()}
}
```

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.

# `changeset`

```elixir
@spec changeset(t(), map(), keyword()) :: Ecto.Changeset.t()
```

# `current_status_changeset`

```elixir
@spec current_status_changeset(t(), map()) :: Ecto.Changeset.t()
```

Changeset for the server-owned `current_status_slug` only.

Kept separate from `changeset/3` so a form submission can never
mass-assign the selected workflow status — it's only ever set through
`PhoenixKitProjects.Statuses.set_current_status/3`, which validates the
slug against the project's resolved status list first. `nil` clears it.
Mirrors `Assignment.status_changeset/2`'s isolation of server-owned
completion fields.

# `derived_status`

```elixir
@spec derived_status(t(), DateTime.t()) :: derived_state()
```

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.

# `eta_from`

```elixir
@spec eta_from(t(), DateTime.t(), number()) :: DateTime.t() | nil
```

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).

# `localized_description`

```elixir
@spec localized_description(t(), String.t() | nil) :: String.t() | nil
```

Returns the project's description in the requested language, with the
same primary-fallback semantics as `localized_name/2`.

# `localized_name`

```elixir
@spec localized_name(t(), String.t() | nil) :: String.t() | nil
```

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.

# `planned_end_for`

```elixir
@spec planned_end_for(t(), number()) :: DateTime.t() | nil
```

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.

# `start_modes`

```elixir
@spec start_modes() :: [String.t()]
```

# `status_translation_override`

```elixir
@spec status_translation_override(t()) :: boolean() | nil
```

Whether status titles should display in the viewer's content locale for
this project. Defaults to `true` — translations are always captured;
this flag only gates *display*. Stored under
`settings["use_status_translations"]` (only an explicit `false` disables).

# `translatable_fields`

```elixir
@spec translatable_fields() :: [String.t()]
```

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.

---

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