Initial commit
This commit is contained in:
commit
c39ae78b86
58 changed files with 2105 additions and 0 deletions
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
[
|
||||||
|
import_deps: [:phoenix],
|
||||||
|
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# The directory Mix will write compiled artifacts to.
|
||||||
|
/_build/
|
||||||
|
|
||||||
|
# If you run "mix test --cover", coverage assets end up here.
|
||||||
|
/cover/
|
||||||
|
|
||||||
|
# The directory Mix downloads your dependencies sources to.
|
||||||
|
/deps/
|
||||||
|
|
||||||
|
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||||
|
/doc/
|
||||||
|
|
||||||
|
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||||
|
/.fetch
|
||||||
|
|
||||||
|
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||||
|
erl_crash.dump
|
||||||
|
|
||||||
|
# Also ignore archive artifacts (built via "mix archive.build").
|
||||||
|
*.ez
|
||||||
|
|
||||||
|
# Ignore package tarball (built via "mix hex.build").
|
||||||
|
tracker_app-*.tar
|
||||||
|
|
||||||
|
# Default db directory for dev
|
||||||
|
/db/
|
||||||
|
|
||||||
|
# Secret configs
|
||||||
|
config/*.secret.exs
|
2
.tool-versions
Normal file
2
.tool-versions
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
elixir 1.11.0-otp-23
|
||||||
|
erlang 23.1.1
|
18
README.md
Normal file
18
README.md
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# TrackerApp
|
||||||
|
|
||||||
|
To start your Phoenix server:
|
||||||
|
|
||||||
|
* Install dependencies with `mix deps.get`
|
||||||
|
* Start Phoenix endpoint with `mix phx.server`
|
||||||
|
|
||||||
|
Now you can visit [`localhost:55864`](http://localhost:55864) from your browser.
|
||||||
|
|
||||||
|
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
* Official website: https://www.phoenixframework.org/
|
||||||
|
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||||
|
* Docs: https://hexdocs.pm/phoenix
|
||||||
|
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||||
|
* Source: https://github.com/phoenixframework/phoenix
|
28
config/config.exs
Normal file
28
config/config.exs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# This file is responsible for configuring your application
|
||||||
|
# and its dependencies with the aid of the Mix.Config module.
|
||||||
|
#
|
||||||
|
# This configuration file is loaded before any dependency and
|
||||||
|
# is restricted to this project.
|
||||||
|
|
||||||
|
# General application configuration
|
||||||
|
use Mix.Config
|
||||||
|
|
||||||
|
# Configures the endpoint
|
||||||
|
config :tracker_app, TrackerAppWeb.Endpoint,
|
||||||
|
url: [host: "localhost"],
|
||||||
|
secret_key_base: "fTB7icMy0XnADRAB8P/j/bhSx2bmbGBaeVIj/TCA2gBJKDqvkBzaLCTKqM+3524M",
|
||||||
|
render_errors: [view: TrackerAppWeb.ErrorView, accepts: ~w(html json), layout: false],
|
||||||
|
pubsub_server: TrackerApp.PubSub,
|
||||||
|
live_view: [signing_salt: "h081us1G"]
|
||||||
|
|
||||||
|
# Configures Elixir's Logger
|
||||||
|
config :logger, :console,
|
||||||
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
metadata: [:request_id]
|
||||||
|
|
||||||
|
# Use Jason for JSON parsing in Phoenix
|
||||||
|
config :phoenix, :json_library, Jason
|
||||||
|
|
||||||
|
# Import environment specific config. This must remain at the bottom
|
||||||
|
# of this file so it overrides the configuration defined above.
|
||||||
|
import_config "#{Mix.env()}.exs"
|
59
config/dev.exs
Normal file
59
config/dev.exs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
use Mix.Config
|
||||||
|
|
||||||
|
# For development, we disable any cache and enable
|
||||||
|
# debugging and code reloading.
|
||||||
|
#
|
||||||
|
# The watchers configuration can be used to run external
|
||||||
|
# watchers to your application. For example, we use it
|
||||||
|
# with webpack to recompile .js and .css sources.
|
||||||
|
config :tracker_app, TrackerAppWeb.Endpoint,
|
||||||
|
http: [port: 55864],
|
||||||
|
debug_errors: true,
|
||||||
|
code_reloader: true,
|
||||||
|
check_origin: false,
|
||||||
|
watchers: []
|
||||||
|
|
||||||
|
# ## SSL Support
|
||||||
|
#
|
||||||
|
# In order to use HTTPS in development, a self-signed
|
||||||
|
# certificate can be generated by running the following
|
||||||
|
# Mix task:
|
||||||
|
#
|
||||||
|
# mix phx.gen.cert
|
||||||
|
#
|
||||||
|
# Note that this task requires Erlang/OTP 20 or later.
|
||||||
|
# Run `mix help phx.gen.cert` for more information.
|
||||||
|
#
|
||||||
|
# The `http:` config above can be replaced with:
|
||||||
|
#
|
||||||
|
# https: [
|
||||||
|
# port: 4001,
|
||||||
|
# cipher_suite: :strong,
|
||||||
|
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||||
|
# certfile: "priv/cert/selfsigned.pem"
|
||||||
|
# ],
|
||||||
|
#
|
||||||
|
# If desired, both `http:` and `https:` keys can be
|
||||||
|
# configured to run both http and https servers on
|
||||||
|
# different ports.
|
||||||
|
|
||||||
|
# Watch static and templates for browser reloading.
|
||||||
|
config :tracker_app, TrackerAppWeb.Endpoint,
|
||||||
|
live_reload: [
|
||||||
|
patterns: [
|
||||||
|
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||||
|
~r"priv/gettext/.*(po)$",
|
||||||
|
~r"lib/tracker_app_web/(live|views)/.*(ex)$",
|
||||||
|
~r"lib/tracker_app_web/templates/.*(eex)$"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Do not include metadata nor timestamps in development logs
|
||||||
|
config :logger, :console, format: "[$level] $message\n"
|
||||||
|
|
||||||
|
# Set a higher stacktrace during development. Avoid configuring such
|
||||||
|
# in production as building large stacktraces may be expensive.
|
||||||
|
config :phoenix, :stacktrace_depth, 20
|
||||||
|
|
||||||
|
# Initialize plugs at runtime for faster development compilation
|
||||||
|
config :phoenix, :plug_init_mode, :runtime
|
55
config/prod.exs
Normal file
55
config/prod.exs
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
use Mix.Config
|
||||||
|
|
||||||
|
# For production, don't forget to configure the url host
|
||||||
|
# to something meaningful, Phoenix uses this information
|
||||||
|
# when generating URLs.
|
||||||
|
#
|
||||||
|
# Note we also include the path to a cache manifest
|
||||||
|
# containing the digested version of static files. This
|
||||||
|
# manifest is generated by the `mix phx.digest` task,
|
||||||
|
# which you should run after static files are built and
|
||||||
|
# before starting your production server.
|
||||||
|
config :tracker_app, TrackerAppWeb.Endpoint,
|
||||||
|
url: [host: "example.com", port: 80],
|
||||||
|
cache_static_manifest: "priv/static/cache_manifest.json"
|
||||||
|
|
||||||
|
# Do not print debug messages in production
|
||||||
|
config :logger, level: :info
|
||||||
|
|
||||||
|
# ## SSL Support
|
||||||
|
#
|
||||||
|
# To get SSL working, you will need to add the `https` key
|
||||||
|
# to the previous section and set your `:url` port to 443:
|
||||||
|
#
|
||||||
|
# config :tracker_app, TrackerAppWeb.Endpoint,
|
||||||
|
# ...
|
||||||
|
# url: [host: "example.com", port: 443],
|
||||||
|
# https: [
|
||||||
|
# port: 443,
|
||||||
|
# cipher_suite: :strong,
|
||||||
|
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
||||||
|
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH"),
|
||||||
|
# transport_options: [socket_opts: [:inet6]]
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# The `cipher_suite` is set to `:strong` to support only the
|
||||||
|
# latest and more secure SSL ciphers. This means old browsers
|
||||||
|
# and clients may not be supported. You can set it to
|
||||||
|
# `:compatible` for wider support.
|
||||||
|
#
|
||||||
|
# `:keyfile` and `:certfile` expect an absolute path to the key
|
||||||
|
# and cert in disk or a relative path inside priv, for example
|
||||||
|
# "priv/ssl/server.key". For all supported SSL configuration
|
||||||
|
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
||||||
|
#
|
||||||
|
# We also recommend setting `force_ssl` in your endpoint, ensuring
|
||||||
|
# no data is ever sent via http, always redirecting to https:
|
||||||
|
#
|
||||||
|
# config :tracker_app, TrackerAppWeb.Endpoint,
|
||||||
|
# force_ssl: [hsts: true]
|
||||||
|
#
|
||||||
|
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||||
|
|
||||||
|
# Finally import the config/prod.secret.exs which loads secrets
|
||||||
|
# and configuration from environment variables.
|
||||||
|
import_config "prod.secret.exs"
|
3
config/runtime.exs
Normal file
3
config/runtime.exs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import Config
|
||||||
|
|
||||||
|
config :tracker_app, file_db_path: "db"
|
10
config/test.exs
Normal file
10
config/test.exs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
use Mix.Config
|
||||||
|
|
||||||
|
# We don't run a server during test. If one is required,
|
||||||
|
# you can enable the server option below.
|
||||||
|
config :tracker_app, TrackerAppWeb.Endpoint,
|
||||||
|
http: [port: 4002],
|
||||||
|
server: false
|
||||||
|
|
||||||
|
# Print only warnings and errors during test
|
||||||
|
config :logger, level: :warn
|
54
lib/file_db/file_db.ex
Normal file
54
lib/file_db/file_db.ex
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
defmodule TrackerApp.FileDB do
|
||||||
|
@moduledoc """
|
||||||
|
Flat file key-value store where each key represents a filename and values are stored in those
|
||||||
|
files using `:erlang.term_to_binary/2`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@file_prefix "filedb_"
|
||||||
|
@file_extension "dat"
|
||||||
|
|
||||||
|
@typedoc "ID (key) for data, only use `[a-zA-Z0-9]` for safety."
|
||||||
|
@type id :: String.t()
|
||||||
|
|
||||||
|
@spec has_id?(id()) :: boolean()
|
||||||
|
def has_id?(id) do
|
||||||
|
id |> path_to() |> File.exists?()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec read(id()) :: {:ok, term()} | {:error, File.posix()}
|
||||||
|
def read(id) do
|
||||||
|
with {:ok, data} <- File.read(path_to(id)), do: {:ok, from_file_format(data)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec write(id(), term()) :: :ok | {:error, File.posix()}
|
||||||
|
def write(id, data) do
|
||||||
|
File.write(path_to(id), to_file_format(data))
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec delete(id()) :: :ok | {:error, File.posix()}
|
||||||
|
def delete(id) do
|
||||||
|
id |> path_to() |> File.rm()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec to_file_format(term()) :: binary()
|
||||||
|
defp to_file_format(data) do
|
||||||
|
:erlang.term_to_binary(data, minor_version: 2, compressed: db_compression())
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec from_file_format(binary()) :: term()
|
||||||
|
defp from_file_format(data) do
|
||||||
|
:erlang.binary_to_term(data)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec path_to(id()) :: String.t()
|
||||||
|
defp path_to(<<prefix::binary-size(2), _rest::binary>> = id) do
|
||||||
|
Path.join([db_path(), prefix, "#{@file_prefix}#{id}.#{@file_extension}"])
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec db_path() :: String.t()
|
||||||
|
defp db_path(),
|
||||||
|
do: Application.get_env(:tracker_app, :file_db_path) || raise("Missing FileDB path!")
|
||||||
|
|
||||||
|
@spec db_compression() :: 0..9
|
||||||
|
defp db_compression(), do: Application.get_env(:tracker_app, :file_db_compression, 0)
|
||||||
|
end
|
48
lib/item_db/item_db.ex
Normal file
48
lib/item_db/item_db.ex
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
defmodule TrackerApp.ItemDB do
|
||||||
|
@moduledoc """
|
||||||
|
Database for account and item data, uses FileDB as a backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias TrackerApp.FileDB
|
||||||
|
alias TrackerApp.ItemDB.Schemas.Account
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Check if account exists with given id.
|
||||||
|
"""
|
||||||
|
@spec account_exists?(Account.id()) :: boolean()
|
||||||
|
def account_exists?(id) do
|
||||||
|
FileDB.has_id?(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Create new empty account. Does not store the account, just returns it.
|
||||||
|
"""
|
||||||
|
@spec create_account() :: Account.t()
|
||||||
|
def create_account() do
|
||||||
|
Account.new()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_account(Account.id()) :: {:ok, Account.t()} | {:error, term()}
|
||||||
|
def get_account(id) do
|
||||||
|
case FileDB.read(id) do
|
||||||
|
{:ok, data} -> {:ok, Account.from_storage(data)}
|
||||||
|
{:error, posix} -> {:error, "Unable to read file due to: #{inspect(posix)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec update_account(Account.t()) :: :ok | {:error, term()}
|
||||||
|
def update_account(account) do
|
||||||
|
case FileDB.write(account.id, Account.to_storage(account)) do
|
||||||
|
:ok -> :ok
|
||||||
|
{:error, posix} -> {:error, "Unable to write file due to: #{inspect(posix)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec delete_account(Account.id()) :: :ok | {:error, String.t()}
|
||||||
|
def delete_account(id) do
|
||||||
|
case FileDB.delete(id) do
|
||||||
|
:ok -> :ok
|
||||||
|
{:error, posix} -> {:error, "Unable to delete file due to: #{inspect(posix)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
58
lib/item_db/schemas/account.ex
Normal file
58
lib/item_db/schemas/account.ex
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
defmodule TrackerApp.ItemDB.Schemas.Account do
|
||||||
|
import TrackerApp.Utils.TypedStruct
|
||||||
|
|
||||||
|
alias TrackerApp.ItemDB.Schemas.{Item, Types}
|
||||||
|
|
||||||
|
@current_version 1
|
||||||
|
|
||||||
|
deftypedstruct(%{
|
||||||
|
id: Types.id(),
|
||||||
|
items: {[Item.t()], []},
|
||||||
|
paid_until: {Date.t() | nil, nil},
|
||||||
|
version: {1, @current_version}
|
||||||
|
})
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Create new account structure.
|
||||||
|
"""
|
||||||
|
@spec new() :: t()
|
||||||
|
def new() do
|
||||||
|
%__MODULE__{
|
||||||
|
id: UUID.uuid4()
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec to_storage(t()) :: map()
|
||||||
|
def to_storage(account) do
|
||||||
|
items = Enum.map(account.items, &Item.to_storage/1)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: account.id,
|
||||||
|
items: items,
|
||||||
|
paid_until: account.paid_until,
|
||||||
|
version: @current_version
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec from_storage(map()) :: Account.t()
|
||||||
|
def from_storage(data), do: from_storage(data, data.version)
|
||||||
|
|
||||||
|
@spec from_storage(map(), Types.version()) :: Account.t()
|
||||||
|
def from_storage(data, version)
|
||||||
|
|
||||||
|
def from_storage(data, 1) do
|
||||||
|
items = Map.get(data, :items) |> Enum.map(&Item.from_storage(&1, 1))
|
||||||
|
|
||||||
|
%__MODULE__{
|
||||||
|
id: data.id,
|
||||||
|
items: items,
|
||||||
|
paid_until: data.paid_until,
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec find_item_by_id(t(), Types.id()) :: Item.t() | nil
|
||||||
|
def find_item_by_id(account, id) do
|
||||||
|
Enum.find(account.items, &(&1.id == id))
|
||||||
|
end
|
||||||
|
end
|
37
lib/item_db/schemas/item.ex
Normal file
37
lib/item_db/schemas/item.ex
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
defmodule TrackerApp.ItemDB.Schemas.Item do
|
||||||
|
import TrackerApp.Utils.TypedStruct
|
||||||
|
|
||||||
|
deftypedstruct(%{
|
||||||
|
id: TrackerApp.ItemDB.Schemas.Types.id(),
|
||||||
|
name: {String.t(), ""},
|
||||||
|
expiry_date: {Date.t() | nil, nil},
|
||||||
|
amount: {pos_integer(), 0},
|
||||||
|
amount_unit: {String.t() | nil, nil}
|
||||||
|
})
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Create new item structure.
|
||||||
|
"""
|
||||||
|
@spec new() :: t()
|
||||||
|
def new() do
|
||||||
|
%__MODULE__{
|
||||||
|
id: UUID.uuid4()
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec to_storage(t()) :: map()
|
||||||
|
def to_storage(item), do: Map.from_struct(item)
|
||||||
|
|
||||||
|
@spec from_storage(map(), TrackerApp.ItemDB.Schemas.Types.version()) :: t()
|
||||||
|
def from_storage(data, version)
|
||||||
|
|
||||||
|
def from_storage(data, 1) do
|
||||||
|
%__MODULE__{
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
expiry_date: data.expiry_date,
|
||||||
|
amount: data.amount,
|
||||||
|
amount_unit: data.amount_unit
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
7
lib/item_db/schemas/types.ex
Normal file
7
lib/item_db/schemas/types.ex
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
defmodule TrackerApp.ItemDB.Schemas.Types do
|
||||||
|
@typedoc "Version of data in storage."
|
||||||
|
@type version :: 1
|
||||||
|
|
||||||
|
@typedoc "UUIDv4 used as object identifier"
|
||||||
|
@type id :: String.t()
|
||||||
|
end
|
5
lib/item_db/schemas/utils.ex
Normal file
5
lib/item_db/schemas/utils.ex
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
defmodule TrackerApp.ItemDB.Schemas.Utils do
|
||||||
|
@id_re ~R/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||||
|
|
||||||
|
def id_re(), do: @id_re
|
||||||
|
end
|
121
lib/tracker_app/account_manager.ex
Normal file
121
lib/tracker_app/account_manager.ex
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
defmodule TrackerApp.AccountManager do
|
||||||
|
@moduledoc """
|
||||||
|
Account manager is responsible for loading account information from files and saving it back,
|
||||||
|
plus updating listening processes about the changes in information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer, restart: :transient
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
import TrackerApp.Utils.TypedStruct
|
||||||
|
|
||||||
|
alias TrackerApp.ItemDB
|
||||||
|
alias TrackerApp.ItemDB.Schemas.{Account, Types}
|
||||||
|
|
||||||
|
@alive_check_interval 10 * 1_000
|
||||||
|
|
||||||
|
defmodule Options do
|
||||||
|
deftypedstruct(%{
|
||||||
|
id: Types.id()
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule State do
|
||||||
|
deftypedstruct(%{
|
||||||
|
data: Account.t(),
|
||||||
|
update_ref: {reference() | nil, nil}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec start_link(Options.t()) :: GenServer.on_start()
|
||||||
|
def start_link(%Options{} = options) do
|
||||||
|
GenServer.start_link(__MODULE__, %{id: options.id},
|
||||||
|
name: {:via, Registry, {__MODULE__.NameRegistry, id_to_name(options.id)}}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
### SERVER API
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
@spec init(%{id: Types.id()}) :: {:ok, State.t()} | {:stop, :account_not_found}
|
||||||
|
def init(%{id: id}) do
|
||||||
|
Logger.debug("Starting AccountManger for #{id}...")
|
||||||
|
|
||||||
|
case ItemDB.get_account(id) do
|
||||||
|
{:ok, account} ->
|
||||||
|
schedule_check()
|
||||||
|
{:ok, %State{data: account}}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
{:stop, :account_not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(msg, from, state)
|
||||||
|
|
||||||
|
def handle_call(:get_data, _from, %State{} = state) do
|
||||||
|
{:reply, state.data, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:update_data, %Account{} = account}, _from, %State{} = state) do
|
||||||
|
case ItemDB.update_account(account) do
|
||||||
|
:ok ->
|
||||||
|
Logger.debug("Account data updated for #{state.data.id}, updating listeners...")
|
||||||
|
listeners = Registry.lookup(__MODULE__.ClientRegistry, id_to_name(state.data.id))
|
||||||
|
Enum.each(listeners, &send(&1 |> elem(0), {__MODULE__, :data_updated, account}))
|
||||||
|
{:reply, :ok, %State{state | data: account}}
|
||||||
|
|
||||||
|
{:error, term} ->
|
||||||
|
{:reply, {:error, term}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(msg, state)
|
||||||
|
|
||||||
|
def handle_info(:check_alive, %State{} = state) do
|
||||||
|
with results when results != [] <-
|
||||||
|
Registry.lookup(__MODULE__.ClientRegistry, id_to_name(state.data.id)),
|
||||||
|
alive when alive != [] <- Enum.filter(results, &(&1 |> elem(0) |> Process.alive?())) do
|
||||||
|
schedule_check()
|
||||||
|
{:noreply, state}
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
Logger.debug("No more listeners, AccountManager for #{state.data.id} closing...")
|
||||||
|
:ok = ItemDB.update_account(state.data)
|
||||||
|
{:stop, :normal, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
### CLIENT API
|
||||||
|
|
||||||
|
@spec start(Options.t()) :: DynamicSupervisor.on_start_child()
|
||||||
|
def start(%Options{} = options) do
|
||||||
|
DynamicSupervisor.start_child(__MODULE__.Supervisor, {__MODULE__, options})
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_data(GenServer.name()) :: Account.t()
|
||||||
|
def get_data(server) do
|
||||||
|
GenServer.call(server, :get_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec update_data(GenServer.name(), Account.t()) :: :ok
|
||||||
|
def update_data(server, %Account{} = account) do
|
||||||
|
GenServer.call(server, {:update_data, account})
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec register(GenServer.name()) :: Account.t()
|
||||||
|
def register(server) do
|
||||||
|
account = get_data(server)
|
||||||
|
{:ok, _} = Registry.register(__MODULE__.ClientRegistry, id_to_name(account.id), nil)
|
||||||
|
account
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec schedule_check() :: reference()
|
||||||
|
defp schedule_check(), do: Process.send_after(self(), :check_alive, @alive_check_interval)
|
||||||
|
|
||||||
|
@spec id_to_name(Types.id()) :: String.t()
|
||||||
|
defp id_to_name(id), do: "account:#{id}"
|
||||||
|
end
|
35
lib/tracker_app/application.ex
Normal file
35
lib/tracker_app/application.ex
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
defmodule TrackerApp.Application do
|
||||||
|
# See https://hexdocs.pm/elixir/Application.html
|
||||||
|
# for more information on OTP Applications
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Application
|
||||||
|
|
||||||
|
def start(_type, _args) do
|
||||||
|
children = [
|
||||||
|
# Start the Telemetry supervisor
|
||||||
|
TrackerAppWeb.Telemetry,
|
||||||
|
# Start the PubSub system
|
||||||
|
{Phoenix.PubSub, name: TrackerApp.PubSub},
|
||||||
|
# Start the Endpoint (http/https)
|
||||||
|
TrackerAppWeb.Endpoint,
|
||||||
|
# Start a worker by calling: TrackerApp.Worker.start_link(arg)
|
||||||
|
# {TrackerApp.Worker, arg}
|
||||||
|
{Registry, keys: :unique, name: TrackerApp.AccountManager.NameRegistry},
|
||||||
|
{Registry, keys: :duplicate, name: TrackerApp.AccountManager.ClientRegistry},
|
||||||
|
{DynamicSupervisor, strategy: :one_for_one, name: TrackerApp.AccountManager.Supervisor}
|
||||||
|
]
|
||||||
|
|
||||||
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
|
# for other strategies and supported options
|
||||||
|
opts = [strategy: :one_for_one, name: TrackerApp.Supervisor]
|
||||||
|
Supervisor.start_link(children, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tell Phoenix to update the endpoint configuration
|
||||||
|
# whenever the application is updated.
|
||||||
|
def config_change(changed, _new, removed) do
|
||||||
|
TrackerAppWeb.Endpoint.config_change(changed, removed)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
71
lib/tracker_app/utils/typed_struct.ex
Normal file
71
lib/tracker_app/utils/typed_struct.ex
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule TrackerApp.Utils.TypedStruct do
|
||||||
|
@doc """
|
||||||
|
Create typed struct with a type, default values, and enforced keys.
|
||||||
|
|
||||||
|
Input should be a map where the key names are names of the struct keys and values are the
|
||||||
|
field information. The value can be a typespec, in which case the field will be enforced, or
|
||||||
|
a 2-tuple of `{typespec, default_value}`, making the field unenforced.
|
||||||
|
|
||||||
|
To prevent ambiguity, a value of `{typespec, :ts_enforced}` will be interpreted as enforced,
|
||||||
|
this will allow you to typespec a 2-tuple.
|
||||||
|
|
||||||
|
NOTE: Due to the ambiguity removal technique above, `:ts_enforced` is not allowed as a default
|
||||||
|
value.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
deftypedstruct(%{
|
||||||
|
# Enforced with simple type
|
||||||
|
foo: integer(),
|
||||||
|
|
||||||
|
# Enforced 2-tuple typed field, written like this to remove ambiguity
|
||||||
|
bar: {{String.t(), integer()}, :ts_enforced},
|
||||||
|
|
||||||
|
# Non-enforced field with default value
|
||||||
|
baz: {any(), ""}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
defmacro deftypedstruct(fields) do
|
||||||
|
fields_list =
|
||||||
|
case fields do
|
||||||
|
{:%{}, _, flist} -> flist
|
||||||
|
_ -> raise ArgumentError, "Fields must be a map!"
|
||||||
|
end
|
||||||
|
|
||||||
|
enforced_list =
|
||||||
|
fields_list
|
||||||
|
|> Enum.filter(fn
|
||||||
|
{_, {_, :ts_enforced}} -> true
|
||||||
|
{_, {_, _}} -> false
|
||||||
|
{_, _} -> true
|
||||||
|
end)
|
||||||
|
|> Enum.map(&elem(&1, 0))
|
||||||
|
|
||||||
|
field_specs =
|
||||||
|
Enum.map(fields_list, fn
|
||||||
|
{field, {typespec, :ts_enforced}} ->
|
||||||
|
{field, typespec}
|
||||||
|
|
||||||
|
{field, {typespec, _}} ->
|
||||||
|
{field, typespec}
|
||||||
|
|
||||||
|
{field, typespec} ->
|
||||||
|
{field, typespec}
|
||||||
|
end)
|
||||||
|
|
||||||
|
field_vals =
|
||||||
|
Enum.map(fields_list, fn
|
||||||
|
{field, {_, :ts_enforced}} -> field
|
||||||
|
{field, {_, default}} -> {field, default}
|
||||||
|
{field, _} -> field
|
||||||
|
end)
|
||||||
|
|
||||||
|
quote do
|
||||||
|
@type t :: %__MODULE__{unquote_splicing(field_specs)}
|
||||||
|
@enforce_keys unquote(enforced_list)
|
||||||
|
defstruct unquote(field_vals)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
102
lib/tracker_app_web.ex
Normal file
102
lib/tracker_app_web.ex
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
defmodule TrackerAppWeb do
|
||||||
|
@moduledoc """
|
||||||
|
The entrypoint for defining your web interface, such
|
||||||
|
as controllers, views, channels and so on.
|
||||||
|
|
||||||
|
This can be used in your application as:
|
||||||
|
|
||||||
|
use TrackerAppWeb, :controller
|
||||||
|
use TrackerAppWeb, :view
|
||||||
|
|
||||||
|
The definitions below will be executed for every view,
|
||||||
|
controller, etc, so keep them short and clean, focused
|
||||||
|
on imports, uses and aliases.
|
||||||
|
|
||||||
|
Do NOT define functions inside the quoted expressions
|
||||||
|
below. Instead, define any helper function in modules
|
||||||
|
and import those modules here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def controller do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Controller, namespace: TrackerAppWeb
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
import TrackerAppWeb.Gettext
|
||||||
|
alias TrackerAppWeb.Router.Helpers, as: Routes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def view do
|
||||||
|
quote do
|
||||||
|
use Phoenix.View,
|
||||||
|
root: "lib/tracker_app_web/templates",
|
||||||
|
namespace: TrackerAppWeb
|
||||||
|
|
||||||
|
# Import convenience functions from controllers
|
||||||
|
import Phoenix.Controller,
|
||||||
|
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
|
||||||
|
|
||||||
|
# Include shared imports and aliases for views
|
||||||
|
unquote(view_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def live_view do
|
||||||
|
quote do
|
||||||
|
use Phoenix.LiveView,
|
||||||
|
layout: {TrackerAppWeb.LayoutView, "live.html"}
|
||||||
|
|
||||||
|
unquote(view_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def live_component do
|
||||||
|
quote do
|
||||||
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
|
unquote(view_helpers())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def router do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Router
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
import Phoenix.LiveView.Router
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Channel
|
||||||
|
import TrackerAppWeb.Gettext
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp view_helpers do
|
||||||
|
quote do
|
||||||
|
# Use all HTML functionality (forms, tags, etc)
|
||||||
|
use Phoenix.HTML
|
||||||
|
|
||||||
|
# Import LiveView helpers (live_render, live_component, live_patch, etc)
|
||||||
|
import Phoenix.LiveView.Helpers
|
||||||
|
|
||||||
|
# Import basic rendering functionality (render, render_layout, etc)
|
||||||
|
import Phoenix.View
|
||||||
|
|
||||||
|
import TrackerAppWeb.ErrorHelpers
|
||||||
|
import TrackerAppWeb.Gettext
|
||||||
|
alias TrackerAppWeb.Router.Helpers, as: Routes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
When used, dispatch to the appropriate controller/view/etc.
|
||||||
|
"""
|
||||||
|
defmacro __using__(which) when is_atom(which) do
|
||||||
|
apply(__MODULE__, which, [])
|
||||||
|
end
|
||||||
|
end
|
49
lib/tracker_app_web/endpoint.ex
Normal file
49
lib/tracker_app_web/endpoint.ex
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
defmodule TrackerAppWeb.Endpoint do
|
||||||
|
use Phoenix.Endpoint, otp_app: :tracker_app
|
||||||
|
|
||||||
|
# The session will be stored in the cookie and signed,
|
||||||
|
# this means its contents can be read but not tampered with.
|
||||||
|
# Set :encryption_salt if you would also like to encrypt it.
|
||||||
|
@session_options [
|
||||||
|
store: :cookie,
|
||||||
|
key: "_tracker_app_key",
|
||||||
|
signing_salt: "JYoZEADW"
|
||||||
|
]
|
||||||
|
|
||||||
|
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
|
||||||
|
|
||||||
|
# Serve at "/" the static files from "priv/static" directory.
|
||||||
|
#
|
||||||
|
# You should set gzip to true if you are running phx.digest
|
||||||
|
# when deploying your static files in production.
|
||||||
|
plug Plug.Static,
|
||||||
|
at: "/",
|
||||||
|
from: :tracker_app,
|
||||||
|
gzip: false,
|
||||||
|
only: ~w(css fonts images js favicon.ico robots.txt)
|
||||||
|
|
||||||
|
# Code reloading can be explicitly enabled under the
|
||||||
|
# :code_reloader configuration of your endpoint.
|
||||||
|
if code_reloading? do
|
||||||
|
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||||
|
plug Phoenix.LiveReloader
|
||||||
|
plug Phoenix.CodeReloader
|
||||||
|
end
|
||||||
|
|
||||||
|
plug Phoenix.LiveDashboard.RequestLogger,
|
||||||
|
param_key: "request_logger",
|
||||||
|
cookie_key: "request_logger"
|
||||||
|
|
||||||
|
plug Plug.RequestId
|
||||||
|
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||||
|
|
||||||
|
plug Plug.Parsers,
|
||||||
|
parsers: [:urlencoded, :multipart, :json],
|
||||||
|
pass: ["*/*"],
|
||||||
|
json_decoder: Phoenix.json_library()
|
||||||
|
|
||||||
|
plug Plug.MethodOverride
|
||||||
|
plug Plug.Head
|
||||||
|
plug Plug.Session, @session_options
|
||||||
|
plug TrackerAppWeb.Router
|
||||||
|
end
|
24
lib/tracker_app_web/gettext.ex
Normal file
24
lib/tracker_app_web/gettext.ex
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
defmodule TrackerAppWeb.Gettext do
|
||||||
|
@moduledoc """
|
||||||
|
A module providing Internationalization with a gettext-based API.
|
||||||
|
|
||||||
|
By using [Gettext](https://hexdocs.pm/gettext),
|
||||||
|
your module gains a set of macros for translations, for example:
|
||||||
|
|
||||||
|
import TrackerAppWeb.Gettext
|
||||||
|
|
||||||
|
# Simple translation
|
||||||
|
gettext("Here is the string to translate")
|
||||||
|
|
||||||
|
# Plural translation
|
||||||
|
ngettext("Here is the string to translate",
|
||||||
|
"Here are the strings to translate",
|
||||||
|
3)
|
||||||
|
|
||||||
|
# Domain-based translation
|
||||||
|
dgettext("errors", "Here is the error message to translate")
|
||||||
|
|
||||||
|
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||||
|
"""
|
||||||
|
use Gettext, otp_app: :tracker_app
|
||||||
|
end
|
78
lib/tracker_app_web/live/account_live.ex
Normal file
78
lib/tracker_app_web/live/account_live.ex
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
defmodule TrackerAppWeb.AccountLive do
|
||||||
|
use TrackerAppWeb, :live_view
|
||||||
|
|
||||||
|
alias TrackerApp.ItemDB.Schemas.Account
|
||||||
|
alias TrackerApp.AccountManager
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"accountId" => id}, _session, socket) do
|
||||||
|
manager =
|
||||||
|
case AccountManager.start(%AccountManager.Options{id: id}) do
|
||||||
|
{:ok, pid} -> pid
|
||||||
|
{:error, {:already_started, pid}} -> pid
|
||||||
|
{:error, :account_not_found} -> :account_not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
if manager != :account_not_found do
|
||||||
|
account =
|
||||||
|
if connected?(socket) do
|
||||||
|
AccountManager.register(manager)
|
||||||
|
else
|
||||||
|
AccountManager.get_data(manager)
|
||||||
|
end
|
||||||
|
|
||||||
|
socket = common_assigns(socket, account)
|
||||||
|
{:ok, assign(socket, account: account, manager: manager)}
|
||||||
|
else
|
||||||
|
{:ok, assign(socket, :account, :not_found)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event(event, params, socket)
|
||||||
|
|
||||||
|
def handle_event("add-item", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
push_redirect(socket,
|
||||||
|
to:
|
||||||
|
Routes.item_path(
|
||||||
|
socket,
|
||||||
|
socket.assigns.live_action,
|
||||||
|
socket.assigns.account.id
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("edit-item", params, socket) do
|
||||||
|
item = Account.find_item_by_id(socket.assigns.account, params["id"])
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
push_redirect(socket,
|
||||||
|
to:
|
||||||
|
Routes.item_path(socket, socket.assigns.live_action, socket.assigns.account.id, item.id)
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(msg, socket)
|
||||||
|
|
||||||
|
def handle_info({AccountManager, :data_updated, %Account{} = account}, socket) do
|
||||||
|
socket = common_assigns(socket, account)
|
||||||
|
{:noreply, assign(socket, account: account)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec common_assigns(Phoenix.LiveView.Socket.t(), Account.t()) :: Phoenix.LiveView.Socket.t()
|
||||||
|
defp common_assigns(socket, %Account{} = account) do
|
||||||
|
# TODO: UTC
|
||||||
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
{expired, nonexpired} =
|
||||||
|
Enum.split_with(account.items, &(Date.compare(&1.expiry_date, today) == :lt))
|
||||||
|
|
||||||
|
assign(
|
||||||
|
socket,
|
||||||
|
expired: expired,
|
||||||
|
nonexpired: nonexpired
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
23
lib/tracker_app_web/live/account_live.html.leex
Normal file
23
lib/tracker_app_web/live/account_live.html.leex
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<%= if @account == :not_found do %>
|
||||||
|
<%= render(TrackerAppWeb.UtilsView, "account_not_found.html", []) %>
|
||||||
|
<% else %>
|
||||||
|
<div class="profile-view">
|
||||||
|
<%= for item <- @nonexpired do %>
|
||||||
|
<div class="item-row" phx-click="edit-item" phx-value-id="<%= item.id %>">
|
||||||
|
<%= item.name %> <%= item.amount %> <%= item.amount_unit %>, expires <%= Date.to_iso8601(item.expiry_date) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<button phx-click="add-item" class="add-item">Add</button>
|
||||||
|
|
||||||
|
<%= if @expired != [] do %>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<%= for item <- @expired do %>
|
||||||
|
<div class="item-row" phx-click="edit-item" phx-value-id="<%= item.id %>">
|
||||||
|
<%= item.name %> <%= item.amount %> <%= item.amount_unit %>, expires <%= Date.to_iso8601(item.expiry_date) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
15
lib/tracker_app_web/live/components/item_editor.ex
Normal file
15
lib/tracker_app_web/live/components/item_editor.ex
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
defmodule TrackerApp.Live.Components.ItemEditor do
|
||||||
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def update(assigns, socket) do
|
||||||
|
assigns = Map.put(assigns, :invalid, false)
|
||||||
|
{:ok, %{socket | assigns: Map.merge(socket.assigns, assigns)}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("save-item", params, socket) do
|
||||||
|
IO.inspect(socket |> Map.to_list())
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
70
lib/tracker_app_web/live/components/item_editor.html.leex
Normal file
70
lib/tracker_app_web/live/components/item_editor.html.leex
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<div class="item-editor">
|
||||||
|
<form phx-submit="save-item" phx-target="<%= @myself %>">
|
||||||
|
<%= if @invalid do %>
|
||||||
|
<div class="item-editor-error">
|
||||||
|
There was an error with the input data, please check the values.
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<input name="id" type="hidden" value="<%= @item.id %>" />
|
||||||
|
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
class="item-editor-name-input"
|
||||||
|
value="<%= @item.name %>"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="item-editor-amount-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item-editor-amount-dec"
|
||||||
|
onclick="const e = document.getElementById('item-editor-amount'); e.value = Math.max(0, --e.value);"
|
||||||
|
>−</button>
|
||||||
|
<input
|
||||||
|
id="item-editor-amount"
|
||||||
|
name="amount"
|
||||||
|
type="text"
|
||||||
|
class="item-editor-amount-input"
|
||||||
|
value="<%= @item.amount %>"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item-editor-amount-inc"
|
||||||
|
onclick="++document.getElementById('item-editor-amount').value;"
|
||||||
|
>+</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
name="amount_unit"
|
||||||
|
type="text"
|
||||||
|
class="item-editor-amount-unit-input"
|
||||||
|
value="<%= @item.amount_unit %>"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-editor-expiry">
|
||||||
|
<% ed = if not is_nil(@item.expiry_date), do: Date.to_iso8601(@item.expiry_date) , else: "" %>
|
||||||
|
<input
|
||||||
|
name="expiry"
|
||||||
|
type="date"
|
||||||
|
min="<%= Date.utc_today() |> Date.to_iso8601() %>"
|
||||||
|
value="<%= ed %>"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item-editor-remove"
|
||||||
|
phx-click="delete-item"
|
||||||
|
phx-value-id="<%= @item.id %>"
|
||||||
|
>🗑</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="item-editor-save"
|
||||||
|
>✓</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
105
lib/tracker_app_web/live/item_live.ex
Normal file
105
lib/tracker_app_web/live/item_live.ex
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
defmodule TrackerAppWeb.ItemLive do
|
||||||
|
use TrackerAppWeb, :live_view
|
||||||
|
|
||||||
|
alias TrackerApp.ItemDB.Schemas.{Account, Item, Types}
|
||||||
|
alias TrackerApp.AccountManager
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"accountId" => id} = params, _session, socket) do
|
||||||
|
socket = assign(socket, invalid: false)
|
||||||
|
|
||||||
|
manager =
|
||||||
|
case AccountManager.start(%AccountManager.Options{id: id}) do
|
||||||
|
{:ok, pid} -> pid
|
||||||
|
{:error, {:already_started, pid}} -> pid
|
||||||
|
{:error, :account_not_found} -> :account_not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
if manager != :account_not_found do
|
||||||
|
account =
|
||||||
|
if connected?(socket) do
|
||||||
|
AccountManager.register(manager)
|
||||||
|
else
|
||||||
|
AccountManager.get_data(manager)
|
||||||
|
end
|
||||||
|
|
||||||
|
item_id = Map.get(params, "itemId")
|
||||||
|
|
||||||
|
{is_new?, item} =
|
||||||
|
if is_nil(item_id) do
|
||||||
|
{true, Item.new()}
|
||||||
|
else
|
||||||
|
{false, find_item_by_id(account.items, item_id) || :not_found}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, assign(socket, account: account, item: item, is_new?: is_new?, manager: manager)}
|
||||||
|
else
|
||||||
|
{:ok, assign(socket, account: :not_found)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event(event, params, socket)
|
||||||
|
|
||||||
|
def handle_event("save-item", params, socket) do
|
||||||
|
with true <- Map.has_key?(params, "name"),
|
||||||
|
true <- Map.has_key?(params, "amount_unit"),
|
||||||
|
{:ok, expiry_date} <- Date.from_iso8601(params["expiry"]),
|
||||||
|
{amount, _} when amount >= 0 <- Integer.parse(params["amount"]),
|
||||||
|
true <- String.length(params["name"]) > 0 do
|
||||||
|
existing_item = find_item_by_id(socket.assigns.account.items, socket.assigns.item.id)
|
||||||
|
|
||||||
|
item = %Item{
|
||||||
|
socket.assigns.item
|
||||||
|
| name: params["name"],
|
||||||
|
amount: amount,
|
||||||
|
expiry_date: expiry_date,
|
||||||
|
amount_unit: params["amount_unit"]
|
||||||
|
}
|
||||||
|
|
||||||
|
items =
|
||||||
|
if is_nil(existing_item) do
|
||||||
|
[item | socket.assigns.account.items]
|
||||||
|
else
|
||||||
|
[item | Enum.filter(socket.assigns.account.items, &(&1.id != item.id))]
|
||||||
|
end
|
||||||
|
|
||||||
|
account = %Account{socket.assigns.account | items: items}
|
||||||
|
:ok = AccountManager.update_data(socket.assigns.manager, account)
|
||||||
|
|
||||||
|
{:noreply, push_redirect(socket, to: Routes.account_path(socket, :paid, account.id))}
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
{:noreply, assign(socket, invalid: true)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("delete-item", _params, socket) do
|
||||||
|
existing_item = find_item_by_id(socket.assigns.account.items, socket.assigns.item.id)
|
||||||
|
|
||||||
|
items =
|
||||||
|
if not is_nil(existing_item) do
|
||||||
|
Enum.filter(socket.assigns.account.items, &(&1.id != socket.assigns.item.id))
|
||||||
|
else
|
||||||
|
# Item had already been deleted, do nothing
|
||||||
|
socket.assigns.account.items
|
||||||
|
end
|
||||||
|
|
||||||
|
account = %Account{socket.assigns.account | items: items}
|
||||||
|
:ok = AccountManager.update_data(socket.assigns.manager, account)
|
||||||
|
|
||||||
|
{:noreply, push_redirect(socket, to: Routes.account_path(socket, :paid, account.id))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(msg, socket)
|
||||||
|
|
||||||
|
def handle_info({AccountManager, :data_updated, %Account{} = account}, socket) do
|
||||||
|
{:noreply, assign(socket, account: account)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec find_item_by_id([Item.t()], Types.id()) :: Item.t() | nil
|
||||||
|
defp find_item_by_id(items, id) do
|
||||||
|
Enum.find(items, &(&1.id == id))
|
||||||
|
end
|
||||||
|
end
|
88
lib/tracker_app_web/live/item_live.html.leex
Normal file
88
lib/tracker_app_web/live/item_live.html.leex
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
<%= if @account == :not_found do %>
|
||||||
|
<%= render(TrackerAppWeb.UtilsView, "account_not_found.html", []) %>
|
||||||
|
<% else %>
|
||||||
|
<div class="item-editor">
|
||||||
|
<%= if @item == :not_found do %>
|
||||||
|
<div class="not-found-error">
|
||||||
|
<p>This item was not found.</p>
|
||||||
|
|
||||||
|
<%= live_redirect(to: Routes.account_path(@socket, :paid, @account.id)) do %>
|
||||||
|
Go back to main view.
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<form phx-submit="save-item">
|
||||||
|
<%= if @invalid do %>
|
||||||
|
<div class="item-editor-error">
|
||||||
|
There was an error with the input data, please check the values.
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<input name="id" type="hidden" value="<%= @item.id %>" />
|
||||||
|
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
class="item-editor-name-input"
|
||||||
|
value="<%= @item.name %>"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="item-editor-amount-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item-editor-amount-dec"
|
||||||
|
onclick="const e = document.getElementById('item-editor-amount'); e.value = Math.max(0, --e.value);"
|
||||||
|
>−</button>
|
||||||
|
<input
|
||||||
|
id="item-editor-amount"
|
||||||
|
name="amount"
|
||||||
|
type="text"
|
||||||
|
class="item-editor-amount-input"
|
||||||
|
value="<%= @item.amount %>"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item-editor-amount-inc"
|
||||||
|
onclick="++document.getElementById('item-editor-amount').value;"
|
||||||
|
>+</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
name="amount_unit"
|
||||||
|
type="text"
|
||||||
|
class="item-editor-amount-unit-input"
|
||||||
|
value="<%= @item.amount_unit %>"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-editor-expiry">
|
||||||
|
<% ed = if not is_nil(@item.expiry_date), do: Date.to_iso8601(@item.expiry_date) , else: "" %>
|
||||||
|
<input
|
||||||
|
name="expiry"
|
||||||
|
type="date"
|
||||||
|
min="<%= Date.utc_today() |> Date.to_iso8601() %>"
|
||||||
|
value="<%= ed %>"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if not @is_new? do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item-editor-remove"
|
||||||
|
phx-click="delete-item"
|
||||||
|
phx-value-id="<%= @item.id %>"
|
||||||
|
>🗑</button>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="item-editor-save"
|
||||||
|
>✓</button>
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
8
lib/tracker_app_web/live/page_live.ex
Normal file
8
lib/tracker_app_web/live/page_live.ex
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
defmodule TrackerAppWeb.PageLive do
|
||||||
|
use TrackerAppWeb, :live_view
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
end
|
14
lib/tracker_app_web/live/page_live.html.leex
Normal file
14
lib/tracker_app_web/live/page_live.html.leex
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<div class="main-page">
|
||||||
|
<h1>TrackerApp</h1>
|
||||||
|
|
||||||
|
<p>Welcome to TrackerApp.</p>
|
||||||
|
|
||||||
|
<div class="main-page-nav">
|
||||||
|
<%= live_redirect(to: Routes.register_path(@socket, :free)) do %>
|
||||||
|
Free mode
|
||||||
|
<% end %>
|
||||||
|
<%= live_redirect(to: Routes.register_path(@socket, :paid)) do %>
|
||||||
|
Paid mode
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
20
lib/tracker_app_web/live/register_live.ex
Normal file
20
lib/tracker_app_web/live/register_live.ex
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
defmodule TrackerAppWeb.RegisterLive do
|
||||||
|
use TrackerAppWeb, :live_view
|
||||||
|
|
||||||
|
alias TrackerApp.ItemDB
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event(evt, params, socket)
|
||||||
|
|
||||||
|
def handle_event("create", _, socket) do
|
||||||
|
with account <- ItemDB.create_account(),
|
||||||
|
:ok <- ItemDB.update_account(account) do
|
||||||
|
{:noreply, push_redirect(socket, to: Routes.account_path(socket, :paid, account.id))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
2
lib/tracker_app_web/live/register_live.html.leex
Normal file
2
lib/tracker_app_web/live/register_live.html.leex
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<%= render(TrackerAppWeb.TermsView, "terms.html", []) %>
|
||||||
|
<button phx-click="create">Create account</button>
|
53
lib/tracker_app_web/router.ex
Normal file
53
lib/tracker_app_web/router.ex
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
defmodule TrackerAppWeb.Router do
|
||||||
|
use TrackerAppWeb, :router
|
||||||
|
|
||||||
|
pipeline :browser do
|
||||||
|
plug :accepts, ["html"]
|
||||||
|
plug :fetch_session
|
||||||
|
plug :fetch_live_flash
|
||||||
|
plug :put_root_layout, {TrackerAppWeb.LayoutView, :root}
|
||||||
|
plug :protect_from_forgery
|
||||||
|
plug :put_secure_browser_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :api do
|
||||||
|
plug :accepts, ["json"]
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", TrackerAppWeb do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
live "/", PageLive, :index
|
||||||
|
live "/register/free", RegisterLive, :free
|
||||||
|
live "/register/paid", RegisterLive, :paid
|
||||||
|
|
||||||
|
live "/free", AccountLive, :free
|
||||||
|
live "/account/:accountId", AccountLive, :paid
|
||||||
|
|
||||||
|
live "/free/item/new", ItemLive, :free
|
||||||
|
live "/free/item/:itemId", ItemLive, :free
|
||||||
|
live "/account/:accountId/item/new", ItemLive, :paid
|
||||||
|
live "/account/:accountId/item/:itemId", ItemLive, :paid
|
||||||
|
end
|
||||||
|
|
||||||
|
# Other scopes may use custom stacks.
|
||||||
|
# scope "/api", TrackerAppWeb do
|
||||||
|
# pipe_through :api
|
||||||
|
# end
|
||||||
|
|
||||||
|
# Enables LiveDashboard only for development
|
||||||
|
#
|
||||||
|
# If you want to use the LiveDashboard in production, you should put
|
||||||
|
# it behind authentication and allow only admins to access it.
|
||||||
|
# If your application does not have an admins-only section yet,
|
||||||
|
# you can use Plug.BasicAuth to set up some basic authentication
|
||||||
|
# as long as you are also using SSL (which you should anyway).
|
||||||
|
if Mix.env() in [:dev, :test] do
|
||||||
|
import Phoenix.LiveDashboard.Router
|
||||||
|
|
||||||
|
scope "/" do
|
||||||
|
pipe_through :browser
|
||||||
|
live_dashboard "/dashboard", metrics: TrackerAppWeb.Telemetry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
48
lib/tracker_app_web/telemetry.ex
Normal file
48
lib/tracker_app_web/telemetry.ex
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
defmodule TrackerAppWeb.Telemetry do
|
||||||
|
use Supervisor
|
||||||
|
import Telemetry.Metrics
|
||||||
|
|
||||||
|
def start_link(arg) do
|
||||||
|
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_arg) do
|
||||||
|
children = [
|
||||||
|
# Telemetry poller will execute the given period measurements
|
||||||
|
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||||
|
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||||
|
# Add reporters as children of your supervision tree.
|
||||||
|
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||||
|
]
|
||||||
|
|
||||||
|
Supervisor.init(children, strategy: :one_for_one)
|
||||||
|
end
|
||||||
|
|
||||||
|
def metrics do
|
||||||
|
[
|
||||||
|
# Phoenix Metrics
|
||||||
|
summary("phoenix.endpoint.stop.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.stop.duration",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
|
||||||
|
# VM Metrics
|
||||||
|
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||||
|
summary("vm.total_run_queue_lengths.total"),
|
||||||
|
summary("vm.total_run_queue_lengths.cpu"),
|
||||||
|
summary("vm.total_run_queue_lengths.io")
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp periodic_measurements do
|
||||||
|
[
|
||||||
|
# A module, function and arguments to be invoked periodically.
|
||||||
|
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||||
|
# {TrackerAppWeb, :count_users, []}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
5
lib/tracker_app_web/templates/layout/app.html.eex
Normal file
5
lib/tracker_app_web/templates/layout/app.html.eex
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<main role="main" class="container">
|
||||||
|
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
|
||||||
|
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
|
||||||
|
<%= @inner_content %>
|
||||||
|
</main>
|
20
lib/tracker_app_web/templates/layout/live.html.leex
Normal file
20
lib/tracker_app_web/templates/layout/live.html.leex
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a><%= gettext("Date") %></a></li>
|
||||||
|
<li><a><%= gettext("Name") %></a></li>
|
||||||
|
<li><a><%= gettext("Settings") %></a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main role="main" class="container">
|
||||||
|
<p class="alert alert-info" role="alert"
|
||||||
|
phx-click="lv:clear-flash"
|
||||||
|
phx-value-key="info"><%= live_flash(@flash, :info) %></p>
|
||||||
|
|
||||||
|
<p class="alert alert-danger" role="alert"
|
||||||
|
phx-click="lv:clear-flash"
|
||||||
|
phx-value-key="error"><%= live_flash(@flash, :error) %></p>
|
||||||
|
|
||||||
|
<%= @inner_content %>
|
||||||
|
</main>
|
15
lib/tracker_app_web/templates/layout/root.html.leex
Normal file
15
lib/tracker_app_web/templates/layout/root.html.leex
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<%= csrf_meta_tag() %>
|
||||||
|
<%= live_title_tag assigns[:page_title] || "TrackerApp", suffix: " · Phoenix Framework" %>
|
||||||
|
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||||
|
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%= @inner_content %>
|
||||||
|
</body>
|
||||||
|
</html>
|
3
lib/tracker_app_web/templates/terms/terms.html.leex
Normal file
3
lib/tracker_app_web/templates/terms/terms.html.leex
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<h2>Legal terms</h2>
|
||||||
|
|
||||||
|
<p>Something something blah blah blah.</p>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="account-not-found-error">
|
||||||
|
<p>Account not found. The account may have been deleted or there is a mistake in the address.</p>
|
||||||
|
</div>
|
47
lib/tracker_app_web/views/error_helpers.ex
Normal file
47
lib/tracker_app_web/views/error_helpers.ex
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
defmodule TrackerAppWeb.ErrorHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
Conveniences for translating and building error messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.HTML
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates tag for inlined form input errors.
|
||||||
|
"""
|
||||||
|
def error_tag(form, field) do
|
||||||
|
Enum.map(Keyword.get_values(form.errors, field), fn error ->
|
||||||
|
content_tag(:span, translate_error(error),
|
||||||
|
class: "invalid-feedback",
|
||||||
|
phx_feedback_for: input_id(form, field)
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Translates an error message using gettext.
|
||||||
|
"""
|
||||||
|
def translate_error({msg, opts}) do
|
||||||
|
# When using gettext, we typically pass the strings we want
|
||||||
|
# to translate as a static argument:
|
||||||
|
#
|
||||||
|
# # Translate "is invalid" in the "errors" domain
|
||||||
|
# dgettext("errors", "is invalid")
|
||||||
|
#
|
||||||
|
# # Translate the number of files with plural rules
|
||||||
|
# dngettext("errors", "1 file", "%{count} files", count)
|
||||||
|
#
|
||||||
|
# Because the error messages we show in our forms and APIs
|
||||||
|
# are defined inside Ecto, we need to translate them dynamically.
|
||||||
|
# This requires us to call the Gettext module passing our gettext
|
||||||
|
# backend as first argument.
|
||||||
|
#
|
||||||
|
# Note we use the "errors" domain, which means translations
|
||||||
|
# should be written to the errors.po file. The :count option is
|
||||||
|
# set by Ecto and indicates we should also apply plural rules.
|
||||||
|
if count = opts[:count] do
|
||||||
|
Gettext.dngettext(TrackerAppWeb.Gettext, "errors", msg, msg, count, opts)
|
||||||
|
else
|
||||||
|
Gettext.dgettext(TrackerAppWeb.Gettext, "errors", msg, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
16
lib/tracker_app_web/views/error_view.ex
Normal file
16
lib/tracker_app_web/views/error_view.ex
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
defmodule TrackerAppWeb.ErrorView do
|
||||||
|
use TrackerAppWeb, :view
|
||||||
|
|
||||||
|
# If you want to customize a particular status code
|
||||||
|
# for a certain format, you may uncomment below.
|
||||||
|
# def render("500.html", _assigns) do
|
||||||
|
# "Internal Server Error"
|
||||||
|
# end
|
||||||
|
|
||||||
|
# By default, Phoenix returns the status message from
|
||||||
|
# the template name. For example, "404.html" becomes
|
||||||
|
# "Not Found".
|
||||||
|
def template_not_found(template, _assigns) do
|
||||||
|
Phoenix.Controller.status_message_from_template(template)
|
||||||
|
end
|
||||||
|
end
|
3
lib/tracker_app_web/views/layout_view.ex
Normal file
3
lib/tracker_app_web/views/layout_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule TrackerAppWeb.LayoutView do
|
||||||
|
use TrackerAppWeb, :view
|
||||||
|
end
|
3
lib/tracker_app_web/views/terms_view.ex
Normal file
3
lib/tracker_app_web/views/terms_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule TrackerAppWeb.TermsView do
|
||||||
|
use TrackerAppWeb, :view
|
||||||
|
end
|
3
lib/tracker_app_web/views/utils_view.ex
Normal file
3
lib/tracker_app_web/views/utils_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule TrackerAppWeb.UtilsView do
|
||||||
|
use TrackerAppWeb, :view
|
||||||
|
end
|
62
mix.exs
Normal file
62
mix.exs
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
defmodule TrackerApp.MixProject do
|
||||||
|
use Mix.Project
|
||||||
|
|
||||||
|
def project do
|
||||||
|
[
|
||||||
|
app: :tracker_app,
|
||||||
|
version: "0.1.0",
|
||||||
|
elixir: "~> 1.11",
|
||||||
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
|
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||||
|
start_permanent: Mix.env() == :prod,
|
||||||
|
aliases: aliases(),
|
||||||
|
deps: deps()
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configuration for the OTP application.
|
||||||
|
#
|
||||||
|
# Type `mix help compile.app` for more information.
|
||||||
|
def application do
|
||||||
|
[
|
||||||
|
mod: {TrackerApp.Application, []},
|
||||||
|
extra_applications: [:logger, :runtime_tools, :os_mon]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Specifies which paths to compile per environment.
|
||||||
|
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||||
|
defp elixirc_paths(_), do: ["lib"]
|
||||||
|
|
||||||
|
# Specifies your project dependencies.
|
||||||
|
#
|
||||||
|
# Type `mix help deps` for examples and options.
|
||||||
|
defp deps do
|
||||||
|
[
|
||||||
|
{:phoenix, "~> 1.5.5"},
|
||||||
|
{:phoenix_live_view, "~> 0.15.3"},
|
||||||
|
{:floki, ">= 0.27.0", only: :test},
|
||||||
|
{:phoenix_html, "~> 2.11"},
|
||||||
|
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||||
|
{:phoenix_live_dashboard, "~> 0.2"},
|
||||||
|
{:telemetry_metrics, "~> 0.6"},
|
||||||
|
{:telemetry_poller, "~> 0.4"},
|
||||||
|
{:gettext, "~> 0.11"},
|
||||||
|
{:jason, "~> 1.0"},
|
||||||
|
{:plug_cowboy, "~> 2.0"},
|
||||||
|
{:elixir_uuid, "~> 1.2"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Aliases are shortcuts or tasks specific to the current project.
|
||||||
|
# For example, to install project dependencies and perform other setup tasks, run:
|
||||||
|
#
|
||||||
|
# $ mix setup
|
||||||
|
#
|
||||||
|
# See the documentation for `Mix` for more info on aliases.
|
||||||
|
defp aliases do
|
||||||
|
[
|
||||||
|
setup: ["deps.get", "cmd npm install --prefix assets"]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
25
mix.lock
Normal file
25
mix.lock
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
%{
|
||||||
|
"cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"},
|
||||||
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
|
||||||
|
"cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"},
|
||||||
|
"elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"},
|
||||||
|
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||||
|
"floki": {:hex, :floki, "0.29.0", "b1710d8c93a2f860dc2d7adc390dd808dc2fb8f78ee562304457b75f4c640881", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "008585ce64b9f74c07d32958ec9866f4b8a124bf4da1e2941b28e41384edaaad"},
|
||||||
|
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
|
||||||
|
"html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"},
|
||||||
|
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
|
||||||
|
"mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
|
||||||
|
"phoenix": {:hex, :phoenix, "1.5.7", "2923bb3af924f184459fe4fa4b100bd25fa6468e69b2803dfae82698269aa5e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "774cd64417c5a3788414fdbb2be2eb9bcd0c048d9e6ad11a0c1fd67b7c0d0978"},
|
||||||
|
"phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"},
|
||||||
|
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.4.0", "87990e68b60213d7487e65814046f9a2bed4a67886c943270125913499b3e5c3", [:mix], [{:ecto_psql_extras, "~> 0.4.1 or ~> 0.5", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "8d52149e58188e9e4497cc0d8900ab94d9b66f96998ec38c47c7a4f8f4f50e57"},
|
||||||
|
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.0", "f35f61c3f959c9a01b36defaa1f0624edd55b87e236b606664a556d6f72fd2e7", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "02c1007ae393f2b76ec61c1a869b1e617179877984678babde131d716f95b582"},
|
||||||
|
"phoenix_live_view": {:hex, :phoenix_live_view, "0.15.3", "70c7917e5c421e32d1a1c8ddf8123378bb741748cd8091eb9d557fb4be92a94f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cabcfb6738419a08600009219a5f0d861de97507fc1232121e1d5221aba849bd"},
|
||||||
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
|
||||||
|
"plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"},
|
||||||
|
"plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"},
|
||||||
|
"plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"},
|
||||||
|
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
|
||||||
|
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
|
||||||
|
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.0", "da9d49ee7e6bb1c259d36ce6539cd45ae14d81247a2b0c90edf55e2b50507f7b", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5cfe67ad464b243835512aa44321cee91faed6ea868d7fb761d7016e02915c3d"},
|
||||||
|
"telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
|
||||||
|
}
|
11
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
11
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
## `msgid`s in this file come from POT (.pot) files.
|
||||||
|
##
|
||||||
|
## Do not add, change, or remove `msgid`s manually here as
|
||||||
|
## they're tied to the ones in the corresponding POT file
|
||||||
|
## (with the same domain).
|
||||||
|
##
|
||||||
|
## Use `mix gettext.extract --merge` or `mix gettext.merge`
|
||||||
|
## to merge POT files into PO files.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Language: en\n"
|
10
priv/gettext/errors.pot
Normal file
10
priv/gettext/errors.pot
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
## This is a PO Template file.
|
||||||
|
##
|
||||||
|
## `msgid`s here are often extracted from source code.
|
||||||
|
## Add new translations manually only if they're dynamic
|
||||||
|
## translations that can't be statically extracted.
|
||||||
|
##
|
||||||
|
## Run `mix gettext.extract` to bring this file up to
|
||||||
|
## date. Leave `msgstr`s empty as changing them here has no
|
||||||
|
## effect: edit them in PO (`.po`) files instead.
|
||||||
|
|
257
priv/static/css/app.css
Normal file
257
priv/static/css/app.css
Normal file
File diff suppressed because one or more lines are too long
BIN
priv/static/favicon.ico
Normal file
BIN
priv/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
priv/static/images/phoenix.png
Normal file
BIN
priv/static/images/phoenix.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
169
priv/static/js/app.js
Normal file
169
priv/static/js/app.js
Normal file
File diff suppressed because one or more lines are too long
5
priv/static/robots.txt
Normal file
5
priv/static/robots.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||||
|
#
|
||||||
|
# To ban all spiders from the entire site uncomment the next two lines:
|
||||||
|
# User-agent: *
|
||||||
|
# Disallow: /
|
11
test/fridge_tracker_web/live/page_live_test.exs
Normal file
11
test/fridge_tracker_web/live/page_live_test.exs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
defmodule TrackerAppWeb.PageLiveTest do
|
||||||
|
use TrackerAppWeb.ConnCase
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
test "disconnected and connected render", %{conn: conn} do
|
||||||
|
{:ok, page_live, disconnected_html} = live(conn, "/")
|
||||||
|
assert disconnected_html =~ "Welcome to Phoenix!"
|
||||||
|
assert render(page_live) =~ "Welcome to Phoenix!"
|
||||||
|
end
|
||||||
|
end
|
14
test/fridge_tracker_web/views/error_view_test.exs
Normal file
14
test/fridge_tracker_web/views/error_view_test.exs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
defmodule TrackerAppWeb.ErrorViewTest do
|
||||||
|
use TrackerAppWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
# Bring render/3 and render_to_string/3 for testing custom views
|
||||||
|
import Phoenix.View
|
||||||
|
|
||||||
|
test "renders 404.html" do
|
||||||
|
assert render_to_string(TrackerAppWeb.ErrorView, "404.html", []) == "Not Found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders 500.html" do
|
||||||
|
assert render_to_string(TrackerAppWeb.ErrorView, "500.html", []) == "Internal Server Error"
|
||||||
|
end
|
||||||
|
end
|
8
test/fridge_tracker_web/views/layout_view_test.exs
Normal file
8
test/fridge_tracker_web/views/layout_view_test.exs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
defmodule TrackerAppWeb.LayoutViewTest do
|
||||||
|
use TrackerAppWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
# When testing helpers, you may want to import Phoenix.HTML and
|
||||||
|
# use functions such as safe_to_string() to convert the helper
|
||||||
|
# result into an HTML string.
|
||||||
|
# import Phoenix.HTML
|
||||||
|
end
|
34
test/support/channel_case.ex
Normal file
34
test/support/channel_case.ex
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
defmodule TrackerAppWeb.ChannelCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the test case to be used by
|
||||||
|
channel tests.
|
||||||
|
|
||||||
|
Such tests rely on `Phoenix.ChannelTest` and also
|
||||||
|
import other functionality to make it easier
|
||||||
|
to build common data structures and query the data layer.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use TrackerAppWeb.ChannelCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
# Import conveniences for testing with channels
|
||||||
|
import Phoenix.ChannelTest
|
||||||
|
import TrackerAppWeb.ChannelCase
|
||||||
|
|
||||||
|
# The default endpoint for testing
|
||||||
|
@endpoint TrackerAppWeb.Endpoint
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup _tags do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
37
test/support/conn_case.ex
Normal file
37
test/support/conn_case.ex
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
defmodule TrackerAppWeb.ConnCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the test case to be used by
|
||||||
|
tests that require setting up a connection.
|
||||||
|
|
||||||
|
Such tests rely on `Phoenix.ConnTest` and also
|
||||||
|
import other functionality to make it easier
|
||||||
|
to build common data structures and query the data layer.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use TrackerAppWeb.ConnCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
# Import conveniences for testing with connections
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.ConnTest
|
||||||
|
import TrackerAppWeb.ConnCase
|
||||||
|
|
||||||
|
alias TrackerAppWeb.Router.Helpers, as: Routes
|
||||||
|
|
||||||
|
# The default endpoint for testing
|
||||||
|
@endpoint TrackerAppWeb.Endpoint
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup _tags do
|
||||||
|
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||||
|
end
|
||||||
|
end
|
1
test/test_helper.exs
Normal file
1
test/test_helper.exs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
ExUnit.start()
|
Loading…
Reference in a new issue