GanttEx.Store (GanttEx v0.1.0)

Copy Markdown View Source

An immutable, in-memory container of tasks and links.

The store keeps tasks as an ordered tree (siblings keep the order they were given in), computes missing dates/durations, rolls up the dates of :project tasks from their children and validates dependencies.

Every mutating function returns {:ok, store, changes}, where changes is the list of everything that changed, including side effects such as the dates of a parent project or the links removed with a task. GanttEx.Live.Gantt turns each change into a GanttEx.Event for the parent LiveView.

Examples

iex> {:ok, store} =
...>   GanttEx.Store.new(
...>     [
...>       %{id: 1, text: "Website", type: :project},
...>       %{id: 2, text: "Design", parent: 1, start_date: ~D[2025-01-06], duration: 3},
...>       %{id: 3, text: "Build", parent: 1, start_date: ~D[2025-01-09], end_date: ~D[2025-01-16]}
...>     ],
...>     [%{id: 1, source: 2, target: 3}]
...>   )
iex> project = GanttEx.Store.get_task(store, 1)
iex> {project.start_date, project.end_date, project.duration}
{~N[2025-01-06 00:00:00], ~N[2025-01-16 00:00:00], 10}
iex> {:ok, store, changes} = GanttEx.Store.update_task(store, 3, %{duration: 9})
iex> Enum.map(changes, fn {type, task, _details} -> {type, task.id} end)
[task_updated: 3, task_updated: 1]
iex> GanttEx.Store.get_task(store, 1).end_date
~N[2025-01-18 00:00:00]

Summary

Types

A change produced by a mutation: {type, entity, details}.

Why an operation was rejected.

A string version of an id, as used in the DOM and in client events.

A visible row, as returned by rows/2.

t()

Functions

Adds amount duration units to datetime for task, honoring its calendar.

Adds a link.

Adds a task. The parent is taken from the task's :parent field.

Returns the ids of the ancestors of id, closest first.

Returns the calendar that applies to task (its :calendar_id, or the global calendar), or nil when the store does not use working time.

Returns the ids of the direct children of id (nil for the root).

Returns the direct children of id (nil for the root).

Merges repeated :task_updated changes of the same task, keeping the position of the first one and the last value.

Returns {earliest_start, latest_end} across all tasks, or nil when empty.

Deletes a link.

Deletes a task with all its descendants and every link attached to them.

Returns the ids of all descendants of id, depth first.

Returns the changes that turn old into new, in an order that can be applied to a database: deleted links, deleted tasks (deepest first), created tasks (parents first), updated tasks, moved tasks, created links and updated links.

Returns the duration between two datetimes for task, honoring its calendar.

Returns the duration unit of the store.

Fetches a link by id.

Fetches a task by id.

Gets a link by id, or nil.

Gets a task by id, or nil.

Returns true if the task has children.

Returns the position of a task among its siblings.

Converts an id into its string key (as used in DOM ids and client events).

Returns the depth of a task (0 for root tasks).

Gets a link by its string key, or nil.

Returns why a link cannot be added, or nil when it is valid.

Returns all links in insertion order.

Returns the links where the task is the source or the target.

Moves a task under parent (nil for the root) at index among its new siblings (nil appends). A task cannot be moved inside itself or one of its descendants.

Builds a store from lists of tasks and links (anything accepted by GanttEx.Task.new/1 and GanttEx.Link.new/1).

Like new/3 but raises ArgumentError when the data is invalid.

Returns the visible rows, in display order.

Completes the dates of a single task for the given duration unit.

Gets a task by its string key (see key/1), or nil.

Returns the number of tasks.

Returns all tasks in tree order (depth first).

Returns the data in the DHTMLX JSON shape: %{data: [task_map], links: [link_map]}.

Updates the :type, :lag, :readonly, :color or meta fields of a link.

Updates a task with the given attributes.

Returns the WBS code (outline number) of every task, such as "1.2.3".

Types

change()

