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.
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
@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_deletedhave empty details:task_updatedand:link_updateddetails contain:changes, a map of the changed fields and their new values:task_moveddetails contain:parent,:indexand:siblings(the ordered ids of the new siblings, including the moved task)
@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.
@type key() :: String.t()
A string version of an id, as used in the DOM and in client events.
@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.
@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
@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]
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}
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}
@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]
@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.
@spec child_ids(t(), GanttEx.Task.id() | nil) :: [GanttEx.Task.id()]
Returns the ids of the direct children of id (nil for the root).
@spec children(t(), GanttEx.Task.id() | nil) :: [GanttEx.Task.t()]
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.
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}}}]
@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]}
@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)
[]
@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
@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]
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]
@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
@spec duration_unit(t()) :: GanttEx.Dates.unit()
Returns the duration unit of the store.
@spec fetch_link(t(), GanttEx.Task.id()) :: {:ok, GanttEx.Link.t()} | :error
Fetches a link by id.
@spec fetch_task(t(), GanttEx.Task.id()) :: {:ok, GanttEx.Task.t()} | :error
Fetches a task by id.
@spec get_link(t(), GanttEx.Task.id()) :: GanttEx.Link.t() | nil
Gets a link by id, or nil.
@spec get_task(t(), GanttEx.Task.id()) :: GanttEx.Task.t() | nil
Gets a task by id, or nil.
@spec has_children?(t(), GanttEx.Task.id()) :: boolean()
Returns true if the task has children.
@spec index(t(), GanttEx.Task.id()) :: non_neg_integer() | nil
Returns the position of a task among its siblings.
@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"
@spec level(t(), GanttEx.Task.id()) :: non_neg_integer()
Returns the depth of a task (0 for root tasks).
@spec link_by_key(t(), key()) :: GanttEx.Link.t() | nil
Gets a link by its string key, or nil.
@spec link_error(t(), GanttEx.Link.t()) :: nil | :self_link | :missing_task | :duplicate_link | :cycle
Returns why a link cannot be added, or nil when it is valid.
A link is rejected when it connects a task to itself, references a missing task, duplicates an existing dependency between the same tasks, or would create a circular dependency.
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> GanttEx.Store.link_error(store, GanttEx.Link.new!(id: 2, source: 2, target: 1))
:cycle
iex> GanttEx.Store.link_error(store, GanttEx.Link.new!(id: 2, source: 1, target: 1))
:self_link
iex> GanttEx.Store.link_error(store, GanttEx.Link.new!(id: 2, source: 1, target: 2))
:duplicate_link
iex> GanttEx.Store.link_error(store, GanttEx.Link.new!(id: 2, source: 1, target: 2, type: :start_to_start))
nil
@spec links(t()) :: [GanttEx.Link.t()]
Returns all links in insertion order.
@spec links_for(t(), GanttEx.Task.id()) :: [GanttEx.Link.t()]
Returns the links where the task is the source or the target.
@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}
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:durationand:lagvalues. Defaults to:day.:work_time- count only working time in durations (seeGanttEx.Calendar). Defaults tofalse.:calendar- the global calendar (aGanttEx.Calendaror its options). Defaults to Monday to Friday, 08:00-12:00 and 13:00-17:00.:calendars- other calendars, referenced by the:calendar_idof tasks.:auto_types- tasks with children become projects and projects without children become tasks, also after every mutation. Defaults tofalse.
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]}
Like new/3 but raises ArgumentError when the data is invalid.
Returns the visible rows, in display order.
Options
:collapsed- aMapSetof 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:metakey. The tree structure is preserved.:group_by-{group_fun, label_fun}to group tasks (projects with children excluded) under virtual group rows.group_funreturns the group of a task, a list of groups, ornil;label_funreturns the label of a group. Group rows havegroup: true, a virtual project task spanning its members andcount; collapsed groups are identified by{:group, value}in:collapsed. Rows have a uniqueuid, 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"]
@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}}
@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
@spec task_count(t()) :: non_neg_integer()
Returns the number of tasks.
@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]
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}
@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}}
@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}}
@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"}