diff --git a/assets/css/_board.css b/assets/css/_board.css
new file mode 100644
index 0000000..a5c5a60
--- /dev/null
+++ b/assets/css/_board.css
@@ -0,0 +1,7 @@
+.bg-boardwhite {
+ background-color: #f2f5f3; /* light square */
+}
+
+.bg-boardblack {
+ background-color: #71828f; /* dark square */
+}
\ No newline at end of file
diff --git a/assets/css/app.css b/assets/css/app.css
index 3650ca2..0f151d4 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -8,6 +8,7 @@
@import "../node_modules/chessground/assets/chessground.brown.css";
@import "../node_modules/chessground/assets/chessground.cburnett.css";
+@import "./_board.css";
@import "./_promotion.css";
@source "../css";
diff --git a/lib/chesstrainer/application.ex b/lib/chesstrainer/application.ex
index c79671b..65f5b77 100644
--- a/lib/chesstrainer/application.ex
+++ b/lib/chesstrainer/application.ex
@@ -15,7 +15,9 @@ defmodule Chesstrainer.Application do
# Start a worker by calling: Chesstrainer.Worker.start_link(arg)
# {Chesstrainer.Worker, arg},
# Start to serve requests, typically the last entry
- ChesstrainerWeb.Endpoint
+ ChesstrainerWeb.Endpoint,
+ {Registry, keys: :unique, name: Chesstrainer.CacheRegistry},
+ {Chesstrainer.Cache, :tablebase}
]
# See https://elixir.hexdocs.pm/Supervisor.html
diff --git a/lib/chesstrainer/cache.ex b/lib/chesstrainer/cache.ex
new file mode 100644
index 0000000..d397652
--- /dev/null
+++ b/lib/chesstrainer/cache.ex
@@ -0,0 +1,101 @@
+defmodule Chesstrainer.Cache do
+ @moduledoc """
+ Generic LRU cache.
+
+ ## Examples
+
+ iex> start_link(:tablebase)
+ """
+
+ use GenServer
+
+ @max_size 1000
+ @backup_interval_minutes 60
+
+ def start_link(table), do: GenServer.start_link(__MODULE__, table, name: via_tuple(table))
+ def put(table, key, value), do: GenServer.call(via_tuple(table), {:put, key, value})
+ def get(table, key), do: GenServer.call(via_tuple(table), {:get, key})
+ def delete(table, key), do: GenServer.call(via_tuple(table), {:delete, key})
+ def backup(table), do: GenServer.cast(via_tuple(table), :backup)
+
+ def show(table) do
+ ensure_table(table)
+ :ets.tab2list(table)
+ end
+
+ def size(table), do: :ets.info(table, :size)
+
+ @impl true
+ def init(table) do
+ {:ok, table, {:continue, :restore_and_backup}}
+ end
+
+ @impl true
+ def handle_continue(:restore_and_backup, table) do
+ create_table(table)
+ __MODULE__.Persist.restore(table)
+ :timer.send_interval(:timer.minutes(@backup_interval_minutes), :backup)
+ {:noreply, table}
+ end
+
+ @impl true
+ def handle_call({:put, key, value}, _from, table) do
+ ensure_table(table)
+ timestamp = System.system_time(:millisecond)
+ :ets.insert(table, {key, value, timestamp})
+
+ GenServer.cast(self(), :maybe_evict)
+
+ {:reply, :ok, table}
+ end
+
+ @impl true
+ def handle_call({:get, key}, _from, table) do
+ ensure_table(table)
+
+ case :ets.lookup(table, key) do
+ [{^key, value, _old_timestamp}] ->
+ timestamp = System.system_time(:millisecond)
+ :ets.insert(table, {key, value, timestamp})
+ {:reply, {:ok, value}, table}
+
+ [] ->
+ {:reply, :error, table}
+ end
+ end
+
+ @impl true
+ def handle_call({:delete, key}, _from, table) do
+ :ets.delete(table, key)
+ {:reply, :ok, table}
+ end
+
+ @impl true
+ def handle_cast(:maybe_evict, table) do
+ __MODULE__.Evict.maybe_evict_oldest(table, @max_size)
+ {:noreply, table}
+ end
+
+ @impl true
+ def handle_cast(:backup, table) do
+ __MODULE__.Persist.backup(table)
+ {:noreply, table}
+ end
+
+ @impl true
+ def handle_info(:backup, table) do
+ GenServer.cast(self(), :backup)
+ {:noreply, table}
+ end
+
+ defp via_tuple(table), do: {:via, Registry, {Chesstrainer.CacheRegistry, table}}
+
+ defp ensure_table(table) do
+ case :ets.whereis(table) do
+ :undefined -> create_table(table)
+ _ -> :ok
+ end
+ end
+
+ defp create_table(table), do: :ets.new(table, [:named_table, :public, :set])
+end
diff --git a/lib/chesstrainer/cache/evict.ex b/lib/chesstrainer/cache/evict.ex
new file mode 100644
index 0000000..a6dfd5a
--- /dev/null
+++ b/lib/chesstrainer/cache/evict.ex
@@ -0,0 +1,29 @@
+defmodule Chesstrainer.Cache.Evict do
+ @moduledoc """
+ Eviction functions for LRU cache
+ """
+
+ # O(n) time, not efficient
+ def maybe_evict_oldest(table, max_size) do
+ if :ets.info(table, :size) > max_size do
+ case find_oldest_entry(table) do
+ {oldest_key, _} -> :ets.delete(table, oldest_key)
+ _ -> :ok
+ end
+ end
+ end
+
+ defp find_oldest_entry(table) do
+ :ets.foldl(
+ fn {k, _v, t}, acc ->
+ case acc do
+ nil -> {k, t}
+ {_, oldest_t} when t < oldest_t -> {k, t}
+ _ -> acc
+ end
+ end,
+ nil,
+ table
+ )
+ end
+end
diff --git a/lib/chesstrainer/cache/persist.ex b/lib/chesstrainer/cache/persist.ex
new file mode 100644
index 0000000..dbed95f
--- /dev/null
+++ b/lib/chesstrainer/cache/persist.ex
@@ -0,0 +1,29 @@
+defmodule Chesstrainer.Cache.Persist do
+ @moduledoc """
+ Persistance for cache. Backup and restore functions.
+ """
+
+ @backup_path "priv/static/backups"
+
+ def backup(table) do
+ File.mkdir_p!(@backup_path)
+ filename = Path.join(@backup_path, "#{table}.det") |> String.to_charlist()
+ {:ok, dets_table} = :dets.open_file(filename, type: :set)
+ :ets.to_dets(table, dets_table)
+ :dets.sync(dets_table)
+ end
+
+ def restore(table) do
+ File.mkdir_p!(@backup_path)
+ filename = Path.join(@backup_path, "#{table}.det") |> String.to_charlist()
+
+ case :dets.open_file(table, file: filename, type: :set) do
+ {:ok, ^table} ->
+ :dets.foldl(fn entry, _ -> :ets.insert(table, entry) end, :ok, table)
+ :dets.close(table)
+
+ {:error, _} ->
+ :ok
+ end
+ end
+end
diff --git a/lib/chesstrainer/endgames.ex b/lib/chesstrainer/endgames.ex
new file mode 100644
index 0000000..510f332
--- /dev/null
+++ b/lib/chesstrainer/endgames.ex
@@ -0,0 +1,123 @@
+defmodule Chesstrainer.Endgames do
+ @moduledoc """
+ The Endgames context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Chesstrainer.Repo
+
+ alias Chesstrainer.Endgames.Endgame
+
+ @doc """
+ Returns the list of endgames.
+
+ ## Examples
+
+ iex> list_endgames()
+ [%Endgame{}, ...]
+
+ """
+ def list_endgames do
+ Repo.all(Endgame)
+ end
+
+ @doc """
+ Gets a single endgame.
+
+ Raises `Ecto.NoResultsError` if the Endgame does not exist.
+
+ ## Examples
+
+ iex> get_endgame!(123)
+ %Endgame{}
+
+ iex> get_endgame!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_endgame!(id), do: Repo.get!(Endgame, id)
+
+ @doc """
+ Creates a endgame.
+
+ ## Examples
+
+ iex> create_endgame(%{field: value})
+ {:ok, %Endgame{}}
+
+ iex> create_endgame(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_endgame(attrs) do
+ %Endgame{}
+ |> Endgame.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Updates a endgame.
+
+ ## Examples
+
+ iex> update_endgame(endgame, %{field: new_value})
+ {:ok, %Endgame{}}
+
+ iex> update_endgame(endgame, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_endgame(%Endgame{} = endgame, attrs) do
+ # ignore fen on update
+ attrs = Map.delete(attrs, "fen")
+
+ endgame
+ |> Endgame.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a endgame.
+
+ ## Examples
+
+ iex> delete_endgame(endgame)
+ {:ok, %Endgame{}}
+
+ iex> delete_endgame(endgame)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_endgame(%Endgame{} = endgame) do
+ Repo.delete(endgame)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking endgame changes.
+
+ ## Examples
+
+ iex> change_endgame(endgame)
+ %Ecto.Changeset{data: %Endgame{}}
+
+ """
+ def change_endgame(%Endgame{} = endgame, attrs \\ %{}) do
+ Endgame.changeset(endgame, attrs)
+ end
+
+ def add_tag(endgame, tag) do
+ endgame
+ |> Repo.preload(:tags)
+ |> Ecto.Changeset.change()
+ |> Ecto.Changeset.put_assoc(:tags, [tag | endgame.tags])
+ |> Repo.update()
+ end
+
+ def remove_tag(endgame, tag) do
+ endgame
+ |> Repo.preload(:tags)
+ |> Ecto.Changeset.change()
+ |> Ecto.Changeset.put_assoc(:tags, endgame.tags -- [tag])
+ |> Repo.update()
+ end
+end
diff --git a/lib/chesstrainer/endgames/endgame.ex b/lib/chesstrainer/endgames/endgame.ex
new file mode 100644
index 0000000..65a79d9
--- /dev/null
+++ b/lib/chesstrainer/endgames/endgame.ex
@@ -0,0 +1,41 @@
+defmodule Chesstrainer.Endgames.Endgame do
+ @moduledoc """
+ A singular endgame entry
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "endgames" do
+ field :fen, :string
+ field :color, Ecto.Enum, values: [:white, :black]
+ field :key, :string
+ field :message, :string
+ field :notes, :string
+ field :result, Ecto.Enum, values: [:win, :loss, :draw]
+ field :rating, :integer
+ field :rating_deviation, :integer
+ field :times_attempted, :integer
+ field :times_solved, :integer
+
+ many_to_many :tags, Chesstrainer.Tags.Tag,
+ join_through: "endgames_tags",
+ on_replace: :delete
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(endgame, attrs) do
+ endgame
+ |> cast(attrs, [:fen, :key, :message, :notes, :result, :rating, :color])
+ |> validate_required([:fen, :color], message: "Invalid FEN")
+ |> validate_required([:key, :result, :rating])
+ |> validate_format(:key, ~r/^(?=.{5,10}$)KQ*R*[NB]*P*\sv\sKQ*R*[NB]*P*$/,
+ message: "Key must follow pattern KQ.. v KQR.., max pieces 7"
+ )
+ |> unique_constraint(:fen, message: "FEN already exists")
+ end
+end
diff --git a/lib/chesstrainer/endgames/tablebase.ex b/lib/chesstrainer/endgames/tablebase.ex
new file mode 100644
index 0000000..d735085
--- /dev/null
+++ b/lib/chesstrainer/endgames/tablebase.ex
@@ -0,0 +1,57 @@
+defmodule Chesstrainer.Endgames.Tablebase do
+ @moduledoc """
+ All endgame tablebase related functions.
+ """
+
+ alias Chesstrainer.Endgames.Tablebase
+
+ @type result :: :win | :draw | :loss | :unknown
+
+ @type t :: %__MODULE__{
+ category: result,
+ checkmate: boolean(),
+ dtc: integer() | nil,
+ dtm: integer() | nil,
+ dtw: integer() | nil,
+ dtz: integer() | nil,
+ insufficient_material: boolean(),
+ moves: [Tablebase.Move.t()]
+ }
+
+ defstruct category: nil,
+ checkmate: false,
+ dtc: nil,
+ dtm: nil,
+ dtw: nil,
+ dtz: nil,
+ insufficient_material: false,
+ moves: nil
+
+ @spec new(String.t()) ::
+ {:ok, t()}
+ | {:cooldown, non_neg_integer()}
+ | {:error, :invalid_endgame}
+ | {:error, term()}
+ def new(fen) do
+ case Tablebase.Cache.get(fen) do
+ {_fen, tablebase} ->
+ {:cache, tablebase}
+
+ _ ->
+ case Tablebase.Parser.from_fen(fen) do
+ {:error, reason} ->
+ {:error, reason}
+
+ {:cooldown, remaining_ms} ->
+ {:cooldown, remaining_ms}
+
+ {:ok, tablebase} when tablebase.category == :unknown ->
+ {:error, :invalid_endgame}
+
+ {:ok, tablebase} ->
+ Tablebase.Cache.put(fen, tablebase)
+ {:ok, tablebase}
+ end
+ end
+ end
+end
diff --git a/lib/chesstrainer/endgames/tablebase/cache.ex b/lib/chesstrainer/endgames/tablebase/cache.ex
new file mode 100644
index 0000000..8214ad3
--- /dev/null
+++ b/lib/chesstrainer/endgames/tablebase/cache.ex
@@ -0,0 +1,17 @@
+defmodule Chesstrainer.Endgames.Tablebase.Cache do
+ @moduledoc """
+ A cache of tablebases by FEN.
+ """
+
+ alias Chesstrainer.Cache
+
+ @table :tablebase
+
+ def put(fen, tablebase), do: Cache.put(@table, fen, tablebase)
+
+ def get(fen), do: Cache.get(@table, fen)
+
+ def delete(fen), do: Cache.delete(@table, fen)
+ def show, do: Cache.show(@table)
+ def size, do: Cache.size(@table)
+end
diff --git a/lib/chesstrainer/endgames/tablebase/move.ex b/lib/chesstrainer/endgames/tablebase/move.ex
new file mode 100644
index 0000000..da42760
--- /dev/null
+++ b/lib/chesstrainer/endgames/tablebase/move.ex
@@ -0,0 +1,41 @@
+defmodule Chesstrainer.Endgames.Tablebase.Move do
+ @moduledoc """
+ Moves struct for the tablebase
+ """
+
+ @type result :: :win | :draw | :loss
+
+ @type t :: %__MODULE__{
+ category: result,
+ checkmate: boolean(),
+ conversion: boolean(),
+ dtc: integer() | nil,
+ dtm: integer() | nil,
+ dtw: integer() | nil,
+ dtz: integer() | nil,
+ precise_dtz: integer() | nil,
+ san: String.t() | nil,
+ stalemate: boolean(),
+ uci: String.t() | nil,
+ variant_loss: boolean(),
+ variant_win: boolean(),
+ zeroing: boolean(),
+ insufficient_material: boolean()
+ }
+
+ defstruct category: nil,
+ checkmate: false,
+ conversion: false,
+ dtc: nil,
+ dtm: nil,
+ dtw: nil,
+ dtz: nil,
+ precise_dtz: nil,
+ san: nil,
+ stalemate: false,
+ uci: nil,
+ variant_loss: false,
+ variant_win: false,
+ zeroing: false,
+ insufficient_material: false
+end
diff --git a/lib/chesstrainer/endgames/tablebase/parser.ex b/lib/chesstrainer/endgames/tablebase/parser.ex
new file mode 100644
index 0000000..8f1af3c
--- /dev/null
+++ b/lib/chesstrainer/endgames/tablebase/parser.ex
@@ -0,0 +1,106 @@
+defmodule Chesstrainer.Endgames.Tablebase.Parser do
+ @moduledoc """
+ Fetch and parse tablebase data to fit the tablebase struct
+
+ Lichess tablebase response from FEN string.
+ https://lichess.org/api#tag/tablebase
+
+ FEN must have spaces replaced by underscores to satisfy the Lichess API.
+ """
+
+ import Chesstrainer.Ratelimiters
+ alias Chesstrainer.Endgames.Tablebase
+
+ @lichess_tablebase_url "http://tablebase.lichess.ovh/standard?fen="
+
+ @spec from_fen(String.t()) ::
+ {:ok, Tablebase.t()} | {:cooldown, non_neg_integer()} | {:error, term()}
+ def from_fen(fen) do
+ case lichess_check_cooldown() do
+ {:ok, 0} -> tablebase_response(fen)
+ {:cooldown, remaining_ms} -> {:cooldown, remaining_ms}
+ end
+ end
+
+ @spec tablebase_response(String.t()) :: {:ok, Tablebase.t()} | {:error, term()}
+ defp tablebase_response(fen) do
+ response =
+ fen
+ |> sanitise_fen()
+ |> build_request_string()
+ |> Req.get()
+ |> parse_response()
+
+ case response do
+ {:ok, body} -> from_map(body)
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ # exposed for testing
+ def from_map(map) do
+ {:ok,
+ %Tablebase{
+ category: map["category"] |> String.to_atom(),
+ checkmate: map["checkmate"],
+ dtc: map["dtc"],
+ dtm: map["dtm"],
+ dtw: map["dtw"],
+ dtz: map["dtz"],
+ insufficient_material: map["insufficient_material"],
+ moves: Enum.map(map["moves"], &moves_from_map/1)
+ }}
+ end
+
+ defp moves_from_map(map) do
+ %Tablebase.Move{
+ category: map["category"] |> String.to_atom(),
+ checkmate: map["checkmate"],
+ conversion: map["conversion"],
+ dtc: map["dtc"],
+ dtm: map["dtm"],
+ dtw: map["dtw"],
+ dtz: map["dtz"],
+ insufficient_material: map["insufficient_material"],
+ precise_dtz: map["precise_dtz"],
+ san: map["san"],
+ stalemate: map["stalemate"],
+ uci: map["uci"],
+ variant_loss: map["variant_loss"],
+ variant_win: map["variant_win"],
+ zeroing: map["zeroing"]
+ }
+ end
+
+ defp parse_response(response) do
+ case response do
+ # Success
+ {:ok, %Req.Response{status: 200, body: body}} ->
+ {:ok, body}
+
+ # Client errors
+ {:ok, %Req.Response{status: status, body: body}} when status in 400..499 ->
+ {:error, {:client_error, status, body}}
+
+ # Rate limiting
+ {:ok, %Req.Response{status: 429, body: body}} ->
+ lichess_add_cooldown()
+ {:error, {:too_many_requests, 429, body}}
+
+ # Server errors
+ {:ok, %Req.Response{status: status, body: body}} when status in 500..599 ->
+ {:error, {:server_error, status, body}}
+
+ # Catch‑all for other status codes
+ {:ok, %Req.Response{status: status, body: body}} ->
+ {:error, {:http_error, status, body}}
+
+ # Network / client‑side failure
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp sanitise_fen(fen), do: String.replace(fen, " ", "_")
+ defp build_request_string(fen), do: "#{@lichess_tablebase_url}#{fen}"
+end
diff --git a/lib/chesstrainer/fen.ex b/lib/chesstrainer/fen.ex
new file mode 100644
index 0000000..b23b003
--- /dev/null
+++ b/lib/chesstrainer/fen.ex
@@ -0,0 +1,13 @@
+defmodule Chesstrainer.FEN do
+ @spec color_from_fen(String.t()) :: :white | :black | nil
+ def color_from_fen(fen) do
+ fen
+ |> String.split(" ")
+ |> Enum.at(1)
+ |> color_initial_to_color_atom()
+ end
+
+ defp color_initial_to_color_atom("b"), do: :black
+ defp color_initial_to_color_atom("w"), do: :white
+ defp color_initial_to_color_atom(_), do: nil
+end
diff --git a/lib/chesstrainer/game.ex b/lib/chesstrainer/game.ex
new file mode 100644
index 0000000..d6ec4b6
--- /dev/null
+++ b/lib/chesstrainer/game.ex
@@ -0,0 +1,145 @@
+defmodule Chesstrainer.Game do
+ @moduledoc """
+ Game related functions
+ """
+ alias Chesstrainer.Endgames
+
+ @type game_type :: :endgame
+ @type square :: {atom(), pos_integer()}
+ @type move :: {square, square}
+ @type colors :: :white | :black
+ @type game_state :: :continue | :loss | :draw
+ @type result :: :win | :draw | :loss
+
+ @type t :: %__MODULE__{
+ player_color: colors,
+ board: map(),
+ active_color: colors,
+ castling: list(),
+ en_passant: term() | nil,
+ moves: list(),
+ halfmove_clock: non_neg_integer(),
+ fullmove_clock: non_neg_integer(),
+ captures: list(),
+ check: term() | nil,
+ result: result,
+ pgn: String.t() | nil,
+ orientation: colors,
+ move_from_square: square,
+ move_to_square: square,
+ game_type: game_type,
+ fen: String.t(),
+ tablebase: term(),
+ game_state: game_state
+ }
+
+ defstruct board: %{},
+ player_color: :white,
+ active_color: :white,
+ castling: [],
+ en_passant: nil,
+ moves: [],
+ halfmove_clock: 0,
+ fullmove_clock: 0,
+ captures: [],
+ check: nil,
+ result: nil,
+ pgn: nil,
+ orientation: :white,
+ move_from_square: nil,
+ move_to_square: nil,
+ game_type: nil,
+ fen: nil,
+ tablebase: nil,
+ game_state: :continue
+
+ @doc """
+ Builds a `%ChessTrainer.Game{}` from a FEN string.
+
+ On success, returns `{:ok, game}` with the parsed board state.
+
+ If the FEN string is invalid, `Chex.Parser.FEN.parse/1` raises a
+ `MatchError`. This function rescues that exception and returns
+ `{:error, :invalid_fen, game}`, where `game` is a fallback position
+ containing only kings.
+
+ This is a bad implementation in the Chex libary and should be fixed at our fork:
+ https://github.com/therealowenrees/chex
+
+ We should return something like{:ok, game} | {:error | :invalid_fen}
+
+ ## Examples
+
+ iex> ChessTrainer.Game.game_from_fen("valid fen", :endgame)
+ {:ok, %ChessTrainer.Game{}}
+
+ iex> ChessTrainer.Game.game_from_fen("invalid fen", :endgame)
+ {:error, :invalid_fen, %ChessTrainer.Game{}}
+
+ """
+ @dialyzer {:nowarn_function, game_from_fen: 2}
+ @spec game_from_fen(String.t(), game_type()) :: {:ok, t()} | {:error, :invalid_fen, t()}
+ def game_from_fen(fen, game_type) do
+ try do
+ {:ok, %Chex.Game{} = chex_game} = Chex.Parser.FEN.parse(fen)
+
+ game = %__MODULE__{
+ board: chex_game.board,
+ player_color: chex_game.active_color,
+ active_color: chex_game.active_color,
+ castling: chex_game.castling,
+ en_passant: chex_game.en_passant,
+ moves: chex_game.moves,
+ halfmove_clock: chex_game.halfmove_clock,
+ fullmove_clock: chex_game.fullmove_clock,
+ captures: chex_game.captures,
+ check: chex_game.check,
+ result: chex_game.result,
+ pgn: chex_game.pgn,
+ orientation: chex_game.active_color,
+ move_from_square: nil,
+ move_to_square: nil,
+ game_type: game_type,
+ fen: fen,
+ game_state: :continue,
+ tablebase:
+ if game_type === :endgame do
+ Endgames.Tablebase.new(fen)
+ else
+ nil
+ end
+ }
+
+ {:ok, game}
+ rescue
+ # if a MatchError error is raised, then return a game with only kings on the board
+ MatchError ->
+ game = %__MODULE__{
+ board: %{
+ {:e, 1} => {:king, :white, {:e, 1}},
+ {:e, 8} => {:king, :black, {:e, 8}}
+ },
+ player_color: :white,
+ active_color: :white,
+ castling: [],
+ en_passant: nil,
+ moves: [],
+ halfmove_clock: 0,
+ fullmove_clock: 0,
+ captures: nil,
+ check: nil,
+ result: :draw,
+ pgn: nil,
+ orientation: :white,
+ move_from_square: nil,
+ move_to_square: nil,
+ game_type: :endgame,
+ fen: "4k3/8/8/8/8/8/8/4K3 w - - 0 1",
+ game_state: :draw,
+ tablebase: nil
+ }
+
+ {:error, :invalid_fen, game}
+ end
+ end
+end
diff --git a/lib/chesstrainer/ratelimiters.ex b/lib/chesstrainer/ratelimiters.ex
new file mode 100644
index 0000000..2fbd3bb
--- /dev/null
+++ b/lib/chesstrainer/ratelimiters.ex
@@ -0,0 +1,48 @@
+defmodule Chesstrainer.Ratelimiters do
+ @moduledoc """
+ Generic rate limiter for external APIs (e.g., Lichess).
+
+ This module exposes convenience functions for each external API
+ that delegate to their specific rate limiter implementation.
+
+ ## Examples
+
+ iex> ChessTrainer.Ratelimiters.lichess_reset_cooldown()
+ true
+
+ iex> ChessTrainer.Ratelimiters.lichess_check_cooldown()
+ {:ok, 0}
+
+ iex> ChessTrainer.Ratelimiters.lichess_add_cooldown()
+ true
+ """
+
+ alias Chesstrainer.Ratelimiters.Lichess
+
+ @doc """
+ Adds a cooldown entry for the Lichess API.
+
+ This sets the cooldown period (e.g. 60 seconds) starting from now.
+ Useful when a `429 Too Many Requests` response is received.
+ """
+ @spec lichess_add_cooldown() :: boolean()
+ def lichess_add_cooldown, do: Lichess.add_cooldown()
+
+ @doc """
+ Resets the cooldown for the Lichess API.
+
+ This clears any existing cooldown, allowing requests immediately.
+ """
+ @spec lichess_reset_cooldown() :: boolean()
+ def lichess_reset_cooldown, do: Lichess.reset_cooldown()
+
+ @doc """
+ Checks whether the Lichess API is currently under cooldown.
+
+ Returns:
+ * `{:ok, 0}` if no cooldown is active
+ * `{:cooldown, remaining_ms}` if still cooling down, with the remaining time in milliseconds
+ """
+ @spec lichess_check_cooldown() :: {:ok, non_neg_integer} | {:cooldown, non_neg_integer}
+ def lichess_check_cooldown, do: Lichess.check_cooldown()
+end
diff --git a/lib/chesstrainer/ratelimiters/lichess.ex b/lib/chesstrainer/ratelimiters/lichess.ex
new file mode 100644
index 0000000..a138ee5
--- /dev/null
+++ b/lib/chesstrainer/ratelimiters/lichess.ex
@@ -0,0 +1,55 @@
+defmodule Chesstrainer.Ratelimiters.Lichess do
+ @moduledoc """
+ Global rate limiter for 429 response codes from the Lichess API.
+ """
+
+ @cooldown_seconds 60
+ @table :lichess_cooldown
+
+ def add_cooldown do
+ ensure_table()
+ insert_cooldown()
+ end
+
+ def reset_cooldown do
+ ensure_table()
+ :ets.insert(@table, {:until, nil})
+ end
+
+ def check_cooldown do
+ ensure_table()
+ current_ms = System.system_time(:millisecond)
+
+ case :ets.lookup(@table, :until) do
+ [{:until, nil}] ->
+ {:ok, 0}
+
+ [{:until, until_ms}] ->
+ remaining = until_ms - current_ms
+
+ if remaining <= 0 do
+ reset_cooldown()
+ {:ok, 0}
+ else
+ {:cooldown, remaining}
+ end
+ end
+ end
+
+ defp create do
+ :ets.new(@table, [:named_table, :public, :set])
+ :ets.insert(@table, {:until, nil})
+ end
+
+ defp ensure_table do
+ case :ets.whereis(@table) do
+ :undefined -> create()
+ _ -> :ok
+ end
+ end
+
+ defp insert_cooldown do
+ until_ms = System.system_time(:millisecond) + @cooldown_seconds * 1000
+ :ets.insert(@table, {:until, until_ms})
+ end
+end
diff --git a/lib/chesstrainer/tags.ex b/lib/chesstrainer/tags.ex
new file mode 100644
index 0000000..42c1efc
--- /dev/null
+++ b/lib/chesstrainer/tags.ex
@@ -0,0 +1,104 @@
+defmodule Chesstrainer.Tags do
+ @moduledoc """
+ The Tags context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Chesstrainer.Repo
+
+ alias Chesstrainer.Tags.Tag
+
+ @doc """
+ Returns the list of tags.
+
+ ## Examples
+
+ iex> list_tags()
+ [%Tag{}, ...]
+
+ """
+ def list_tags do
+ Repo.all(Tag)
+ end
+
+ @doc """
+ Gets a single tag.
+
+ Raises `Ecto.NoResultsError` if the Tag does not exist.
+
+ ## Examples
+
+ iex> get_tag!(123)
+ %Tag{}
+
+ iex> get_tag!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_tag!(id), do: Repo.get!(Tag, id)
+
+ @doc """
+ Creates a tag.
+
+ ## Examples
+
+ iex> create_tag(%{field: value})
+ {:ok, %Tag{}}
+
+ iex> create_tag(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_tag(attrs) do
+ %Tag{}
+ |> Tag.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Updates a tag.
+
+ ## Examples
+
+ iex> update_tag(tag, %{field: new_value})
+ {:ok, %Tag{}}
+
+ iex> update_tag(tag, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_tag(%Tag{} = tag, attrs) do
+ tag
+ |> Tag.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a tag.
+
+ ## Examples
+
+ iex> delete_tag(tag)
+ {:ok, %Tag{}}
+
+ iex> delete_tag(tag)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_tag(%Tag{} = tag) do
+ Repo.delete(tag)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking tag changes.
+
+ ## Examples
+
+ iex> change_tag(tag)
+ %Ecto.Changeset{data: %Tag{}}
+
+ """
+ def change_tag(%Tag{} = tag, attrs \\ %{}) do
+ Tag.changeset(tag, attrs)
+ end
+end
diff --git a/lib/chesstrainer/tags/tag.ex b/lib/chesstrainer/tags/tag.ex
new file mode 100644
index 0000000..93fe811
--- /dev/null
+++ b/lib/chesstrainer/tags/tag.ex
@@ -0,0 +1,19 @@
+defmodule Chesstrainer.Tags.Tag do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "tags" do
+ field :name, :string
+ field :category, Ecto.Enum, values: [endgame: "endgame", opening: "opening", tactic: "tactic"]
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(tag, attrs) do
+ tag
+ |> cast(attrs, [:name, :category])
+ |> validate_required([:name, :category])
+ |> unique_constraint(:name)
+ end
+end
diff --git a/lib/chesstrainer_web/components/board/board_component.ex b/lib/chesstrainer_web/components/board/board_component.ex
new file mode 100644
index 0000000..11483e9
--- /dev/null
+++ b/lib/chesstrainer_web/components/board/board_component.ex
@@ -0,0 +1,55 @@
+defmodule ChesstrainerWeb.Board.BoardComponent do
+ @moduledoc """
+ Chess board component
+ """
+
+ use Phoenix.Component
+ import ChesstrainerWeb.Board.SquareComponent
+
+ attr :game, :map, required: true
+
+ attr :myself, :any, required: true
+
+ def board(assigns) do
+ files_list = [:a, :b, :c, :d, :e, :f, :g, :h]
+
+ assigns =
+ assigns
+ |> assign(:files_list, files_list)
+ |> assign(
+ :files,
+ case assigns.game.orientation do
+ :white -> files_list
+ :black -> Enum.reverse(files_list)
+ end
+ )
+ |> assign(
+ :ranks,
+ case assigns.game.orientation do
+ :white -> Enum.reverse(1..8)
+ :black -> 1..8
+ end
+ )
+
+ ~H"""
+
+
+ <%= for rank <- @ranks do %>
+ <%= for file <- @files do %>
+ <% square = {file, rank} %>
+ <% piece = Map.get(@game.board, square) %>
+ <.square
+ file={file}
+ rank={rank}
+ piece={piece}
+ files_list={@files_list}
+ move_from_square={@game.move_from_square}
+ myself={@myself}
+ />
+ <% end %>
+ <% end %>
+
+
+ """
+ end
+end
diff --git a/lib/chesstrainer_web/components/board/piece_component.ex b/lib/chesstrainer_web/components/board/piece_component.ex
new file mode 100644
index 0000000..d4c9232
--- /dev/null
+++ b/lib/chesstrainer_web/components/board/piece_component.ex
@@ -0,0 +1,33 @@
+defmodule ChesstrainerWeb.Board.PieceComponent do
+ @moduledoc """
+ Chess pieces component
+ """
+ use Phoenix.Component
+
+ attr :piece, :any, required: true
+ attr :class, :string, default: ""
+
+ def piece(assigns) do
+ assigns = assign(assigns, :path, piece_path(assigns.piece))
+
+ ~H"""
+ <%= if @path do %>
+
+ <% end %>
+ """
+ end
+
+ def piece_path({:king, :white, _square}), do: "/images/pieces/white/king.svg"
+ def piece_path({:queen, :white, _square}), do: "/images/pieces/white/queen.svg"
+ def piece_path({:rook, :white, _square}), do: "/images/pieces/white/rook.svg"
+ def piece_path({:bishop, :white, _square}), do: "/images/pieces/white/bishop.svg"
+ def piece_path({:knight, :white, _square}), do: "/images/pieces/white/knight.svg"
+ def piece_path({:pawn, :white, _square}), do: "/images/pieces/white/pawn.svg"
+ def piece_path({:king, :black, _square}), do: "/images/pieces/black/king.svg"
+ def piece_path({:queen, :black, _square}), do: "/images/pieces/black/queen.svg"
+ def piece_path({:rook, :black, _square}), do: "/images/pieces/black/rook.svg"
+ def piece_path({:bishop, :black, _square}), do: "/images/pieces/black/bishop.svg"
+ def piece_path({:knight, :black, _square}), do: "/images/pieces/black/knight.svg"
+ def piece_path({:pawn, :black, _square}), do: "/images/pieces/black/pawn.svg"
+ def piece_path(_), do: nil
+end
diff --git a/lib/chesstrainer_web/components/board/square_component.ex b/lib/chesstrainer_web/components/board/square_component.ex
new file mode 100644
index 0000000..f538db8
--- /dev/null
+++ b/lib/chesstrainer_web/components/board/square_component.ex
@@ -0,0 +1,48 @@
+defmodule ChesstrainerWeb.Board.SquareComponent do
+ @moduledoc """
+ Renders a single chess square with optional piece and highlighting.
+ """
+ use Phoenix.Component
+ import ChesstrainerWeb.Board.PieceComponent
+
+ attr :file, :atom, required: true
+ attr :rank, :integer, required: true
+ attr :piece, :any, default: nil
+ attr :files_list, :list, required: true
+ attr :move_from_square, :any, default: nil
+ attr :myself, :any, required: true
+
+ def square(assigns) do
+ current_square = {assigns.file, assigns.rank}
+
+ assigns =
+ assigns
+ |> assign(:highlight_square, current_square === assigns.move_from_square)
+
+ ~H"""
+ f == @file end),
+ @rank - 1
+ ),
+ highlight_square(@highlight_square)
+ ]}
+ phx-click="square-click"
+ phx-value-file={@file}
+ phx-value-rank={@rank}
+ phx-value-type="move"
+ phx-target={@myself}
+ >
+ <.piece piece={@piece} class="w-10 h-10" />
+
+ """
+ end
+
+ def background(file_idx, rank_idx) when rem(file_idx + rank_idx, 2) != 0, do: "bg-boardwhite"
+ def background(_, _), do: "bg-boardblack"
+
+ def highlight_square(true), do: "ring-inset ring-2 ring-yellow-400"
+ def highlight_square(false), do: ""
+end
diff --git a/lib/chesstrainer_web/live/endgame_live/form.ex b/lib/chesstrainer_web/live/endgame_live/form.ex
new file mode 100644
index 0000000..e102fe6
--- /dev/null
+++ b/lib/chesstrainer_web/live/endgame_live/form.ex
@@ -0,0 +1,212 @@
+defmodule ChesstrainerWeb.EndgameLive.Form do
+ use ChesstrainerWeb, :live_view
+
+ alias Chesstrainer.Repo
+ alias Chesstrainer.Tags
+ alias Chesstrainer.FEN
+ alias Chesstrainer.Endgames
+ alias Chesstrainer.Endgames.Endgame
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ {@page_title}
+ <:subtitle>Use this form to manage endgame records in your database.
+
+
+ <.form for={@form} id="endgame-form" phx-change="validate" phx-submit="save">
+ <.input field={@form[:fen]} type="text" label="Fen" disabled={@live_action == :edit} />
+ <.input field={@form[:color]} type="text" label="Color" disabled />
+ <.input field={@form[:key]} type="text" label="Key" />
+ <.input field={@form[:rating]} type="number" label="Rating" />
+
+
+
+
+
+
+
+
+
+
+ <%= for tag <- @tag_results do %>
+ -
+ {tag.name}
+
+ <% end %>
+
+ <%= if @tag_query != "" and @tag_results == [] do %>
+ -
+ Create tag "{@tag_query}"
+
+ <% end %>
+
+
+
+
+ <%= for tag <- @endgame.tags do %>
+
+ {tag.name}
+
+
+ <% end %>
+
+
+
+ <.input field={@form[:message]} type="textarea" label="Message" />
+ <.input field={@form[:notes]} type="textarea" label="Notes" />
+ <.input
+ field={@form[:result]}
+ type="select"
+ options={[
+ {"Win", :win},
+ {"Draw", :draw},
+ {"Loss", :loss}
+ ]}
+ label="Result"
+ />
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(params, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:return_to, return_to(params["return_to"]))
+ |> apply_action(socket.assigns.live_action, params)}
+ end
+
+ defp return_to("show"), do: "show"
+ defp return_to(_), do: "index"
+
+ defp apply_action(socket, :edit, %{"id" => id}) do
+ endgame = Endgames.get_endgame!(id)
+
+ socket
+ |> assign(:page_title, "Edit Endgame")
+ |> assign(:endgame, endgame)
+ |> assign(:tag_query, "")
+ |> assign(:tag_results, [])
+ |> assign(:endgame, Repo.preload(endgame, :tags))
+ |> assign(:form, to_form(Endgames.change_endgame(endgame)))
+ end
+
+ defp apply_action(socket, :new, _params) do
+ endgame = %Endgame{}
+
+ socket
+ |> assign(:page_title, "New Endgame")
+ |> assign(:endgame, endgame)
+ |> assign(:tag_query, "")
+ |> assign(:tag_results, [])
+ |> assign(:endgame, Repo.preload(endgame, :tags))
+ |> assign(:form, to_form(Endgames.change_endgame(endgame)))
+ end
+
+ @impl true
+ def handle_event("search_tags", %{"tag_search" => query}, socket) do
+ tags = Tags.search(query, "endgame")
+
+ {:noreply,
+ socket
+ |> assign(:tag_query, query)
+ |> assign(:tag_results, tags)}
+ end
+
+ def handle_event("add_tag", %{"id" => id}, socket) do
+ tag = Tags.get_tag!(id)
+ {:ok, endgame} = Endgames.add_tag(socket.assigns.endgame, tag)
+
+ {:noreply,
+ socket
+ |> assign(:endgame, Repo.preload(endgame, :tags))
+ |> assign(:tag_query, "")
+ |> assign(:tag_results, [])}
+ end
+
+ def handle_event("create_tag", %{"name" => name}, socket) do
+ {:ok, tag} = Tags.get_or_create(name, "endgame")
+ {:ok, endgame} = Endgames.add_tag(socket.assigns.endgame, tag)
+
+ {:noreply,
+ socket
+ |> assign(:endgame, Repo.preload(endgame, :tags))
+ |> assign(:tag_query, "")
+ |> assign(:tag_results, [])}
+ end
+
+ def handle_event("remove_tag", %{"id" => id}, socket) do
+ tag = Tags.get_tag!(id)
+ {:ok, endgame} = Endgames.remove_tag(socket.assigns.endgame, tag)
+
+ {:noreply,
+ socket
+ |> assign(:endgame, Repo.preload(endgame, :tags))}
+ end
+
+ def handle_event("validate", %{"endgame" => endgame_params}, socket) do
+ # Compute color from the incoming FEN
+ color = FEN.color_from_fen(endgame_params["fen"])
+
+ # Update the struct
+ endgame = %{socket.assigns.endgame | color: color}
+
+ # Build the changeset *from the updated struct*
+ changeset =
+ endgame
+ |> Endgames.change_endgame()
+ |> Ecto.Changeset.put_change(:color, color)
+
+ {:noreply, assign(socket, endgame: endgame, form: to_form(changeset, action: :validate))}
+ end
+
+ def handle_event("save", %{"endgame" => endgame_params}, socket) do
+ save_endgame(socket, socket.assigns.live_action, endgame_params)
+ end
+
+ defp save_endgame(socket, :edit, endgame_params) do
+ case Endgames.update_endgame(socket.assigns.endgame, endgame_params) do
+ {:ok, endgame} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Endgame updated successfully")
+ |> push_navigate(to: return_path(socket.assigns.return_to, endgame))}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp save_endgame(socket, :new, endgame_params) do
+ color = FEN.color_from_fen(endgame_params["fen"])
+ params = Map.put(endgame_params, "color", color)
+
+ case Endgames.create_endgame(params) do
+ {:ok, endgame} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Endgame created successfully")
+ |> push_navigate(to: return_path(socket.assigns.return_to, endgame))}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp return_path("index", _endgame), do: ~p"/admin/endgames"
+ defp return_path("show", endgame), do: ~p"/admin/endgames/#{endgame}"
+end
diff --git a/lib/chesstrainer_web/live/endgame_live/index.ex b/lib/chesstrainer_web/live/endgame_live/index.ex
new file mode 100644
index 0000000..91a5fc9
--- /dev/null
+++ b/lib/chesstrainer_web/live/endgame_live/index.ex
@@ -0,0 +1,71 @@
+defmodule ChesstrainerWeb.EndgameLive.Index do
+ use ChesstrainerWeb, :live_view
+
+ alias Chesstrainer.Endgames
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ Listing Endgames
+ <:actions>
+ <.button variant="primary" navigate={~p"/admin/endgames/new"}>
+ <.icon name="hero-plus" /> New Endgame
+
+
+
+
+ <.table
+ id="endgames"
+ rows={@streams.endgames}
+ row_click={fn {_id, endgame} -> JS.navigate(~p"/admin/endgames/#{endgame}") end}
+ >
+ <:col :let={{_id, endgame}} label="Fen">{endgame.fen}
+ <:col :let={{_id, endgame}} label="Color">{endgame.color}
+ <:col :let={{_id, endgame}} label="Key">{endgame.key}
+ <:col :let={{_id, endgame}} label="Rating">{endgame.rating}
+ <:col :let={{_id, endgame}} label="Message">{endgame.message}
+ <:col :let={{_id, endgame}} label="Notes">{endgame.notes}
+ <:col :let={{_id, endgame}} label="Result">{endgame.result}
+ <:col :let={{_id, endgame}} label="Attempted">{endgame.times_attempted}
+ <:col :let={{_id, endgame}} label="Solved">{endgame.times_solved}
+ <:action :let={{_id, endgame}}>
+
+ <.link navigate={~p"/admin/endgames/#{endgame}"}>Show
+
+ <.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit
+
+ <:action :let={{id, endgame}}>
+ <.link
+ phx-click={JS.push("delete", value: %{id: endgame.id}) |> hide("##{id}")}
+ data-confirm="Are you sure?"
+ >
+ Delete
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, "Listing Endgames")
+ |> stream(:endgames, list_endgames())}
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ endgame = Endgames.get_endgame!(id)
+ {:ok, _} = Endgames.delete_endgame(endgame)
+
+ {:noreply, stream_delete(socket, :endgames, endgame)}
+ end
+
+ defp list_endgames() do
+ Endgames.list_endgames()
+ end
+end
diff --git a/lib/chesstrainer_web/live/endgame_live/show.ex b/lib/chesstrainer_web/live/endgame_live/show.ex
new file mode 100644
index 0000000..c5d6811
--- /dev/null
+++ b/lib/chesstrainer_web/live/endgame_live/show.ex
@@ -0,0 +1,64 @@
+defmodule ChesstrainerWeb.EndgameLive.Show do
+ use ChesstrainerWeb, :live_view
+
+ alias Chesstrainer.Endgames
+ alias Chesstrainer.Game
+
+ import ChesstrainerWeb.Board.BoardComponent
+
+ @impl true
+ def render(assigns) do
+ game =
+ case Game.game_from_fen(assigns.endgame.fen, :endgame) do
+ {:ok, g} -> g
+ {:error, :invalid_fen, g} -> g
+ end
+
+ # TODO once we return {:error, :invalid_fen, game}, then put flash to show it is invalid
+ # write tests for this
+
+ assigns =
+ assigns
+ |> assign(:game, game)
+ |> assign(:game_type, :endgame)
+
+ ~H"""
+
+ <.header>
+ Endgame {@endgame.id}
+ <:subtitle>This is a endgame record from your database.
+ <:actions>
+ <.button navigate={~p"/admin/endgames"}>
+ <.icon name="hero-arrow-left" />
+
+ <.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}>
+ <.icon name="hero-pencil-square" /> Edit endgame
+
+
+
+
+ <.board game={@game} myself="board" />
+
+ <.list>
+ <:item title="Fen">{@endgame.fen}
+ <:item title="Key">{@endgame.key}
+ <:item title="Color">{@endgame.color}
+ <:item title="Rating">{@endgame.rating}
+ <:item title="Message">{@endgame.message}
+ <:item title="Notes">{@endgame.notes}
+ <:item title="Result">{@endgame.result}
+ <:item title="Attempted">{@endgame.times_attempted}
+ <:item title="Solved">{@endgame.times_solved}
+
+
+ """
+ end
+
+ @impl true
+ def mount(%{"id" => id}, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, "Show Endgame")
+ |> assign(:endgame, Endgames.get_endgame!(id))}
+ end
+end
diff --git a/lib/chesstrainer_web/router.ex b/lib/chesstrainer_web/router.ex
index 1052c59..59fb058 100644
--- a/lib/chesstrainer_web/router.ex
+++ b/lib/chesstrainer_web/router.ex
@@ -22,6 +22,17 @@ defmodule ChesstrainerWeb.Router do
live "/chess-test", ChessTestLive
end
+ scope "/admin", ChesstrainerWeb do
+ pipe_through :browser
+
+ live_session :endgames do
+ live "/endgames", EndgameLive.Index, :index
+ live "/endgames/new", EndgameLive.Form, :new
+ live "/endgames/:id", EndgameLive.Show, :show
+ live "/endgames/:id/edit", EndgameLive.Form, :edit
+ end
+ end
+
# Other scopes may use custom stacks.
# scope "/api", ChesstrainerWeb do
# pipe_through :api
diff --git a/mix.exs b/mix.exs
index b8cfcd7..90f407d 100644
--- a/mix.exs
+++ b/mix.exs
@@ -65,7 +65,8 @@ defmodule Chesstrainer.MixProject do
{:gettext, "~> 1.0"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
- {:bandit, "~> 1.5"}
+ {:bandit, "~> 1.5"},
+ {:chex, git: "https://github.com/therealowenrees/chex.git", tag: "v0.2.0-alpha.2"}
]
end
diff --git a/mix.lock b/mix.lock
index aba7a3c..34a27c9 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,6 +1,7 @@
%{
"bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
+ "chex": {:git, "https://github.com/therealowenrees/chex.git", "655b5c3df6c5297689d7872bbce1e86c6c629d26", [tag: "v0.2.0-alpha.2"]},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
@@ -21,6 +22,7 @@
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
+ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
diff --git a/priv/repo/migrations/20251105204020_create_tags.exs b/priv/repo/migrations/20251105204020_create_tags.exs
new file mode 100644
index 0000000..9644634
--- /dev/null
+++ b/priv/repo/migrations/20251105204020_create_tags.exs
@@ -0,0 +1,14 @@
+defmodule Chesstrainer.Repo.Migrations.CreateTags do
+ use Ecto.Migration
+
+ def change do
+ create table(:tags) do
+ add :name, :string
+ add :category, :string
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:tags, [:name, :category])
+ end
+end
diff --git a/priv/repo/migrations/20251127164545_create_endgames.exs b/priv/repo/migrations/20251127164545_create_endgames.exs
new file mode 100644
index 0000000..b100576
--- /dev/null
+++ b/priv/repo/migrations/20251127164545_create_endgames.exs
@@ -0,0 +1,23 @@
+defmodule Chesstrainer.Repo.Migrations.CreateEndgames do
+ use Ecto.Migration
+
+ def change do
+ create table(:endgames, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :fen, :string
+ add :color, :string
+ add :key, :string
+ add :message, :text
+ add :notes, :text
+ add :result, :string
+ add :rating, :integer, default: 1500
+ add :rating_deviation, :integer, default: 350
+ add :times_attempted, :integer, default: 0
+ add :times_solved, :integer, default: 0
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:endgames, [:fen])
+ end
+end
diff --git a/priv/repo/migrations/20251205210539_create_endgames_tags.exs b/priv/repo/migrations/20251205210539_create_endgames_tags.exs
new file mode 100644
index 0000000..d8b2aa1
--- /dev/null
+++ b/priv/repo/migrations/20251205210539_create_endgames_tags.exs
@@ -0,0 +1,12 @@
+defmodule Chesstrainer.Repo.Migrations.CreateEndgamesTags do
+ use Ecto.Migration
+
+ def change do
+ create table(:endgames_tags, primary_key: false) do
+ add :endgame_id, references(:endgames, type: :binary_id, on_delete: :delete_all)
+ add :tag_id, references(:tags, on_delete: :delete_all)
+ end
+
+ create unique_index(:endgames_tags, [:endgame_id, :tag_id])
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 5b9b28b..179b8af 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -9,3 +9,57 @@
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
+
+alias Chesstrainer.Repo
+alias Chesstrainer.Endgames.Endgame
+alias Chesstrainer.Tags.Tag
+
+now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+IO.puts("🌱 Seeding basic endgames into DB...")
+
+Repo.insert_all(Endgame, [
+ %{
+ id: Ecto.UUID.generate(),
+ fen: "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1",
+ color: :white,
+ key: "KR v K",
+ message: "Rook vs king endgame",
+ notes: "Basic rook endgame",
+ result: :win,
+ inserted_at: now,
+ updated_at: now
+ },
+ %{
+ id: Ecto.UUID.generate(),
+ fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0",
+ color: :black,
+ key: "KPPP v KPP",
+ message: "3 pawns vs 2 pawns",
+ notes: "https://www.chessgames.com/perl/chessgame?gid=1017147",
+ result: :win,
+ inserted_at: now,
+ updated_at: now
+ }
+])
+
+IO.puts("✅ Done seeding endgames.")
+
+IO.puts("🌱 Seeding endgame tags into DB...")
+
+Repo.insert_all(Tag, [
+ %{
+ name: "mate in 2",
+ category: :endgame,
+ inserted_at: now,
+ updated_at: now
+ },
+ %{
+ name: "mate in 3",
+ category: :endgame,
+ inserted_at: now,
+ updated_at: now
+ }
+])
+
+IO.puts("✅ Done seeding endgame tags.")
diff --git a/priv/static/backups/tablebase.det b/priv/static/backups/tablebase.det
new file mode 100644
index 0000000..c1ae47e
Binary files /dev/null and b/priv/static/backups/tablebase.det differ
diff --git a/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg
new file mode 100644
index 0000000..b6a8006
--- /dev/null
+++ b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz
new file mode 100644
index 0000000..473a420
Binary files /dev/null and b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz differ
diff --git a/priv/static/images/pieces/black/bishop.svg b/priv/static/images/pieces/black/bishop.svg
new file mode 100644
index 0000000..b6a8006
--- /dev/null
+++ b/priv/static/images/pieces/black/bishop.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/priv/static/images/pieces/black/bishop.svg.gz b/priv/static/images/pieces/black/bishop.svg.gz
new file mode 100644
index 0000000..473a420
Binary files /dev/null and b/priv/static/images/pieces/black/bishop.svg.gz differ
diff --git a/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg
new file mode 100644
index 0000000..ba2ac9f
--- /dev/null
+++ b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz
new file mode 100644
index 0000000..3e5e17a
Binary files /dev/null and b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz differ
diff --git a/priv/static/images/pieces/black/king.svg b/priv/static/images/pieces/black/king.svg
new file mode 100644
index 0000000..ba2ac9f
--- /dev/null
+++ b/priv/static/images/pieces/black/king.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/priv/static/images/pieces/black/king.svg.gz b/priv/static/images/pieces/black/king.svg.gz
new file mode 100644
index 0000000..3e5e17a
Binary files /dev/null and b/priv/static/images/pieces/black/king.svg.gz differ
diff --git a/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg
new file mode 100644
index 0000000..203c6fe
--- /dev/null
+++ b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg
@@ -0,0 +1,22 @@
+
+
+
diff --git a/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz
new file mode 100644
index 0000000..1e43ee9
Binary files /dev/null and b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz differ
diff --git a/priv/static/images/pieces/black/knight.svg b/priv/static/images/pieces/black/knight.svg
new file mode 100644
index 0000000..203c6fe
--- /dev/null
+++ b/priv/static/images/pieces/black/knight.svg
@@ -0,0 +1,22 @@
+
+
+
diff --git a/priv/static/images/pieces/black/knight.svg.gz b/priv/static/images/pieces/black/knight.svg.gz
new file mode 100644
index 0000000..1e43ee9
Binary files /dev/null and b/priv/static/images/pieces/black/knight.svg.gz differ
diff --git a/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg
new file mode 100644
index 0000000..b534de8
--- /dev/null
+++ b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz
new file mode 100644
index 0000000..2eb7680
Binary files /dev/null and b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz differ
diff --git a/priv/static/images/pieces/black/pawn.svg b/priv/static/images/pieces/black/pawn.svg
new file mode 100644
index 0000000..b534de8
--- /dev/null
+++ b/priv/static/images/pieces/black/pawn.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/priv/static/images/pieces/black/pawn.svg.gz b/priv/static/images/pieces/black/pawn.svg.gz
new file mode 100644
index 0000000..2eb7680
Binary files /dev/null and b/priv/static/images/pieces/black/pawn.svg.gz differ
diff --git a/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg
new file mode 100644
index 0000000..e557734
--- /dev/null
+++ b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg
@@ -0,0 +1,27 @@
+
+
+
diff --git a/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz
new file mode 100644
index 0000000..3cdc4a6
Binary files /dev/null and b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz differ
diff --git a/priv/static/images/pieces/black/queen.svg b/priv/static/images/pieces/black/queen.svg
new file mode 100644
index 0000000..e557734
--- /dev/null
+++ b/priv/static/images/pieces/black/queen.svg
@@ -0,0 +1,27 @@
+
+
+
diff --git a/priv/static/images/pieces/black/queen.svg.gz b/priv/static/images/pieces/black/queen.svg.gz
new file mode 100644
index 0000000..3cdc4a6
Binary files /dev/null and b/priv/static/images/pieces/black/queen.svg.gz differ
diff --git a/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg
new file mode 100644
index 0000000..4eec43c
--- /dev/null
+++ b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg
@@ -0,0 +1,39 @@
+
+
+
diff --git a/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz
new file mode 100644
index 0000000..2196985
Binary files /dev/null and b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz differ
diff --git a/priv/static/images/pieces/black/rook.svg b/priv/static/images/pieces/black/rook.svg
new file mode 100644
index 0000000..4eec43c
--- /dev/null
+++ b/priv/static/images/pieces/black/rook.svg
@@ -0,0 +1,39 @@
+
+
+
diff --git a/priv/static/images/pieces/black/rook.svg.gz b/priv/static/images/pieces/black/rook.svg.gz
new file mode 100644
index 0000000..2196985
Binary files /dev/null and b/priv/static/images/pieces/black/rook.svg.gz differ
diff --git a/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg
new file mode 100644
index 0000000..18f034d
--- /dev/null
+++ b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz
new file mode 100644
index 0000000..0800e6e
Binary files /dev/null and b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz differ
diff --git a/priv/static/images/pieces/white/bishop.svg b/priv/static/images/pieces/white/bishop.svg
new file mode 100644
index 0000000..18f034d
--- /dev/null
+++ b/priv/static/images/pieces/white/bishop.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/priv/static/images/pieces/white/bishop.svg.gz b/priv/static/images/pieces/white/bishop.svg.gz
new file mode 100644
index 0000000..0800e6e
Binary files /dev/null and b/priv/static/images/pieces/white/bishop.svg.gz differ
diff --git a/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg
new file mode 100644
index 0000000..632ca1a
--- /dev/null
+++ b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz
new file mode 100644
index 0000000..5cce64a
Binary files /dev/null and b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz differ
diff --git a/priv/static/images/pieces/white/king.svg b/priv/static/images/pieces/white/king.svg
new file mode 100644
index 0000000..632ca1a
--- /dev/null
+++ b/priv/static/images/pieces/white/king.svg
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/priv/static/images/pieces/white/king.svg.gz b/priv/static/images/pieces/white/king.svg.gz
new file mode 100644
index 0000000..5cce64a
Binary files /dev/null and b/priv/static/images/pieces/white/king.svg.gz differ
diff --git a/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg
new file mode 100644
index 0000000..92feeee
--- /dev/null
+++ b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg
@@ -0,0 +1,19 @@
+
+
+
diff --git a/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz
new file mode 100644
index 0000000..dd8df6e
Binary files /dev/null and b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz differ
diff --git a/priv/static/images/pieces/white/knight.svg b/priv/static/images/pieces/white/knight.svg
new file mode 100644
index 0000000..92feeee
--- /dev/null
+++ b/priv/static/images/pieces/white/knight.svg
@@ -0,0 +1,19 @@
+
+
+
diff --git a/priv/static/images/pieces/white/knight.svg.gz b/priv/static/images/pieces/white/knight.svg.gz
new file mode 100644
index 0000000..dd8df6e
Binary files /dev/null and b/priv/static/images/pieces/white/knight.svg.gz differ
diff --git a/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg
new file mode 100644
index 0000000..b265fe1
--- /dev/null
+++ b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz
new file mode 100644
index 0000000..ede930b
Binary files /dev/null and b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz differ
diff --git a/priv/static/images/pieces/white/pawn.svg b/priv/static/images/pieces/white/pawn.svg
new file mode 100644
index 0000000..b265fe1
--- /dev/null
+++ b/priv/static/images/pieces/white/pawn.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/priv/static/images/pieces/white/pawn.svg.gz b/priv/static/images/pieces/white/pawn.svg.gz
new file mode 100644
index 0000000..ede930b
Binary files /dev/null and b/priv/static/images/pieces/white/pawn.svg.gz differ
diff --git a/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg
new file mode 100644
index 0000000..8df7c8f
--- /dev/null
+++ b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg
@@ -0,0 +1,15 @@
+
+
+
diff --git a/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz
new file mode 100644
index 0000000..e519860
Binary files /dev/null and b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz differ
diff --git a/priv/static/images/pieces/white/queen.svg b/priv/static/images/pieces/white/queen.svg
new file mode 100644
index 0000000..8df7c8f
--- /dev/null
+++ b/priv/static/images/pieces/white/queen.svg
@@ -0,0 +1,15 @@
+
+
+
diff --git a/priv/static/images/pieces/white/queen.svg.gz b/priv/static/images/pieces/white/queen.svg.gz
new file mode 100644
index 0000000..e519860
Binary files /dev/null and b/priv/static/images/pieces/white/queen.svg.gz differ
diff --git a/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg
new file mode 100644
index 0000000..ac03578
--- /dev/null
+++ b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg
@@ -0,0 +1,25 @@
+
+
+
diff --git a/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz
new file mode 100644
index 0000000..30b59b7
Binary files /dev/null and b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz differ
diff --git a/priv/static/images/pieces/white/rook.svg b/priv/static/images/pieces/white/rook.svg
new file mode 100644
index 0000000..ac03578
--- /dev/null
+++ b/priv/static/images/pieces/white/rook.svg
@@ -0,0 +1,25 @@
+
+
+
diff --git a/priv/static/images/pieces/white/rook.svg.gz b/priv/static/images/pieces/white/rook.svg.gz
new file mode 100644
index 0000000..30b59b7
Binary files /dev/null and b/priv/static/images/pieces/white/rook.svg.gz differ
diff --git a/test/chesstrainer/tags_test.exs b/test/chesstrainer/tags_test.exs
new file mode 100644
index 0000000..7f06c2f
--- /dev/null
+++ b/test/chesstrainer/tags_test.exs
@@ -0,0 +1,61 @@
+defmodule Chesstrainer.TagsTest do
+ use Chesstrainer.DataCase
+
+ alias Chesstrainer.Tags
+
+ describe "tags" do
+ alias Chesstrainer.Tags.Tag
+
+ import Chesstrainer.TagsFixtures
+
+ @invalid_attrs %{name: nil, category: nil}
+
+ test "list_tags/0 returns all tags" do
+ tag = tag_fixture()
+ assert Tags.list_tags() == [tag]
+ end
+
+ test "get_tag!/1 returns the tag with given id" do
+ tag = tag_fixture()
+ assert Tags.get_tag!(tag.id) == tag
+ end
+
+ test "create_tag/1 with valid data creates a tag" do
+ valid_attrs = %{name: "some name", category: "some category"}
+
+ assert {:ok, %Tag{} = tag} = Tags.create_tag(valid_attrs)
+ assert tag.name == "some name"
+ assert tag.category == "some category"
+ end
+
+ test "create_tag/1 with invalid data returns error changeset" do
+ assert {:error, %Ecto.Changeset{}} = Tags.create_tag(@invalid_attrs)
+ end
+
+ test "update_tag/2 with valid data updates the tag" do
+ tag = tag_fixture()
+ update_attrs = %{name: "some updated name", category: "some updated category"}
+
+ assert {:ok, %Tag{} = tag} = Tags.update_tag(tag, update_attrs)
+ assert tag.name == "some updated name"
+ assert tag.category == "some updated category"
+ end
+
+ test "update_tag/2 with invalid data returns error changeset" do
+ tag = tag_fixture()
+ assert {:error, %Ecto.Changeset{}} = Tags.update_tag(tag, @invalid_attrs)
+ assert tag == Tags.get_tag!(tag.id)
+ end
+
+ test "delete_tag/1 deletes the tag" do
+ tag = tag_fixture()
+ assert {:ok, %Tag{}} = Tags.delete_tag(tag)
+ assert_raise Ecto.NoResultsError, fn -> Tags.get_tag!(tag.id) end
+ end
+
+ test "change_tag/1 returns a tag changeset" do
+ tag = tag_fixture()
+ assert %Ecto.Changeset{} = Tags.change_tag(tag)
+ end
+ end
+end
diff --git a/test/support/fixtures/tags_fixtures.ex b/test/support/fixtures/tags_fixtures.ex
new file mode 100644
index 0000000..4fea40f
--- /dev/null
+++ b/test/support/fixtures/tags_fixtures.ex
@@ -0,0 +1,26 @@
+defmodule Chesstrainer.TagsFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Chesstrainer.Tags` context.
+ """
+
+ @doc """
+ Generate a unique tag name.
+ """
+ def unique_tag_name, do: "some name#{System.unique_integer([:positive])}"
+
+ @doc """
+ Generate a tag.
+ """
+ def tag_fixture(attrs \\ %{}) do
+ {:ok, tag} =
+ attrs
+ |> Enum.into(%{
+ category: "some category",
+ name: unique_tag_name()
+ })
+ |> Chesstrainer.Tags.create_tag()
+
+ tag
+ end
+end