@type change() ::
  {:task_created | :task_updated | :task_deleted | :task_moved,
   GanttEx.Task.t(), map()}
  | {:link_created | :link_updated | :link_deleted, GanttEx.Link.t(), map()}

A change produced by a mutation: {type, entity, details}.

  • :task_created, :task_deleted, :link_created, :link_deleted have empty details
  • :task_updated and :link_updated details contain :changes, a map of the changed fields and their new values
  • :task_moved details contain :parent, :index and :siblings (the ordered ids of the new siblings, including the moved task)

error()

@type error() ::
  GanttEx.Task.error()
  | {:invalid_task, GanttEx.Task.id(), term()}
  | {:invalid_link, GanttEx.Task.id(), term()}
  | {:duplicate_id, :task | :link, GanttEx.Task.id()}
  | {:parent_cycle, GanttEx.Task.id()}
  | :not_found
  | :missing_parent
  | :invalid_parent
  | :self_link
  | :missing_task
  | :duplicate_link
  | :cycle

Why an operation was rejected.

key()

@type key() :: String.t()

A string version of an id, as used in the DOM and in client events.

row()

@type row() :: %{
  task: GanttEx.Task.t(),
  key: key(),
  index: non_neg_integer(),
  level: non_neg_integer(),
  parent_key: key() | nil,
  has_children: boolean(),
  open: boolean(),
  wbs: String.t(),
  uid: String.t(),
  group: boolean(),
  count: non_neg_integer() | nil
}

A visible row, as returned by rows/2.

t()

@type t() :: %GanttEx.Store{
  auto_types: boolean(),
  calendar: GanttEx.Calendar.t() | nil,
  calendars: %{required(term()) => GanttEx.Calendar.t()},
  children: %{required(GanttEx.Task.id() | nil) => [GanttEx.Task.id()]},
  duration_unit: GanttEx.Dates.unit(),
  link_keys: %{required(key()) => GanttEx.Task.id()},
  link_order: [GanttEx.Task.id()],
  links: %{required(GanttEx.Task.id()) => GanttEx.Link.t()},
  task_keys: %{required(key()) => GanttEx.Task.id()},
  tasks: %{required(GanttEx.Task.id()) => GanttEx.Task.t()},
  work_time: boolean()
}

Functions

add_duration(store, task, datetime, amount)

@spec add_duration(t(), GanttEx.Task.t() | nil, NaiveDateTime.t(), number()) ::
  NaiveDateTime.t()

Adds amount duration units to datetime for task, honoring its calendar.

iex> store = GanttEx.Store.new!([], [], work_time: true)
iex> GanttEx.Store.add_duration(store, nil, ~N[2025-01-10 00:00:00], 1)
~N[2025-01-11 00:00:00]
iex> GanttEx.Store.add_duration(store, nil, ~N[2025-01-10 00:00:00], 2)
~N[2025-01-14 00:00:00]

add_link(store, attrs)

@spec add_link(t(), term()) :: {:ok, t(), [change()]} | {:error, error()}

Adds a link.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, start_date: ~D[2025-01-01]}])
iex> {:ok, store, [{:link_created, link, %{}}]} = GanttEx.Store.add_link(store, %{id: "l1", source: 1, target: 2})
iex> link.type
:finish_to_start
iex> GanttEx.Store.add_link(store, %{id: "l2", source: 2, target: 1})
{:error, :cycle}

add_task(store, attrs, opts \\ [])

@spec add_task(t(), term(), keyword()) :: {:ok, t(), [change()]} | {:error, error()}

Adds a task. The parent is taken from the task's :parent field.

Options

  • :index - position among its siblings. Defaults to appending.

Examples

iex> store = GanttEx.Store.new!([%{id: 1, type: :project, start_date: ~D[2025-01-01]}])
iex> {:ok, store, changes} = GanttEx.Store.add_task(store, %{id: 2, parent: 1, start_date: ~D[2025-01-02], duration: 4})
iex> Enum.map(changes, fn {type, task, _} -> {type, task.id} end)
[task_created: 2, task_updated: 1]
iex> GanttEx.Store.add_task(store, %{id: 3, parent: 99, start_date: ~D[2025-01-01]})
{:error, :missing_parent}

