Skip to content

Commit 2d8d075

Browse files
authored
Add practice exercise ledger (#1367)
* Add practice exercise `ledger` * Format config file * Fix bad solution * type for locale
1 parent 86b156f commit 2d8d075

File tree

11 files changed

+537
-0
lines changed

11 files changed

+537
-0
lines changed

config.json

+19
Original file line numberDiff line numberDiff line change
@@ -2314,6 +2314,25 @@
23142314
"practices": [],
23152315
"difficulty": 6
23162316
},
2317+
{
2318+
"slug": "ledger",
2319+
"name": "Ledger",
2320+
"uuid": "a3134be3-ac2f-4612-9cd2-4f2a6a4de48f",
2321+
"prerequisites": [
2322+
"if",
2323+
"lists",
2324+
"atoms",
2325+
"strings",
2326+
"maps",
2327+
"dates-and-time",
2328+
"multiple-clause-functions",
2329+
"pattern-matching"
2330+
],
2331+
"practices": [
2332+
"multiple-clause-functions"
2333+
],
2334+
"difficulty": 6
2335+
},
23172336
{
23182337
"slug": "list-ops",
23192338
"name": "List Ops",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Instructions
2+
3+
Refactor a ledger printer.
4+
5+
The ledger exercise is a refactoring exercise.
6+
There is code that prints a nicely formatted ledger, given a locale (American or Dutch) and a currency (US dollar or euro).
7+
The code however is rather badly written, though (somewhat surprisingly) it consistently passes the test suite.
8+
9+
Rewrite this code.
10+
Remember that in refactoring the trick is to make small steps that keep the tests passing.
11+
That way you can always quickly go back to a working version.
12+
Version control tools like git can help here as well.
13+
14+
Please keep a log of what changes you've made and make a comment on the exercise containing that log, this will help reviewers.
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Used by "mix format"
2+
[
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]

exercises/practice/ledger/.gitignore

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# Ignore .fetch files in case you like to edit your project deps locally.
14+
/.fetch
15+
16+
# If the VM crashes, it generates a dump, let's ignore it too.
17+
erl_crash.dump
18+
19+
# Also ignore archive artifacts (built via "mix archive.build").
20+
*.ez
21+
22+
# Ignore package tarball (built via "mix hex.build").
23+
ledger-*.tar
24+
25+
# Temporary files, for example, from tests.
26+
/tmp/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"authors": [
3+
"jiegillet"
4+
],
5+
"files": {
6+
"solution": [
7+
"lib/ledger.ex"
8+
],
9+
"test": [
10+
"test/ledger_test.exs"
11+
],
12+
"example": [
13+
".meta/example.ex"
14+
]
15+
},
16+
"blurb": "Refactor a ledger printer."
17+
}
+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
defmodule Ledger do
2+
@doc """
3+
Format the given entries given a currency and locale
4+
"""
5+
@type currency :: :usd | :eur
6+
@type locale :: :en_US | :nl_NL
7+
@type entry :: %{amount_in_cents: integer(), date: Date.t(), description: String.t()}
8+
9+
@spec format_entries(currency(), locale(), list(entry())) :: String.t()
10+
def format_entries(currency, locale, entries) do
11+
header = header(locale)
12+
13+
entries =
14+
entries
15+
|> Enum.sort(&compare_entries/2)
16+
|> Enum.map(fn entry -> format_entry(currency, locale, entry) end)
17+
18+
Enum.join([header | entries], "\n") <> "\n"
19+
end
20+
21+
defp header(:en_US), do: "Date | Description | Change "
22+
defp header(:nl_NL), do: "Datum | Omschrijving | Verandering "
23+
24+
defp compare_entries(a, b) do
25+
case Date.compare(a.date, b.date) do
26+
:lt ->
27+
true
28+
29+
:gt ->
30+
false
31+
32+
:eq ->
33+
cond do
34+
a.description < b.description -> true
35+
a.description > b.description -> false
36+
true -> a.amount_in_cents <= b.amount_in_cents
37+
end
38+
end
39+
end
40+
41+
@description_width 25
42+
@amount_width 13
43+
defp format_entry(currency, locale, %{
44+
amount_in_cents: amount,
45+
date: date,
46+
description: description
47+
}) do
48+
date = format_date(date, locale)
49+
50+
amount =
51+
amount
52+
|> format_amount(currency, locale)
53+
|> String.pad_leading(@amount_width, " ")
54+
55+
description =
56+
if String.length(description) > @description_width do
57+
String.slice(description, 0, @description_width - 3) <> "..."
58+
else
59+
String.pad_trailing(description, @description_width, " ")
60+
end
61+
62+
Enum.join([date, description, amount], " | ")
63+
end
64+
65+
defp format_date(date, :en_US) do
66+
year = date.year
67+
month = date.month |> to_string() |> String.pad_leading(2, "0")
68+
day = date.day |> to_string() |> String.pad_leading(2, "0")
69+
Enum.join([month, day, year], "/")
70+
end
71+
72+
defp format_date(date, :nl_NL) do
73+
year = date.year
74+
month = date.month |> to_string() |> String.pad_leading(2, "0")
75+
day = date.day |> to_string() |> String.pad_leading(2, "0")
76+
Enum.join([day, month, year], "-")
77+
end
78+
79+
defp format_amount(amount, currency, :en_US) do
80+
currency = format_currency(currency)
81+
number = format_number(abs(amount), ".", ",")
82+
83+
if amount >= 0 do
84+
" #{currency}#{number} "
85+
else
86+
"(#{currency}#{number})"
87+
end
88+
end
89+
90+
defp format_amount(amount, currency, :nl_NL) do
91+
currency = format_currency(currency)
92+
number = format_number(abs(amount), ",", ".")
93+
94+
if amount >= 0 do
95+
"#{currency} #{number} "
96+
else
97+
"#{currency} -#{number} "
98+
end
99+
end
100+
101+
defp format_currency(:usd), do: "$"
102+
defp format_currency(:eur), do: "€"
103+
104+
defp format_number(number, decimal_separator, thousand_separator) do
105+
decimal = number |> rem(100) |> to_string() |> String.pad_leading(2, "0")
106+
whole = number |> div(100) |> to_string() |> chunk() |> Enum.join(thousand_separator)
107+
whole <> decimal_separator <> decimal
108+
end
109+
110+
defp chunk(number) do
111+
case String.length(number) do
112+
0 ->
113+
[]
114+
115+
n when n < 3 ->
116+
[number]
117+
118+
n when rem(n, 3) == 0 ->
119+
{chunk, rest} = String.split_at(number, 3)
120+
[chunk | chunk(rest)]
121+
122+
n ->
123+
{chunk, rest} = String.split_at(number, rem(n, 3))
124+
[chunk | chunk(rest)]
125+
end
126+
end
127+
end
+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# This is an auto-generated file.
2+
#
3+
# Regenerating this file via `configlet sync` will:
4+
# - Recreate every `description` key/value pair
5+
# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications
6+
# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion)
7+
# - Preserve any other key/value pair
8+
#
9+
# As user-added comments (using the # character) will be removed when this file
10+
# is regenerated, comments can be added via a `comment` key.
11+
12+
[d131ecae-a30e-436c-b8f3-858039a27234]
13+
description = "empty ledger"
14+
15+
[ce4618d2-9379-4eca-b207-9df1c4ec8aaa]
16+
description = "one entry"
17+
18+
[8d02e9cb-e6ee-4b77-9ce4-e5aec8eb5ccb]
19+
description = "credit and debit"
20+
21+
[502c4106-0371-4e7c-a7d8-9ce33f16ccb1]
22+
description = "multiple entries on same date ordered by description"
23+
24+
[29dd3659-6c2d-4380-94a8-6d96086e28e1]
25+
description = "final order tie breaker is change"
26+
27+
[9b9712a6-f779-4f5c-a759-af65615fcbb9]
28+
description = "overlong description is truncated"
29+
30+
[67318aad-af53-4f3d-aa19-1293b4d4c924]
31+
description = "euros"
32+
33+
[bdc499b6-51f5-4117-95f2-43cb6737208e]
34+
description = "Dutch locale"
35+
36+
[86591cd4-1379-4208-ae54-0ee2652b4670]
37+
description = "Dutch locale and euros"
38+
39+
[876bcec8-d7d7-4ba4-82bd-b836ac87c5d2]
40+
description = "Dutch negative number with 3 digits before decimal point"
41+
42+
[29670d1c-56be-492a-9c5e-427e4b766309]
43+
description = "American negative number with 3 digits before decimal point"
+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
defmodule Ledger do
2+
@doc """
3+
Format the given entries given a currency and locale
4+
"""
5+
@type currency :: :usd | :eur
6+
@type locale :: :en_US | :nl_NL
7+
@type entry :: %{amount_in_cents: integer(), date: Date.t(), description: String.t()}
8+
9+
@spec format_entries(currency(), locale(), list(entry())) :: String.t()
10+
def format_entries(currency, locale, entries) do
11+
header =
12+
if locale == :en_US do
13+
"Date | Description | Change \n"
14+
else
15+
"Datum | Omschrijving | Verandering \n"
16+
end
17+
18+
if entries == [] do
19+
header
20+
else
21+
entries =
22+
Enum.sort(entries, fn a, b ->
23+
cond do
24+
a.date.day < b.date.day -> true
25+
a.date.day > b.date.day -> false
26+
a.description < b.description -> true
27+
a.description > b.description -> false
28+
true -> a.amount_in_cents <= b.amount_in_cents
29+
end
30+
end)
31+
|> Enum.map(fn entry -> format_entry(currency, locale, entry) end)
32+
|> Enum.join("\n")
33+
34+
header <> entries <> "\n"
35+
end
36+
end
37+
38+
defp format_entry(currency, locale, entry) do
39+
year = entry.date.year |> to_string()
40+
month = entry.date.month |> to_string() |> String.pad_leading(2, "0")
41+
day = entry.date.day |> to_string() |> String.pad_leading(2, "0")
42+
43+
date =
44+
if locale == :en_US do
45+
month <> "/" <> day <> "/" <> year <> " "
46+
else
47+
day <> "-" <> month <> "-" <> year <> " "
48+
end
49+
50+
number =
51+
if locale == :en_US do
52+
decimal =
53+
entry.amount_in_cents |> abs |> rem(100) |> to_string() |> String.pad_leading(2, "0")
54+
55+
whole =
56+
if abs(div(entry.amount_in_cents, 100)) < 1000 do
57+
abs(div(entry.amount_in_cents, 100)) |> to_string()
58+
else
59+
to_string(div(abs(div(entry.amount_in_cents, 100)), 1000)) <>
60+
"," <> to_string(rem(abs(div(entry.amount_in_cents, 100)), 1000))
61+
end
62+
63+
whole <> "." <> decimal
64+
else
65+
decimal =
66+
entry.amount_in_cents |> abs |> rem(100) |> to_string() |> String.pad_leading(2, "0")
67+
68+
whole =
69+
if abs(div(entry.amount_in_cents, 100)) < 1000 do
70+
abs(div(entry.amount_in_cents, 100)) |> to_string()
71+
else
72+
to_string(div(abs(div(entry.amount_in_cents, 100)), 1000)) <>
73+
"." <> to_string(rem(abs(div(entry.amount_in_cents, 100)), 1000))
74+
end
75+
76+
whole <> "," <> decimal
77+
end
78+
79+
amount =
80+
if entry.amount_in_cents >= 0 do
81+
if locale == :en_US do
82+
" #{if(currency == :eur, do: "€", else: "$")}#{number} "
83+
else
84+
" #{if(currency == :eur, do: "€", else: "$")} #{number} "
85+
end
86+
else
87+
if locale == :en_US do
88+
" (#{if(currency == :eur, do: "€", else: "$")}#{number})"
89+
else
90+
" #{if(currency == :eur, do: "€", else: "$")} -#{number} "
91+
end
92+
end
93+
|> String.pad_leading(14, " ")
94+
95+
description =
96+
if entry.description |> String.length() > 26 do
97+
" " <> String.slice(entry.description, 0, 22) <> "..."
98+
else
99+
" " <> String.pad_trailing(entry.description, 25, " ")
100+
end
101+
102+
date <> "|" <> description <> " |" <> amount
103+
end
104+
end

exercises/practice/ledger/mix.exs

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
defmodule Ledger.MixProject do
2+
use Mix.Project
3+
4+
def project do
5+
[
6+
app: :ledger,
7+
version: "0.1.0",
8+
# elixir: "~> 1.10",
9+
start_permanent: Mix.env() == :prod,
10+
deps: deps()
11+
]
12+
end
13+
14+
# Run "mix help compile.app" to learn about applications.
15+
def application do
16+
[
17+
extra_applications: [:logger]
18+
]
19+
end
20+
21+
# Run "mix help deps" to learn about dependencies.
22+
defp deps do
23+
[
24+
# {:dep_from_hexpm, "~> 0.3.0"},
25+
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
26+
]
27+
end
28+
end

0 commit comments

Comments
 (0)