diff --git a/lib/cklist/accounts/user.ex b/lib/cklist/accounts/user.ex index 9447257..0c42bb7 100644 --- a/lib/cklist/accounts/user.ex +++ b/lib/cklist/accounts/user.ex @@ -8,6 +8,8 @@ defmodule Cklist.Accounts.User do field :hashed_password, :string, redact: true field :confirmed_at, :naive_datetime + has_many :checklists, Cklist.Checklists.Checklist + timestamps(type: :utc_datetime) end diff --git a/lib/cklist/checklists.ex b/lib/cklist/checklists.ex index 12e315f..ca8207b 100644 --- a/lib/cklist/checklists.ex +++ b/lib/cklist/checklists.ex @@ -4,9 +4,10 @@ defmodule Cklist.Checklists do """ import Ecto.Query, warn: false - alias Cklist.Repo - + alias Cklist.Checklists.Run + alias Cklist.Checklists.Activity alias Cklist.Checklists.Checklist + alias Cklist.Repo @doc """ Returns the list of checklists. @@ -107,4 +108,35 @@ defmodule Cklist.Checklists do def change_checklist(%Checklist{} = checklist, attrs \\ %{}) do Checklist.changeset(checklist, attrs) end + + def log_run_start(checklist, user) do + {:ok, run} = %Run{} + |> Run.changeset(%{checklist_id: checklist.id}) + |> Repo.insert() + + %Activity{} + |> Activity.changeset(%{run_id: run.id, user_id: user.id, event: %{type: "checklist_start"}}) + |> Repo.insert() + + # return current run + run + end + + def log_run_abort(run, user) do + %Activity{} + |> Activity.changeset(%{run_id: run.id, user_id: user.id, event: %{type: "checklist_abort"}}) + |> Repo.insert() + end + + def log_run_complete(run, user) do + %Activity{} + |> Activity.changeset(%{run_id: run.id, user_id: user.id, event: %{type: "checklist_complete"}}) + |> Repo.insert() + end + + def log_step_complete(run, user, step_id, is_done \\ true) do + %Activity{} + |> Activity.changeset(%{run_id: run.id, user_id: user.id, event: %{type: "step_done", step_id: step_id, done: is_done}}) + |> Repo.insert() + end end diff --git a/lib/cklist/checklists/activity.ex b/lib/cklist/checklists/activity.ex new file mode 100644 index 0000000..2923100 --- /dev/null +++ b/lib/cklist/checklists/activity.ex @@ -0,0 +1,22 @@ +defmodule Cklist.Checklists.Activity do + use Ecto.Schema + import Ecto.Changeset + + schema "activity" do + # We assume events have a type. Any other properties are optional and type-dependent. + field :event, :map + + belongs_to :run, Cklist.Checklists.Run + belongs_to :user, Cklist.Accounts.User + + # We don't expect activities to be modified. + timestamps([type: :utc_datetime, updated_at: false]) + end + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, [:event, :run_id, :user_id]) + |> validate_required([:event, :run_id, :user_id]) + end +end diff --git a/lib/cklist/checklists/checklist.ex b/lib/cklist/checklists/checklist.ex index 7f86ecc..3b618d3 100644 --- a/lib/cklist/checklists/checklist.ex +++ b/lib/cklist/checklists/checklist.ex @@ -6,9 +6,11 @@ defmodule Cklist.Checklists.Checklist do field :description, :string field :title, :string field :document, :map - field :user_id, :id field :access, Ecto.Enum, values: [:public, :personal] + belongs_to :user, Cklist.Accounts.User + has_many :runs, Cklist.Checklists.Run + timestamps(type: :utc_datetime) end diff --git a/lib/cklist/checklists/run.ex b/lib/cklist/checklists/run.ex new file mode 100644 index 0000000..7b22ada --- /dev/null +++ b/lib/cklist/checklists/run.ex @@ -0,0 +1,16 @@ +defmodule Cklist.Checklists.Run do + use Ecto.Schema + import Ecto.Changeset + + schema "runs" do + belongs_to :checklist, Cklist.Checklists.Checklist + has_many :activities, Cklist.Checklists.Activity + end + + @doc false + def changeset(run, attrs) do + run + |> cast(attrs, [:checklist_id]) + |> validate_required([:checklist_id]) + end +end diff --git a/lib/cklist_web/live/cklist_run_live.ex b/lib/cklist_web/live/cklist_run_live.ex index 02c528a..2d10f18 100644 --- a/lib/cklist_web/live/cklist_run_live.ex +++ b/lib/cklist_web/live/cklist_run_live.ex @@ -8,9 +8,14 @@ defmodule CklistWeb.CklistRunLive do end def mount(%{"id" => id}, _session, socket) do + user = socket.assigns.current_user + checklist = Checklists.get_checklist!(id) + run = Checklists.log_run_start(checklist, user) + socket = socket |> assign(:checklist, checklist) + |> assign(:run, run) |> assign(:steps, length(checklist.document["steps"])) |> assign(:steps_done, 0) |> assign(:completed, false) @@ -21,21 +26,9 @@ defmodule CklistWeb.CklistRunLive do {:ok, socket} end - def handle_event("step_done", params, %{assigns: assigns} = socket) do - [step_name] = params["_target"] - updated_state = Map.put(assigns.step_state, step_name, Map.get(params, step_name) == "true") - updated_steps_done = Enum.reduce(updated_state, 0, &is_done/2) - - { - :noreply, - socket - |> assign(:step_state, updated_state) - |> assign(:steps_done, updated_steps_done) - |> assign(:completed, updated_steps_done === assigns.steps) - } - end - + # Handles aborting a checklist. def handle_event("abort", _params, %{assigns: assigns} = socket) do + Checklists.log_run_abort(assigns.run, assigns.current_user) { :noreply, socket @@ -44,6 +37,7 @@ defmodule CklistWeb.CklistRunLive do } end + # Handles "next_step" event for sequential checklists. def handle_event("next_step", _params, %{assigns: assigns} = socket) do case assigns.completed do true -> @@ -54,19 +48,47 @@ defmodule CklistWeb.CklistRunLive do |> redirect(to: ~p"/checklists/#{assigns.checklist}") } false -> + Checklists.log_step_complete(assigns.run, assigns.current_user, assigns.steps_done) updated_steps_done = assigns.steps_done + 1 completed = assigns.steps === updated_steps_done + if completed do + Checklists.log_run_complete(assigns.run, assigns.current_user) + end { :noreply, socket - |> assign(:steps_done, updated_steps_done) |> assign(:current_step, Enum.at(assigns.checklist.document["steps"], updated_steps_done)) |> assign(:next_step, Enum.at(assigns.checklist.document["steps"], updated_steps_done + 1)) + |> assign(:steps_done, updated_steps_done) |> assign(:completed, completed) } end end + # Handles "step_done" events for non-sequential checklists. + def handle_event("step_done", params, %{assigns: assigns} = socket) do + [step_name] = params["_target"] + is_done = Map.get(params, step_name) == "true" + updated_state = Map.put(assigns.step_state, step_name, is_done) + updated_steps_done = Enum.reduce(updated_state, 0, &is_done/2) + + step_id = Enum.find_index(assigns.checklist.document["steps"], &(&1["name"] == step_name)) + Checklists.log_step_complete(assigns.run, assigns.current_user, step_id, is_done) + + completed = updated_steps_done === assigns.steps + if completed do + Checklists.log_run_complete(assigns.run, assigns.current_user) + end + + { + :noreply, + socket + |> assign(:step_state, updated_state) + |> assign(:steps_done, updated_steps_done) + |> assign(:completed, completed) + } + end + defp is_done({_, true}, count), do: count + 1 defp is_done(_, count), do: count end diff --git a/priv/repo/migrations/20240310152508_create_checklist_run.exs b/priv/repo/migrations/20240310152508_create_checklist_run.exs new file mode 100644 index 0000000..55b40cb --- /dev/null +++ b/priv/repo/migrations/20240310152508_create_checklist_run.exs @@ -0,0 +1,9 @@ +defmodule Cklist.Repo.Migrations.CreateChecklistRun do + use Ecto.Migration + + def change do + create table(:runs) do + add :checklist_id, references(:checklists) + end + end +end diff --git a/priv/repo/migrations/20240310153254_create_activity_log.exs b/priv/repo/migrations/20240310153254_create_activity_log.exs new file mode 100644 index 0000000..78af720 --- /dev/null +++ b/priv/repo/migrations/20240310153254_create_activity_log.exs @@ -0,0 +1,14 @@ +defmodule Cklist.Repo.Migrations.CreateActivityLog do + use Ecto.Migration + + def change do + create table(:activity) do + add :event, :map + + add :user_id, references(:users) + add :run_id, references(:runs) + + timestamps([type: :utc_datetime, updated_at: false]) + end + end +end