ancestor_ids(store, id)

@spec ancestor_ids(t(), GanttEx.Task.id()) :: [GanttEx.Task.id()]

Returns the ids of the ancestors of id, closest first.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, parent: 1, start_date: ~D[2025-01-01]}, %{id: 3, parent: 2, start_date: ~D[2025-01-01]}])
iex> GanttEx.Store.ancestor_ids(store, 3)
[2, 1]

calendar_for(store, arg2)

@spec calendar_for(t(), GanttEx.Task.t() | nil) :: GanttEx.Calendar.t() | nil

Returns the calendar that applies to task (its :calendar_id, or the global calendar), or nil when the store does not use working time.

child_ids(store, id)

@spec child_ids(t(), GanttEx.Task.id() | nil) :: [GanttEx.Task.id()]

Returns the ids of the direct children of id (nil for the root).

children(store, id)

@spec children(t(), GanttEx.Task.id() | nil) :: [GanttEx.Task.t()]

Returns the direct children of id (nil for the root).

compact(changes)

@spec compact([change()]) :: [change()]

Merges repeated :task_updated changes of the same task, keeping the position of the first one and the last value.

iex> a = GanttEx.Task.new!(id: 1, text: "A")
iex> b = %{a | text: "B"}
iex> GanttEx.Store.compact([{:task_updated, a, %{changes: %{start_date: 1}}}, {:task_updated, b, %{changes: %{end_date: 2}}}])
[{:task_updated, b, %{changes: %{start_date: 1, end_date: 2}}}]

date_range(store)

@spec date_range(t()) :: {NaiveDateTime.t(), NaiveDateTime.t()} | nil

Returns {earliest_start, latest_end} across all tasks, or nil when empty.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-03], duration: 2}, %{id: 2, start_date: ~D[2025-01-01], duration: 1}])
iex> GanttEx.Store.date_range(store)
{~N[2025-01-01 00:00:00], ~N[2025-01-05 00:00:00]}

delete_link(store, id)

@spec delete_link(t(), GanttEx.Task.id()) ::
  {:ok, t(), [change()]} | {:error, error()}

Deletes a link.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, start_date: ~D[2025-01-01]}], [%{id: 1, source: 1, target: 2}])
iex> {:ok, store, [{:link_deleted, _link, %{}}]} = GanttEx.Store.delete_link(store, 1)
iex> GanttEx.Store.links(store)
[]

delete_task(store, id)

@spec delete_task(t(), GanttEx.Task.id()) ::
  {:ok, t(), [change()]} | {:error, error()}

Deletes a task with all its descendants and every link attached to them.

Changes are reported links first, then tasks from the deepest descendant up to the deleted task, then the updated parent projects.

iex> store = GanttEx.Store.new!(
...>   [%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, parent: 1, start_date: ~D[2025-01-01]}, %{id: 3, start_date: ~D[2025-01-01]}],
...>   [%{id: 10, source: 2, target: 3}]
...> )
iex> {:ok, store, changes} = GanttEx.Store.delete_task(store, 1)
iex> Enum.map(changes, fn {type, entity, _} -> {type, entity.id} end)
[link_deleted: 10, task_deleted: 2, task_deleted: 1]
iex> GanttEx.Store.task_count(store)
1

descendant_ids(store, id)

@spec descendant_ids(t(), GanttEx.Task.id() | nil) :: [GanttEx.Task.id()]

Returns the ids of all descendants of id, depth first.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, parent: 1, start_date: ~D[2025-01-01]}, %{id: 3, parent: 2, start_date: ~D[2025-01-01]}])
iex> GanttEx.Store.descendant_ids(store, 1)
[2, 3]

diff(old, new)

@spec diff(t(), t()) :: [change()]

Returns the changes that turn old into new, in an order that can be applied to a database: deleted links, deleted tasks (deepest first), created tasks (parents first), updated tasks, moved tasks, created links and updated links.

