Skip to content

Commit a567501

Browse files
committed
Add count, min and max, and rule off each block in the margin
1 parent 9af38ad commit a567501

8 files changed

Lines changed: 200 additions & 22 deletions

File tree

assets/css/app.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,22 @@
152152
height: var(--sheet-line-height);
153153
}
154154

155+
/* The line an accountant draws under a column of figures before writing the
156+
total beneath it. It marks where the entries stop and what they add up to
157+
starts, which the margin otherwise leaves to the reader to work out.
158+
159+
Drawn as an inset shadow rather than a border on purpose. Every answer row
160+
is exactly one line high and every column derives from that one value, so
161+
a row that grew by the width of a border — or whose text shifted down by
162+
it — would put this answer out of step with the line that produced it. A
163+
shadow occupies no space at all.
164+
165+
The colour is mixed rather than set with `opacity`, which would fade the
166+
answer along with its rule. */
167+
.sheet-answers > .sheet-rule {
168+
box-shadow: inset 0 1px 0 0 color-mix(in oklab, currentColor 45%, transparent);
169+
}
170+
155171
/* The editor is a transparent textarea laid over a coloured copy of its own
156172
text. Both boxes must agree byte for byte on metrics — font, size, line
157173
height, padding, wrapping — or the colours drift out from under the cursor.

lib/localize_pad/lexicon.ex

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ defmodule LocalizePad.Lexicon do
5656
| :of_reversed
5757
| :per_reversed
5858

59-
@type aggregate :: :sum | :average | :median
59+
@type aggregate :: :sum | :average | :median | :count | :minimum | :maximum
6060

6161
@type deictic ::
6262
:now | :today | :tomorrow | :yesterday | :day_after_tomorrow | :day_before_yesterday
@@ -246,31 +246,51 @@ defmodule LocalizePad.Lexicon do
246246
# Spanish is the reminder that these are near neighbours in every language:
247247
# `media` is the average and `mediana` is the median, one letter apart and
248248
# two different answers.
249+
#
250+
# `min` is also the abbreviation for a minute, so a line reading nothing but
251+
# `min` answers the smallest entry above it rather than one minute. Only a
252+
# line that is *entirely* the word is read this way — `5 min` is still five
253+
# minutes — and nobody writes a bare `min` meaning a duration.
249254
@aggregates %{
250255
en: %{
251256
sum: ["sum", "subtotal", "total"],
252257
average: ["average", "avg", "mean"],
253-
median: ["median"]
258+
median: ["median"],
259+
count: ["count"],
260+
minimum: ["min", "minimum", "lowest", "smallest"],
261+
maximum: ["max", "maximum", "highest", "largest"]
254262
},
255263
de: %{
256264
sum: ["summe", "zwischensumme", "gesamt", "gesamtsumme", "total"],
257265
average: ["durchschnitt", "mittelwert", "mittel"],
258-
median: ["median", "zentralwert"]
266+
median: ["median", "zentralwert"],
267+
count: ["anzahl"],
268+
minimum: ["min", "minimum", "kleinster wert"],
269+
maximum: ["max", "maximum", "größter wert", "grösster wert"]
259270
},
260271
fr: %{
261272
sum: ["somme", "sous-total", "total"],
262273
average: ["moyenne"],
263-
median: ["médiane", "mediane"]
274+
median: ["médiane", "mediane"],
275+
count: ["compte", "nombre"],
276+
minimum: ["min", "minimum"],
277+
maximum: ["max", "maximum"]
264278
},
265279
es: %{
266280
sum: ["suma", "subtotal", "total"],
267281
average: ["promedio", "media"],
268-
median: ["mediana"]
282+
median: ["mediana"],
283+
count: ["cuenta", "recuento"],
284+
minimum: ["min", "mínimo", "minimo"],
285+
maximum: ["max", "máximo", "maximo"]
269286
},
270287
ja: %{
271288
sum: ["合計", "小計", "計"],
272289
average: ["平均", "平均値"],
273-
median: ["中央値", "メジアン"]
290+
median: ["中央値", "メジアン"],
291+
count: ["件数", "個数", "カウント"],
292+
minimum: ["最小", "最小値"],
293+
maximum: ["最大", "最大値"]
274294
}
275295
}
276296

@@ -285,7 +305,8 @@ defmodule LocalizePad.Lexicon do
285305
286306
### Returns
287307
288-
* `{:ok, function}` where function is `:sum`, `:average` or `:median`.
308+
* `{:ok, function}` where function is `:sum`, `:average`, `:median`,
309+
`:count`, `:minimum` or `:maximum`.
289310
290311
* `:error` when the word names no function in this locale, which is what
291312
makes `somme` a calculation on a French sheet and prose on an English one.

lib/localize_pad/line.ex

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ defmodule LocalizePad.Line do
1212
1313
* `:comment` — begins with `//`.
1414
15-
* `:aggregate` — `sum`, `average` or `median`, alone on a line. Summarises
16-
every entry above it, back to the previous aggregate or heading.
15+
* `:aggregate` — `sum`, `average`, `median`, `count`, `min` or `max`, alone
16+
on a line. Summarises every entry above it, back to the previous aggregate
17+
or heading.
1718
1819
* `:declaration` — `name = expression`. Binds a name for the lines below.
1920
@@ -25,7 +26,7 @@ defmodule LocalizePad.Line do
2526
file records it out of band. A sheet here is plain text first — it has to
2627
survive being copied into a chat window and pasted back — so the aggregate
2728
is a word you can type. `sum` on its own line is the marker, and `average`,
28-
`mean` and `median` are the others.
29+
`mean`, `median`, `count`, `min` and `max` are the others.
2930
3031
Which words those are is the reader's business, not English's:
3132
`Durchschnitt` on a German sheet is the same line as `average` on an English

lib/localize_pad/sheet.ex

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,22 @@ defmodule LocalizePad.Sheet do
2626
declaration names a value for later use, and counting `cost = 550` as an
2727
entry as well as every line that uses `cost` would double it.
2828
29-
A line reading `sum`, `average` or `median` does the same over the entries
30-
above it, back to the previous such line or heading. Several of them may sit
31-
one under the other, and each reports on the block the run as a whole
32-
follows rather than on the line above it — `sum` then `average` answers for
33-
the same entries twice, which is the point of writing both.
34-
35-
The three answer together or not at all. An average is a sum shared out and
36-
a median is an ordering, so each needs what the sum needs: values of one
37-
kind, converted into one unit or one currency. What the sum will not add,
38-
the others will not average or order either.
29+
A line reading `sum`, `average`, `median`, `count`, `min` or `max` does the
30+
same over the entries above it, back to the previous such line or heading.
31+
Several of them may sit one under the other, and each reports on the block
32+
the run as a whole follows rather than on the line above it — `sum` then
33+
`average` answers for the same entries twice, which is the point of writing
34+
both.
35+
36+
Five of the six answer together or not at all. An average is a sum shared
37+
out, and a median, a minimum and a maximum are orderings, so each needs what
38+
the sum needs: values of one kind, converted into one unit or one currency.
39+
What the sum will not add, the others will not average or order either.
40+
41+
`count` is the exception, because counting asks nothing of the values but
42+
that they be there. A page of metres and kilograms has no total and no
43+
middle, and still plainly has two entries on it. It counts what the others
44+
use, so the sum shared out by the count is always the average.
3945
4046
Three sheets have a total, and they are the three where one exists to be had:
4147
@@ -614,6 +620,29 @@ defmodule LocalizePad.Sheet do
614620
end
615621
end
616622

623+
# The smallest and largest come out of the same ordering, and out of it
624+
# already converted — the smallest of a metre and 50 cm is half a metre,
625+
# written in the unit the sheet chose, exactly as the sum and the median
626+
# are.
627+
defp combine(values, :minimum, locale, rates), do: end_of(values, locale, rates, &List.first/1)
628+
defp combine(values, :maximum, locale, rates), do: end_of(values, locale, rates, &List.last/1)
629+
630+
# Counting asks nothing of the values but that they be entries, so it
631+
# answers where the others refuse: a page of metres and kilograms has no
632+
# total and no middle, and still plainly has two things on it.
633+
#
634+
# What it counts is what the others use, which is what keeps the sum shared
635+
# out by the count equal to the average. A date is not an entry here for the
636+
# same reason it is not part of the total.
637+
defp combine(values, :count, _locale, _rates), do: length(values)
638+
639+
defp end_of(values, locale, rates, pick) do
640+
case ordered(values, locale, rates) do
641+
{:ok, sorted} -> pick.(sorted)
642+
:error -> nil
643+
end
644+
end
645+
617646
defp middle(values, locale, rates) do
618647
count = length(values)
619648
above = div(count, 2)

lib/localize_pad_web/live/sheet_live.ex

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ defmodule LocalizePadWeb.SheetLive do
145145

146146
use LocalizePadWeb, :live_view
147147

148-
alias LocalizePad.{Examples, Highlight, Locales, Refusal, Share, Sheet, Timeline, Value}
148+
alias LocalizePad.{Examples, Highlight, Line, Locales, Refusal, Share, Sheet, Timeline, Value}
149149

150150
@sample """
151151
# A first sheet
@@ -401,10 +401,35 @@ defmodule LocalizePadWeb.SheetLive do
401401
)
402402
)
403403
|> assign(:share_payload, Share.encode(socket.assigns.source, socket.assigns.locale))
404+
|> assign(:ruled, ruled(sheet))
404405
|> assign(:total, format_total(sheet, socket.assigns.locale))
405406
|> assign(:detail, detail_for(sheet, socket.assigns[:selected], socket.assigns.locale))
406407
end
407408

409+
# Which answers get the rule drawn over them: the accountant's line under a
410+
# column of figures, before the total is written beneath it.
411+
#
412+
# One per run, not one per aggregate, so `sum` / `average` / `median` read as
413+
# one bracketed group rather than three stripes. Blank lines sit inside a run
414+
# here exactly as they do in the block the run reports on — the two rules
415+
# have to agree or the drawing would contradict the arithmetic.
416+
#
417+
# An aggregate with no answer is passed over rather than ruled off. A rule
418+
# over an empty row delineates nothing, and where the sum refuses but the
419+
# count does not, the line belongs over the answer that is actually there.
420+
defp ruled(%Sheet{lines: lines}) do
421+
{ruled, _drawn} =
422+
Enum.reduce(lines, {MapSet.new(), false}, fn
423+
%Line{kind: :aggregate, formatted: nil}, accumulated -> accumulated
424+
%Line{kind: :aggregate} = line, {ruled, false} -> {MapSet.put(ruled, line.index), true}
425+
%Line{kind: :aggregate}, accumulated -> accumulated
426+
%Line{kind: :blank}, accumulated -> accumulated
427+
_line, {ruled, _drawn} -> {ruled, false}
428+
end)
429+
430+
ruled
431+
end
432+
408433
defp detail_for(_sheet, nil, _locale), do: nil
409434

410435
defp detail_for(sheet, index, locale) do
@@ -665,7 +690,8 @@ defmodule LocalizePadWeb.SheetLive do
665690
line.formatted && "cursor-pointer hover:opacity-70",
666691
line.index == @selected && "font-semibold",
667692
line.error && "opacity-40",
668-
line.kind in [:heading, :comment] && "opacity-30"
693+
line.kind in [:heading, :comment] && "opacity-30",
694+
MapSet.member?(@ruled, line.index) && "sheet-rule"
669695
]}
670696
title={
671697
line.error &&

test/localize_pad/languages_test.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ defmodule LocalizePad.LanguagesTest do
7878
assert answer("10\n20\n30\n中央値", "ja") == "20"
7979
end
8080

81+
test "and it can count and find its ends in its own language too" do
82+
assert answer("10\n20\n30\nanzahl", "de") == "3"
83+
assert answer("10\n20\n30\nnombre", "fr") == "3"
84+
assert answer("10\n20\n30\ncuenta", "es") == "3"
85+
assert answer("10\n20\n30\n件数", "ja") == "3"
86+
87+
assert answer("10\n20\n30\nkleinster wert", "de") == "10"
88+
assert answer("10\n20\n30\nmáximo", "es") == "30"
89+
assert answer("10\n20\n30\n最小", "ja") == "10"
90+
end
91+
8192
test "and the average and the median are one letter apart in Spanish" do
8293
# `media` is the average and `mediana` is the median. Two words, two
8394
# answers, and nothing but the last three letters to tell them apart.

test/localize_pad/sheet_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,28 @@ defmodule LocalizePad.SheetTest do
153153
assert answers("1\n2\n3\n4\nmedian") == ["1", "2", "3", "4", "2.5"]
154154
end
155155

156+
test "a minimum and a maximum are the ends of that same order" do
157+
assert answers("30\n10\n20\nmin\nmax") == ["30", "10", "20", "10", "30"]
158+
end
159+
160+
test "a count counts the entries" do
161+
assert answers("19\n22\n1\ncount") == ["19", "22", "1", "3"]
162+
end
163+
164+
test "and counts what the average divides by, so the two agree" do
165+
# The date is no more part of the count than it is part of the sum.
166+
# Counted, `sum / count` would answer 13.67 where the average says 20.5.
167+
assert answers("19\nApril 12, 2026\n22\nsum\ncount\naverage") ==
168+
["19", "April 12, 2026", "22", "41", "2", "20.5"]
169+
end
170+
171+
test "a count answers where the others refuse, because counting asks nothing" do
172+
# There is no total of a distance and a weight and no middle one either,
173+
# and the page still plainly has two entries on it.
174+
assert answers("3 meters\n100 kg\nsum\nmedian\ncount") ==
175+
["3 meters", "100 kilograms", nil, nil, "2"]
176+
end
177+
156178
test "a quantity that will not add refuses the total rather than being skipped" do
157179
# Skipping it would answer `41` for a sheet whose middle line is a
158180
# distance, under a label reading `Total`. There is no total of a number
@@ -226,6 +248,13 @@ defmodule LocalizePad.SheetTest do
226248
["1 meter", "50 centimeters", "2 meters", "1 meter"]
227249
end
228250

251+
test "the ends of the order are converted too, not reported as written" do
252+
# 50 cm is the smallest however it is spelled, and it is answered in the
253+
# unit the sheet chose rather than the one that line happened to use.
254+
assert answers("1 meter\n50 cm\n2 m\nmin\nmax") ==
255+
["1 meter", "50 centimeters", "2 meters", "0.5 meters", "2 meters"]
256+
end
257+
229258
test "quantities of different kinds have no average" do
230259
assert answers("3 meters\n100 kg\naverage") == ["3 meters", "100 kilograms", nil]
231260
end

test/localize_pad_web/live/sheet_live_test.exs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,51 @@ defmodule LocalizePadWeb.SheetLiveTest do
192192
end
193193
end
194194

195+
describe "the rule over an aggregate" do
196+
# The answer rows are the only thing carrying a line number, so the rows
197+
# ruled off can be read straight out of the markup.
198+
defp ruled_lines(html) do
199+
~r/phx-value-line="(\d+)"[^>]*sheet-rule/
200+
|> Regex.scan(html)
201+
|> Enum.map(fn [_whole, index] -> String.to_integer(index) end)
202+
end
203+
204+
test "one rule opens a run, however many functions are in it", %{conn: conn} do
205+
{:ok, live, _html} = live(conn, ~p"/")
206+
207+
# Three stripes would say the three answers were three separate things.
208+
html = render_change(live, :edit, %{"source" => "10\n20\nsum\naverage\nmedian"})
209+
210+
assert ruled_lines(html) == [2]
211+
end
212+
213+
test "and a blank inside the run does not start a second one", %{conn: conn} do
214+
{:ok, live, _html} = live(conn, ~p"/")
215+
216+
html = render_change(live, :edit, %{"source" => "10\n20\nsum\n\naverage"})
217+
218+
assert ruled_lines(html) == [2]
219+
end
220+
221+
test "every block gets its own", %{conn: conn} do
222+
{:ok, live, _html} = live(conn, ~p"/")
223+
224+
html = render_change(live, :edit, %{"source" => "10\nsum\n# Travel\n20\nsum"})
225+
226+
assert ruled_lines(html) == [1, 4]
227+
end
228+
229+
test "an aggregate with no answer is passed over, not ruled off", %{conn: conn} do
230+
{:ok, live, _html} = live(conn, ~p"/")
231+
232+
# There is no total of a distance and a weight, so the rule belongs over
233+
# the count, which is the first answer the run actually produces.
234+
html = render_change(live, :edit, %{"source" => "3 meters\n100 kg\nsum\ncount"})
235+
236+
assert ruled_lines(html) == [3]
237+
end
238+
end
239+
195240
describe "windows of one session" do
196241
# Two windows of a session mirror each other. The property that matters
197242
# more than the feature is that *only* they do: the topic is derived from a

0 commit comments

Comments
 (0)