GanttEx.History uses it to report what undo and redo change.

iex> old = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, start_date: ~D[2025-01-02]}], [%{id: 1, source: 1, target: 2}])
iex> {:ok, new, _} = GanttEx.Store.delete_task(old, 2)
iex> new |> GanttEx.Store.diff(old) |> Enum.map(fn {type, entity, _} -> {type, entity.id} end)
[task_created: 2, link_created: 1]
iex> old |> GanttEx.Store.diff(new) |> Enum.map(fn {type, entity, _} -> {type, entity.id} end)
[link_deleted: 1, task_deleted: 2]

duration_between(store, task, from, to)

@spec duration_between(
  t(),
  GanttEx.Task.t() | nil,
  NaiveDateTime.t(),
  NaiveDateTime.t()
) :: integer()

Returns the duration between two datetimes for task, honoring its calendar.

iex> store = GanttEx.Store.new!([], [], work_time: true)
iex> GanttEx.Store.duration_between(store, nil, ~N[2025-01-10 00:00:00], ~N[2025-01-14 00:00:00])
2

duration_unit(store)

@spec duration_unit(t()) :: GanttEx.Dates.unit()

Returns the duration unit of the store.

fetch_link(store, id)

@spec fetch_link(t(), GanttEx.Task.id()) :: {:ok, GanttEx.Link.t()} | :error

Fetches a link by id.

fetch_task(store, id)

@spec fetch_task(t(), GanttEx.Task.id()) :: {:ok, GanttEx.Task.t()} | :error

Fetches a task by id.

get_link(store, id)

@spec get_link(t(), GanttEx.Task.id()) :: GanttEx.Link.t() | nil

Gets a link by id, or nil.

get_task(store, id)

@spec get_task(t(), GanttEx.Task.id()) :: GanttEx.Task.t() | nil

Gets a task by id, or nil.

has_children?(store, id)

@spec has_children?(t(), GanttEx.Task.id()) :: boolean()

Returns true if the task has children.

index(store, id)

@spec index(t(), GanttEx.Task.id()) :: non_neg_integer() | nil

Returns the position of a task among its siblings.

key(id)

@spec key(GanttEx.Task.id()) :: key()

Converts an id into its string key (as used in DOM ids and client events).

iex> GanttEx.Store.key(42)
"42"

iex> GanttEx.Store.key("a-1")
"a-1"

level(store, id)

@spec level(t(), GanttEx.Task.id()) :: non_neg_integer()

Returns the depth of a task (0 for root tasks).

links(store)

@spec links(t()) :: [GanttEx.Link.t()]

Returns all links in insertion order.

move_task(store, id, parent, index)

@spec move_task(
  t(),
  GanttEx.Task.id(),
  GanttEx.Task.id() | nil,
  non_neg_integer() | nil
) ::
  {:ok, t(), [change()]} | {:error, error()}

Moves a task under parent (nil for the root) at index among its new siblings (nil appends). A task cannot be moved inside itself or one of its descendants.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, start_date: ~D[2025-01-01]}, %{id: 3, start_date: ~D[2025-01-01]}])
iex> {:ok, store, [{:task_moved, _task, details}]} = GanttEx.Store.move_task(store, 3, nil, 0)
iex> details
%{parent: nil, index: 0, siblings: [3, 1, 2]}
iex> {:ok, store, _} = GanttEx.Store.move_task(store, 2, 1, nil)
iex> GanttEx.Store.move_task(store, 1, 2, nil)
{:error, :invalid_parent}

new(tasks \\ [], links \\ [], opts \\ [])

@spec new([term()], [term()], keyword()) :: {:ok, t()} | {:error, error()}

Builds a store from lists of tasks and links (anything accepted by GanttEx.Task.new/1 and GanttEx.Link.new/1).

Options

  • :duration_unit - unit of :duration and :lag values. Defaults to :day.
  • :work_time - count only working time in durations (see GanttEx.Calendar). Defaults to false.
  • :calendar - the global calendar (a GanttEx.Calendar or its options). Defaults to Monday to Friday, 08:00-12:00 and 13:00-17:00.
  • :calendars - other calendars, referenced by the :calendar_id of tasks.
  • :auto_types - tasks with children become projects and projects without children become tasks, also after every mutation. Defaults to false.

Dates are completed as follows: when both :start_date and :end_date are given the duration is computed (the end date wins over a given duration); with a start date and a duration the end date is computed; a lone start date gets a duration of 1. Milestones always end when they start.

Tasks pointing to an unknown parent become root tasks and links pointing to unknown tasks are ignored.

iex> {:ok, store} = GanttEx.Store.new([%{id: "a", start_date: "2025-02-01 09:00"}], [], duration_unit: :hour)
iex> GanttEx.Store.get_task(store, "a").end_date
~N[2025-02-01 10:00:00]

iex> GanttEx.Store.new([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 1, start_date: ~D[2025-01-01]}])
{:error, {:duplicate_id, :task, 1}}

iex> GanttEx.Store.new([%{id: 1, text: "Undated"}])
{:error, {:invalid_task, 1, {:missing, :start_date}}}

With working time, a 2-day task starting on Friday ends on Tuesday, and a task starting on a weekend starts on Monday:

iex> {:ok, store} = GanttEx.Store.new([%{id: 1, start_date: ~D[2025-01-10], duration: 2}, %{id: 2, start_date: ~D[2025-01-11], duration: 1}], [], work_time: true)
iex> {GanttEx.Store.get_task(store, 1).end_date, GanttEx.Store.get_task(store, 2).start_date}
{~N[2025-01-14 00:00:00], ~N[2025-01-13 00:00:00]}

new!(tasks \\ [], links \\ [], opts \\ [])

@spec new!([term()], [term()], keyword()) :: t()

Like new/3 but raises ArgumentError when the data is invalid.

rows(store, opts \\ [])

@spec rows(t(), keyword()) :: [row()]

Returns the visible rows, in display order.

Options

  • :collapsed - a MapSet of task ids whose children are hidden.
  • :filter - a 1-arity function; only matching tasks (and their ancestors, to keep the tree readable) are returned. Collapsed branches are expanded while filtering.
  • :sort - {field, :asc | :desc} to sort siblings by a task field or a :meta key. The tree structure is preserved.

  • :group_by - {group_fun, label_fun} to group tasks (projects with children excluded) under virtual group rows. group_fun returns the group of a task, a list of groups, or nil; label_fun returns the label of a group. Group rows have group: true, a virtual project task spanning its members and count; collapsed groups are identified by {:group, value} in :collapsed. Rows have a unique uid, as a task can appear in several groups.

Examples

iex> store = GanttEx.Store.new!([
...>   %{id: 1, text: "B", start_date: ~D[2025-01-01]},
...>   %{id: 2, text: "child", parent: 1, start_date: ~D[2025-01-01]},
...>   %{id: 3, text: "A", start_date: ~D[2025-01-01]}
...> ])
iex> store |> GanttEx.Store.rows() |> Enum.map(&{&1.key, &1.level})
[{"1", 0}, {"2", 1}, {"3", 0}]
iex> store |> GanttEx.Store.rows(collapsed: MapSet.new([1])) |> Enum.map(& &1.key)
["1", "3"]
iex> store |> GanttEx.Store.rows(sort: {:text, :asc}) |> Enum.map(& &1.task.text)
["A", "B", "child"]
iex> store |> GanttEx.Store.rows(filter: &(&1.text == "child")) |> Enum.map(& &1.key)
["1", "2"]

schedule(task, unit, changed \\ [], calendar \\ nil)

@spec schedule(
  GanttEx.Task.t(),
  GanttEx.Dates.unit(),
  [atom()],
  GanttEx.Calendar.t() | nil
) ::
  {:ok, GanttEx.Task.t()} | {:error, GanttEx.Task.error()}

Completes the dates of a single task for the given duration unit.

iex> task = GanttEx.Task.new!(id: 1, start_date: ~D[2025-01-01], duration: 2)
iex> {:ok, task} = GanttEx.Store.schedule(task, :day)
iex> task.end_date
~N[2025-01-03 00:00:00]

iex> task = GanttEx.Task.new!(id: 1, start_date: ~D[2025-01-05], end_date: ~D[2025-01-01])
iex> GanttEx.Store.schedule(task, :day)
{:error, {:invalid, :end_date}}

task_by_key(store, key)

@spec task_by_key(t(), key()) :: GanttEx.Task.t() | nil

Gets a task by its string key (see key/1), or nil.

iex> store = GanttEx.Store.new!([%{id: 7, start_date: ~D[2025-01-01]}])
iex> GanttEx.Store.task_by_key(store, "7").id
7

task_count(store)

@spec task_count(t()) :: non_neg_integer()

Returns the number of tasks.

tasks(store)

@spec tasks(t()) :: [GanttEx.Task.t()]

Returns all tasks in tree order (depth first).

iex> store = GanttEx.Store.new!([%{id: 2, parent: 1, start_date: ~D[2025-01-01]}, %{id: 1, start_date: ~D[2025-01-01]}, %{id: 3, start_date: ~D[2025-01-01]}])
iex> store |> GanttEx.Store.tasks() |> Enum.map(& &1.id)
[1, 2, 3]

to_data(store)

@spec to_data(t()) :: %{data: [map()], links: [map()]}

Returns the data in the DHTMLX JSON shape: %{data: [task_map], links: [link_map]}.

iex> store = GanttEx.Store.new!([%{id: 1, text: "A", start_date: ~D[2025-01-01]}])
iex> %{data: [task], links: []} = GanttEx.Store.to_data(store)
iex> Map.take(task, [:id, :text, :duration])
%{id: 1, text: "A", duration: 1}

update_link(store, id, attrs)

@spec update_link(t(), GanttEx.Task.id(), map() | keyword()) ::
  {:ok, t(), [change()]} | {:error, error()}

Updates the :type, :lag, :readonly, :color or meta fields of a link.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, start_date: ~D[2025-01-01]}], [%{id: 1, source: 1, target: 2}])
iex> {:ok, _store, [{:link_updated, link, %{changes: changes}}]} = GanttEx.Store.update_link(store, 1, %{lag: 2})
iex> {link.lag, changes}
{2, %{lag: 2}}

update_task(store, id, attrs)

@spec update_task(t(), GanttEx.Task.id(), map() | keyword()) ::
  {:ok, t(), [change()]} | {:error, error()}

Updates a task with the given attributes.

Dates are kept consistent: changing only the start moves the task (the duration is kept), changing the end or the duration resizes it. Changing :parent moves the task to the end of its new parent.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01], duration: 2}])
iex> {:ok, store, [{:task_updated, task, %{changes: changes}}]} = GanttEx.Store.update_task(store, 1, %{start_date: ~D[2025-01-10]})
iex> {task.end_date, changes}
{~N[2025-01-12 00:00:00], %{start_date: ~N[2025-01-10 00:00:00], end_date: ~N[2025-01-12 00:00:00]}}
iex> GanttEx.Store.update_task(store, 1, %{end_date: ~D[2024-01-01]})
{:error, {:invalid, :end_date}}

wbs_codes(store)

@spec wbs_codes(t()) :: %{required(GanttEx.Task.id()) => String.t()}

Returns the WBS code (outline number) of every task, such as "1.2.3".

Codes follow the order of the tree, whatever the sorting or filtering of the rows.

iex> store = GanttEx.Store.new!([%{id: 1, start_date: ~D[2025-01-01]}, %{id: 2, parent: 1, start_date: ~D[2025-01-01]}, %{id: 3, parent: 1, start_date: ~D[2025-01-01]}, %{id: 4, start_date: ~D[2025-01-01]}])
iex> GanttEx.Store.wbs_codes(store)
%{1 => "1", 2 => "1.1", 3 => "1.2", 4 => "2"}