diff --git a/AGENTS.md b/AGENTS.md
index a894fe0..33de5fb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -169,8 +169,11 @@ production.
- Adding a menu section touches `MainMenu.ActionKeys`, the keyboard rows, the menu test and the
router mapping.
- Charts are hand-drawn (`RgbCanvas` + 5x7 bitmap font + PNG over `ZLibStream`) to avoid native
- dependencies; unknown glyphs render blank, so names stay in the caption, and the text screens
- always carry the exact numbers.
+ dependencies; the font folds accents (`á` renders as `a`) and leaves what it does not know
+ blank, so the icon and the exact name stay in the caption, where the phone's font draws them.
+ A horizontal bar is a share of the `total` it is given, never a fraction of the longest bar:
+ the percentage in the row is what the length shows, and two rows are comparable. The text
+ screens always carry the exact numbers.
**Settings, budgets and routing**
@@ -211,8 +214,9 @@ the closing of the last month sent at 23:59 on the user's last local day, with t
day as fallback; verified nightly backups;
reproducible deploy. Settings erases everything and switches the daily reminder; a budget set
once recurs every month with per-month overrides; the summary lists remaining budget and drills
-into each category's movements. **828 tests green**, build with zero warnings, `dotnet format`
-clean.
+into each category's movements. The category chart measures each bar as its share of the month
+and draws the category name; the breakdown has a way back. **835 tests green**, build with zero
+warnings, `dotnet format` clean.
Next: CSV export of a date range's expenses, then seed categories and recurring-rule editing.
The backlog and decisions are in [`docs/HANDOFF.md`](docs/HANDOFF.md).
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 86e7cd4..010797c 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -4,7 +4,7 @@ The working context is [`AGENTS.md`](../AGENTS.md); the architecture is
[`TECHNICAL-DESIGN.md`](TECHNICAL-DESIGN.md). This file is only the next task's prompt and the
decisions already taken for what comes after.
-## Done in this round (all verified, 828 tests green)
+## Done in this round (all verified, 835 tests green)
**Scheduled-notification foundation**
@@ -54,11 +54,20 @@ decisions already taken for what comes after.
**Statistics chart (category)**
-- The category chart now numbers each bar and labels it `$gastado/$presupuesto uso%`, and the
- caption carries a numbered legend with the category names (`Statistics.ChartCategoryLegend*`).
- It reads `GetMonthlySummaryAsync` for the per-category budget; the daily chart is unchanged.
-- `BitmapFont` gained `$` and `/` glyphs so the money and the separator are legible; category
- names still stay in the caption because accented letters are not in the font.
+- Each row of the category chart now carries the number, the category name and
+ `$gasto ($share %)` on one line, with the bar underneath: the bar is that share of the month's
+ total (`SpendingChartRenderer.HorizontalBars(entries, total)`), so 3 % is 3 % of the row
+ instead of a fraction of the longest bar, which is what made a "3 %" bar look halfway. The
+ caption keeps a numbered legend with the icon and the budget
+ (`Statistics.ChartCategoryLegend*`), read from `GetMonthlySummaryAsync`; the daily chart is
+ unchanged.
+- `BitmapFont` gained `A-Z`, `a-z` and the punctuation a name needs, and folds accents before
+ drawing (`á` → `a`) because a 5x7 glyph cannot hold a diacritic. `BitmapFont.Truncate` cuts a
+ name that does not fit and marks the cut with `..`. Icons and other scripts still leave a
+ blank, which is why the exact name stays in the caption too.
+- The breakdown chooser ("📂 ¿Qué categoría quieres revisar?") ends in an "↩️ Volver" row that
+ hands off to the summary (`CategoryDetailConversation.BackToSummaryCallback`), so the flow has
+ an exit besides the persistent menu. It is present even when the month has no movements.
**Daily reminder at 21:00 local**
diff --git a/src/MyBudget.Telegram/Charts/BitmapFont.cs b/src/MyBudget.Telegram/Charts/BitmapFont.cs
index ba81c99..ebd7ad9 100644
--- a/src/MyBudget.Telegram/Charts/BitmapFont.cs
+++ b/src/MyBudget.Telegram/Charts/BitmapFont.cs
@@ -1,10 +1,16 @@
+using System.Globalization;
+using System.Text;
+
namespace MyBudget.Telegram.Charts;
///
-/// A 5x7 bitmap font for the only text a chart draws: digits, separators and a few symbols.
+/// A 5x7 bitmap font for the text a chart draws: digits, money symbols and the letters a
+/// category name needs.
///
-/// Category and month names stay in the caption, where Telegram renders them with the phone's
-/// own font and full accents. That is what keeps the image dependency-free: no TTF to embed,
+/// Accents are folded rather than drawn, so "Alimentación" is rendered as "Alimentacion" inside
+/// the five-by-seven grid. Anything the font still does not know (an icon, another script) leaves
+/// a blank, and the exact name keeps its icon and accents in the caption, where Telegram renders
+/// it with the phone's own font. That is what keeps the image dependency-free: no TTF to embed,
/// no fontconfig in the container, and identical pixels in every environment.
///
///
@@ -15,8 +21,36 @@ internal static class BitmapFont
private const int Advance = GlyphWidth + 1;
+ /// Appended when a string does not fit, so a cut name cannot read as a full one.
+ private const string CutMark = "..";
+
public static int Measure(string text, int scale) =>
- string.IsNullOrEmpty(text) ? 0 : ((text.Length * Advance) - 1) * scale;
+ string.IsNullOrEmpty(text) ? 0 : ((Normalized(text).Length * Advance) - 1) * scale;
+
+ ///
+ /// Cuts a string down to the widest it can be in pixels. Every
+ /// character advances the same number of pixels, so the fit is a division rather than a
+ /// measurement loop.
+ ///
+ public static string Truncate(string text, int maxWidth, int scale)
+ {
+ var normalized = string.IsNullOrEmpty(text) ? string.Empty : Normalized(text);
+
+ if (Measure(normalized, scale) <= maxWidth)
+ {
+ return normalized;
+ }
+
+ var room = Math.Max(0, (((maxWidth / scale) + 1) / Advance) - CutMark.Length);
+
+ // Never cut between the halves of a surrogate pair: the result is normalized again.
+ if (room > 0 && char.IsHighSurrogate(normalized[room - 1]))
+ {
+ room--;
+ }
+
+ return room == 0 ? string.Empty : normalized[..room] + CutMark;
+ }
public static void Draw(RgbCanvas canvas, string text, int x, int y, int scale, Rgb colour)
{
@@ -25,7 +59,7 @@ public static void Draw(RgbCanvas canvas, string text, int x, int y, int scale,
var cursor = x;
- foreach (var character in text)
+ foreach (var character in Normalized(text))
{
if (Glyph(character) is { } rows)
{
@@ -46,6 +80,31 @@ public static void Draw(RgbCanvas canvas, string text, int x, int y, int scale,
}
}
+ ///
+ /// Removes the combining marks a five-by-seven glyph cannot hold: á decomposes into
+ /// a plus a acute, and the acute is what gets dropped.
+ ///
+ private static string Normalized(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return string.Empty;
+ }
+
+ var decomposed = text.Normalize(NormalizationForm.FormD);
+ var builder = new StringBuilder(decomposed.Length);
+
+ foreach (var character in decomposed)
+ {
+ if (CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.NonSpacingMark)
+ {
+ builder.Append(character);
+ }
+ }
+
+ return builder.ToString().Normalize(NormalizationForm.FormC);
+ }
+
private static byte[]? Glyph(char character) => character switch
{
'0' => Rows("01110", "10001", "10011", "10101", "11001", "10001", "01110"),
@@ -61,10 +120,72 @@ public static void Draw(RgbCanvas canvas, string text, int x, int y, int scale,
'.' => Rows("00000", "00000", "00000", "00000", "00000", "00110", "00110"),
',' => Rows("00000", "00000", "00000", "00000", "00110", "00110", "00100"),
'%' => Rows("11001", "11010", "00010", "00100", "01000", "01011", "10011"),
- '$' => Rows("00100", "01111", "10100", "01110", "00101", "11110", "00100"),
+ '$' => Rows("00100", "01111", "10100", "00110", "00001", "11110", "00100"),
'/' => Rows("00001", "00010", "00010", "00100", "01000", "01000", "10000"),
'-' => Rows("00000", "00000", "00000", "01110", "00000", "00000", "00000"),
+ ':' => Rows("00000", "00110", "00110", "00000", "00110", "00110", "00000"),
+ '(' => Rows("00010", "00100", "01000", "01000", "01000", "00100", "00010"),
+ ')' => Rows("01000", "00100", "00010", "00010", "00010", "00100", "01000"),
+ '+' => Rows("00000", "00100", "00100", "11111", "00100", "00100", "00000"),
+ '=' => Rows("00000", "00000", "11111", "00000", "11111", "00000", "00000"),
+ '?' => Rows("01110", "10001", "00001", "00110", "00100", "00000", "00100"),
+ '!' => Rows("00100", "00100", "00100", "00100", "00100", "00000", "00100"),
+ '\'' => Rows("00100", "00100", "01000", "00000", "00000", "00000", "00000"),
+ '&' => Rows("01100", "10010", "10010", "01100", "10101", "10010", "01101"),
+ '·' => Rows("00000", "00000", "00000", "00100", "00000", "00000", "00000"),
' ' => Rows("00000", "00000", "00000", "00000", "00000", "00000", "00000"),
+ 'A' => Rows("01110", "10001", "10001", "11111", "10001", "10001", "10001"),
+ 'B' => Rows("11110", "10001", "10001", "11110", "10001", "10001", "11110"),
+ 'C' => Rows("01110", "10001", "10000", "10000", "10000", "10001", "01110"),
+ 'D' => Rows("11110", "10001", "10001", "10001", "10001", "10001", "11110"),
+ 'E' => Rows("11111", "10000", "10000", "11110", "10000", "10000", "11111"),
+ 'F' => Rows("11111", "10000", "10000", "11110", "10000", "10000", "10000"),
+ 'G' => Rows("01110", "10001", "10000", "10111", "10001", "10001", "01111"),
+ 'H' => Rows("10001", "10001", "10001", "11111", "10001", "10001", "10001"),
+ 'I' => Rows("11111", "00100", "00100", "00100", "00100", "00100", "11111"),
+ 'J' => Rows("00111", "00010", "00010", "00010", "00010", "10010", "01100"),
+ 'K' => Rows("10001", "10010", "10100", "11000", "10100", "10010", "10001"),
+ 'L' => Rows("10000", "10000", "10000", "10000", "10000", "10000", "11111"),
+ 'M' => Rows("10001", "11011", "10101", "10101", "10001", "10001", "10001"),
+ 'N' => Rows("10001", "10001", "11001", "10101", "10011", "10001", "10001"),
+ 'O' => Rows("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
+ 'P' => Rows("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
+ 'Q' => Rows("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
+ 'R' => Rows("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
+ 'S' => Rows("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
+ 'T' => Rows("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
+ 'U' => Rows("10001", "10001", "10001", "10001", "10001", "10001", "01110"),
+ 'V' => Rows("10001", "10001", "10001", "10001", "10001", "01010", "00100"),
+ 'W' => Rows("10001", "10001", "10001", "10101", "10101", "10101", "01010"),
+ 'X' => Rows("10001", "10001", "01010", "00100", "01010", "10001", "10001"),
+ 'Y' => Rows("10001", "10001", "01010", "00100", "00100", "00100", "00100"),
+ 'Z' => Rows("11111", "00001", "00010", "00100", "01000", "10000", "11111"),
+ 'a' => Rows("00000", "00000", "01110", "00001", "01111", "10001", "01111"),
+ 'b' => Rows("10000", "10000", "11110", "10001", "10001", "10001", "11110"),
+ 'c' => Rows("00000", "00000", "01111", "10000", "10000", "10000", "01111"),
+ 'd' => Rows("00001", "00001", "01111", "10001", "10001", "10001", "01111"),
+ 'e' => Rows("00000", "00000", "01110", "10001", "11111", "10000", "01110"),
+ 'f' => Rows("00110", "01001", "01000", "11110", "01000", "01000", "01000"),
+ 'g' => Rows("00000", "00000", "01110", "10001", "10001", "01111", "11111"),
+ 'h' => Rows("10000", "10000", "11110", "10001", "10001", "10001", "10001"),
+ 'i' => Rows("00100", "00000", "01100", "00100", "00100", "00100", "01110"),
+ 'j' => Rows("00010", "00000", "00110", "00010", "00010", "10010", "01100"),
+ 'k' => Rows("10000", "10000", "10010", "10100", "11000", "10100", "10010"),
+ 'l' => Rows("01100", "00100", "00100", "00100", "00100", "00100", "01110"),
+ 'm' => Rows("00000", "00000", "11110", "10101", "10101", "10101", "10101"),
+ 'n' => Rows("00000", "00000", "11110", "10001", "10001", "10001", "10001"),
+ 'o' => Rows("00000", "00000", "01110", "10001", "10001", "10001", "01110"),
+ 'p' => Rows("00000", "00000", "11110", "10001", "10001", "11110", "10000"),
+ 'q' => Rows("00000", "00000", "01110", "10001", "10001", "01111", "00001"),
+ 'r' => Rows("00000", "00000", "10110", "11001", "10000", "10000", "10000"),
+ 's' => Rows("00000", "00000", "01111", "10000", "01110", "00001", "11110"),
+ 't' => Rows("01000", "01000", "11110", "01000", "01000", "01001", "00110"),
+ 'u' => Rows("00000", "00000", "10001", "10001", "10001", "10011", "01101"),
+ 'v' => Rows("00000", "00000", "10001", "10001", "10001", "01010", "00100"),
+ 'w' => Rows("00000", "00000", "10001", "10101", "10101", "10101", "01010"),
+ 'x' => Rows("00000", "00000", "10001", "01010", "00100", "01010", "10001"),
+ 'y' => Rows("00000", "00000", "10001", "10001", "01010", "00100", "00100"),
+ 'z' => Rows("00000", "00000", "11111", "00010", "00100", "01000", "11111"),
_ => null,
};
diff --git a/src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs b/src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs
index a8195e5..c4437d1 100644
--- a/src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs
+++ b/src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs
@@ -1,15 +1,19 @@
namespace MyBudget.Telegram.Charts;
-/// One drawn value. The label is optional and drawn with the bitmap font.
-internal sealed record ChartEntry(long Value, string? Label = null);
+///
+/// One drawn value. Name sits at the left of the row's header line, Label at its
+/// right edge, and the bar fills the row underneath.
+///
+internal sealed record ChartEntry(long Value, string? Label = null, string? Name = null);
///
/// Turns report numbers into a PNG.
///
-/// Two shapes cover the product: horizontal bars for spending per category (the label is the
-/// amount, at the end of each bar) and vertical bars for spending per day (the label is the
-/// day number). Category names, month names and totals stay in the caption: the image is the
-/// visual comparison, the message is the exact data.
+/// Two shapes cover the product: horizontal bars for spending per category and vertical bars for
+/// spending per day. The horizontal bar is measured against the total it is given, never
+/// against the longest bar, so a row's length is literally that category's share of the month and
+/// two rows are comparable; the exact numbers travel in the row itself and in the caption, where
+/// the phone's font draws the icon and the accents.
///
///
internal static class SpendingChartRenderer
@@ -21,10 +25,22 @@ internal static class SpendingChartRenderer
internal const int Margin = 28;
internal const int BarHeight = 26;
- internal const int RowHeight = 44;
+
+ /// Text on a horizontal row is drawn at twice the base glyph size.
+ internal const int LabelScale = 2;
+
+ internal const int NameLineHeight = BitmapFont.GlyphHeight * LabelScale;
+ internal const int NameGap = 4;
+
+ /// Where the bar starts inside its row: under the name and the numbers.
+ internal const int BarOffsetInRow = NameLineHeight + NameGap;
+
+ internal const int RowGap = 16;
+ internal const int RowHeight = BarOffsetInRow + BarHeight + RowGap;
internal const int VerticalAreaHeight = 180;
private const int VerticalLabelHeight = 18;
private const int LabelGap = 16;
+ private const int MinimumBarWidth = 3;
private static readonly Rgb[] Palette =
[
@@ -38,13 +54,16 @@ internal static class SpendingChartRenderer
new(138, 138, 78),
];
- public static byte[] HorizontalBars(IReadOnlyList entries) =>
- PngEncoder.Encode(DrawHorizontalBars(entries));
+ ///
+ /// What the bars are a share of: 100 % fills a row, so a 3 % category is 3 % of the row.
+ ///
+ public static byte[] HorizontalBars(IReadOnlyList entries, long total) =>
+ PngEncoder.Encode(DrawHorizontalBars(entries, total));
public static byte[] VerticalBars(IReadOnlyList entries) =>
PngEncoder.Encode(DrawVerticalBars(entries));
- internal static RgbCanvas DrawHorizontalBars(IReadOnlyList entries)
+ internal static RgbCanvas DrawHorizontalBars(IReadOnlyList entries, long total)
{
ArgumentNullException.ThrowIfNull(entries);
@@ -57,32 +76,40 @@ internal static RgbCanvas DrawHorizontalBars(IReadOnlyList entries)
return canvas;
}
- const int scale = 2;
- var maximum = bars.Max(entry => entry.Value);
+ // One width for every row: a bar is a share of the whole row, so rows stay comparable
+ // no matter how long their labels are.
+ var available = Width - (Margin * 2);
for (var index = 0; index < bars.Count; index++)
{
var entry = bars[index];
- var label = entry.Label ?? string.Empty;
- var labelWidth = BitmapFont.Measure(label, scale);
+ var rowTop = Margin + (index * RowHeight);
- // The label owns the right edge; the bar grows in what is left of the row.
- var available = Math.Max(0, Width - (Margin * 2) - labelWidth - LabelGap);
- var barWidth = maximum > 0 ? (int)((decimal)entry.Value / maximum * available) : 0;
- if (entry.Value > 0 && barWidth < 3)
+ var numbers = entry.Label ?? string.Empty;
+ var numbersWidth = BitmapFont.Measure(numbers, LabelScale);
+ var name = BitmapFont.Truncate(
+ entry.Name ?? string.Empty, available - numbersWidth - LabelGap, LabelScale);
+
+ // The name and the exact numbers share the header line, name at the left edge,
+ // numbers at the right edge.
+ BitmapFont.Draw(canvas, name, Margin, rowTop, LabelScale, Rgb.Ink);
+ BitmapFont.Draw(
+ canvas, numbers, Width - Margin - numbersWidth, rowTop, LabelScale, Rgb.Ink);
+
+ var barWidth = total > 0 ? (int)((decimal)entry.Value / total * available) : 0;
+ barWidth = Math.Clamp(barWidth, 0, available);
+
+ if (total > 0 && entry.Value > 0 && barWidth < MinimumBarWidth)
{
- barWidth = 3;
+ barWidth = MinimumBarWidth;
}
- var y = Margin + (index * RowHeight);
- canvas.Rectangle(Margin, y, barWidth, BarHeight, Palette[index % Palette.Length]);
- BitmapFont.Draw(
- canvas,
- label,
- Width - Margin - labelWidth,
- y + ((BarHeight - (BitmapFont.GlyphHeight * scale)) / 2),
- scale,
- Rgb.Ink);
+ canvas.Rectangle(
+ Margin,
+ rowTop + BarOffsetInRow,
+ barWidth,
+ BarHeight,
+ Palette[index % Palette.Length]);
}
return canvas;
diff --git a/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs b/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs
index 44e6141..92e30ea 100644
--- a/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs
+++ b/src/MyBudget.Telegram/Conversations/CategoryDetailConversation.cs
@@ -32,6 +32,9 @@ internal sealed class CategoryDetailConversation(
private const string ExpensePrefix = CallbackPrefix + "exp:";
private const string BackCallback = CallbackPrefix + "back";
+ /// Leaves the breakdown for the summary it was opened from.
+ internal const string BackToSummaryCallback = CallbackPrefix + "summary";
+
private const string ChooseState = "choose";
private const string MovementsState = "movements";
private const int PageSize = 20;
@@ -51,6 +54,15 @@ public async Task HandleCallbackAsync(
{
var period = CurrentPeriod(context);
+ if (callback.Data == BackToSummaryCallback)
+ {
+ // The breakdown is a side screen of the summary; going back resumes it.
+ return new ConversationTurn([])
+ {
+ HandoffConversation = SummaryConversation.ConversationName,
+ };
+ }
+
if (callback.Data.StartsWith(CategoryPrefix, StringComparison.Ordinal)
&& Guid.TryParse(callback.Data[CategoryPrefix.Length..], out var categoryId))
{
@@ -93,6 +105,9 @@ private async Task BuildChooserAsync(
? messages.Get(language, MessageKeys.BreakdownEmpty)
: messages.Get(language, MessageKeys.BreakdownChoose);
+ // Without this row the only way out of the breakdown is the persistent menu.
+ rows.Add([new BotButton(messages.Get(language, MessageKeys.ButtonBack), BackToSummaryCallback)]);
+
return new ConversationTurn([BotResponse.Message(text, BotKeyboard.Inline([.. rows]))])
{
NextState = ChooseState,
diff --git a/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs b/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs
index d36476c..1461248 100644
--- a/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs
+++ b/src/MyBudget.Telegram/Conversations/StatisticsConversation.cs
@@ -137,8 +137,10 @@ private async Task BuildChartAsync(
}
///
- /// The category bars are numbered and the amount and usage stay on the bar; the category
- /// names, which the bitmap font cannot draw, go in a numbered legend under the chart.
+ /// Each row carries the category's name and the amount with its share of the month, and the
+ /// bar below fills the row in proportion to that share, so the number and the length always
+ /// agree. The budget detail stays in the numbered legend under the chart, where the phone's
+ /// font draws the icon and the accents.
///
private async Task<(byte[] Png, string Caption)> BuildCategoryChartAsync(
ConversationContext context,
@@ -149,6 +151,7 @@ private async Task BuildChartAsync(
var currency = context.User.Currency;
var summary = await reports.GetMonthlySummaryAsync(
context.User.Id, period, cancellationToken);
+ var total = summary.TotalSpent;
var lines = summary.Lines
.Where(line => line.Spent > 0)
@@ -165,19 +168,24 @@ private async Task BuildChartAsync(
var line = lines[index];
var number = index + 1;
var spent = moneyFormatter.Format(line.Spent, currency);
+ var share = total > 0 ? (line.Spent * 100m) / total : 0m;
+
+ entries.Add(new ChartEntry(
+ line.Spent,
+ $"{spent} ({moneyFormatter.FormatPercentage(share)})",
+ $"{number} {line.CategoryName}"));
+
var label = $"{line.Icon} {line.CategoryName}";
if (line.HasBudget)
{
var budget = moneyFormatter.Format(line.Budget, currency);
var usage = moneyFormatter.FormatPercentage(line.UsagePercentage ?? 0m);
- entries.Add(new ChartEntry(line.Spent, $"{number} {spent}/{budget} {usage}"));
legend.Add(messages.Get(
language, MessageKeys.StatisticsChartCategoryLegend, number, label, spent, budget, usage));
}
else
{
- entries.Add(new ChartEntry(line.Spent, $"{number} {spent}"));
legend.Add(messages.Get(
language, MessageKeys.StatisticsChartCategoryLegendNoBudget, number, label, spent));
}
@@ -191,7 +199,7 @@ private async Task BuildChartAsync(
caption += "\n\n" + string.Join("\n", legend);
}
- return (SpendingChartRenderer.HorizontalBars(entries), caption);
+ return (SpendingChartRenderer.HorizontalBars(entries, total), caption);
}
///
diff --git a/tests/MyBudget.Telegram.Tests/CategoryDetailConversationTests.cs b/tests/MyBudget.Telegram.Tests/CategoryDetailConversationTests.cs
index 8efaaf2..d297977 100644
--- a/tests/MyBudget.Telegram.Tests/CategoryDetailConversationTests.cs
+++ b/tests/MyBudget.Telegram.Tests/CategoryDetailConversationTests.cs
@@ -103,4 +103,44 @@ public async Task Opening_a_movement_hands_it_to_the_expenses_flow()
harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation
.Should().Be(ExpensesConversation.ConversationName);
}
+
+ [Fact]
+ public async Task The_chooser_always_offers_a_way_back_to_the_summary()
+ {
+ var harness = TelegramHarness.Build();
+ harness.ReportService
+ .GetStatisticsAsync(harness.User.Id, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new PeriodStatistics(
+ September, 700_000, 2, 30, 0, Shares(), [], [],
+ new PeriodComparison(September, September.Previous, 700_000, 0, true, false)));
+
+ var turn = await harness.Router.RouteCallbackAsync(
+ ContextFor(harness, SummaryConversation.ConversationName, "summary"),
+ new IncomingCallback("cb", CategoryDetailConversation.OpenCallback),
+ CancellationToken.None);
+
+ var back = turn.Responses[0].Keyboard!.Rows[^1].Single();
+
+ back.CallbackData.Should().Be(CategoryDetailConversation.BackToSummaryCallback);
+ back.Text.Should().Be(harness.Messages.Get("es", MessageKeys.ButtonBack));
+ }
+
+ [Fact]
+ public async Task Going_back_from_the_chooser_resumes_the_summary()
+ {
+ var harness = TelegramHarness.Build();
+ harness.ReportService
+ .GetMonthlySummaryAsync(harness.User.Id, Arg.Any(), Arg.Any())
+ .Returns(new MonthlySummary(September, []));
+
+ var turn = await harness.Router.RouteCallbackAsync(
+ ContextFor(harness, CategoryDetailConversation.ConversationName, "choose"),
+ new IncomingCallback("cb", CategoryDetailConversation.BackToSummaryCallback),
+ CancellationToken.None);
+
+ harness.Conversations.SnapshotOf(harness.User.Id)!.Conversation
+ .Should().Be(SummaryConversation.ConversationName);
+ turn.Responses[0].Text.Should().StartWith(
+ harness.Messages.Get("es", MessageKeys.SummaryHeader, "septiembre 2026"));
+ }
}
diff --git a/tests/MyBudget.Telegram.Tests/ChartRendererTests.cs b/tests/MyBudget.Telegram.Tests/ChartRendererTests.cs
index 38d99b0..f3a1528 100644
--- a/tests/MyBudget.Telegram.Tests/ChartRendererTests.cs
+++ b/tests/MyBudget.Telegram.Tests/ChartRendererTests.cs
@@ -15,7 +15,7 @@ public sealed class ChartRendererTests
[Fact]
public void A_chart_is_a_png_with_the_canvas_size_in_its_header()
{
- var png = SpendingChartRenderer.HorizontalBars([new ChartEntry(100, "100")]);
+ var png = SpendingChartRenderer.HorizontalBars([new ChartEntry(100, "100")], total: 100);
png.Should().StartWith(PngSignature);
ReadInt(png, 16).Should().Be(SpendingChartRenderer.Width);
@@ -39,20 +39,44 @@ public void The_image_data_decompresses_to_the_expected_scanlines()
}
[Fact]
- public void Horizontal_bars_are_proportional_to_their_values()
+ public void A_bar_is_its_share_of_the_total_so_the_number_and_the_length_agree()
{
- var canvas = SpendingChartRenderer.DrawHorizontalBars([new ChartEntry(100), new ChartEntry(50)]);
+ // Three of a hundred must be 3 % of the row: the percentage the user reads is the
+ // length the user sees, not a fraction of the longest bar.
+ var canvas = SpendingChartRenderer.DrawHorizontalBars([new ChartEntry(3)], total: 100);
- var firstRow = SpendingChartRenderer.Margin + (SpendingChartRenderer.BarHeight / 2);
- var secondRow = firstRow + SpendingChartRenderer.RowHeight;
+ var available = SpendingChartRenderer.Width - (SpendingChartRenderer.Margin * 2);
+ var bar = ColouredPixelsInRow(canvas, BarRow(index: 0));
- var longest = ColouredPixelsInRow(canvas, firstRow);
- var half = ColouredPixelsInRow(canvas, secondRow);
+ ((double)bar).Should().BeApproximately(available * 0.03, 2.0);
+ }
+
+ [Fact]
+ public void Two_bars_keep_their_ratio_against_the_same_total()
+ {
+ var canvas = SpendingChartRenderer.DrawHorizontalBars(
+ [new ChartEntry(100), new ChartEntry(50)], total: 200);
+
+ var longest = ColouredPixelsInRow(canvas, BarRow(index: 0));
+ var half = ColouredPixelsInRow(canvas, BarRow(index: 1));
half.Should().BeGreaterThan(0);
((double)half).Should().BeApproximately(longest / 2.0, 1.5);
}
+ [Fact]
+ public void The_name_and_the_numbers_sit_above_the_bar()
+ {
+ var canvas = SpendingChartRenderer.DrawHorizontalBars(
+ [new ChartEntry(50, "50 %", "1 Comida")], total: 100);
+
+ ColouredPixelsInRow(canvas, SpendingChartRenderer.Margin + 1)
+ .Should().BeGreaterThan(0, "the header line carries the name and the exact numbers");
+
+ ColouredPixelsInRow(canvas, SpendingChartRenderer.Margin + SpendingChartRenderer.BarOffsetInRow + 1)
+ .Should().BeGreaterThan(0, "the bar fills the row under them");
+ }
+
[Fact]
public void More_bars_than_the_cap_are_not_drawn()
{
@@ -60,7 +84,7 @@ public void More_bars_than_the_cap_are_not_drawn()
.Select(index => new ChartEntry(index + 1))
.ToList();
- var png = SpendingChartRenderer.HorizontalBars(entries);
+ var png = SpendingChartRenderer.HorizontalBars(entries, total: 1000);
ReadInt(png, 20).Should().Be(
(SpendingChartRenderer.Margin * 2)
@@ -95,17 +119,52 @@ public void The_font_measures_advances_and_draws_digits()
}
[Fact]
- public void Text_the_font_does_not_know_draws_nothing()
+ public void A_category_name_is_drawn_letter_by_letter()
+ {
+ var canvas = new RgbCanvas(160, 16);
+ canvas.Fill(Rgb.White);
+
+ BitmapFont.Draw(canvas, "Mercado", 0, 0, 2, Rgb.Ink);
+
+ ColouredPixels(canvas).Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void An_accent_is_folded_away_instead_of_dropping_the_word()
{
- // Category names live in the caption, not the image, so unknown glyphs are blank.
+ BitmapFont.Measure("holá", scale: 2).Should().Be(BitmapFont.Measure("hola", scale: 2));
+
var canvas = new RgbCanvas(64, 16);
canvas.Fill(Rgb.White);
BitmapFont.Draw(canvas, "holá", 0, 0, 2, Rgb.Ink);
+ ColouredPixels(canvas).Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void Characters_the_font_does_not_know_draw_nothing()
+ {
+ // An icon or another script leaves a blank; the exact name stays in the caption.
+ var canvas = new RgbCanvas(64, 16);
+ canvas.Fill(Rgb.White);
+
+ BitmapFont.Draw(canvas, "日本語", 0, 0, 2, Rgb.Ink);
+
ColouredPixels(canvas).Should().Be(0);
}
+ [Fact]
+ public void A_name_that_does_not_fit_is_cut_with_two_dots()
+ {
+ var room = BitmapFont.Measure("abc", scale: 2);
+
+ var cut = BitmapFont.Truncate("una categoría muy larga", room, scale: 2);
+
+ BitmapFont.Measure(cut, scale: 2).Should().BeLessOrEqualTo(room);
+ cut.Should().EndWith("..");
+ }
+
[Fact]
public void The_font_draws_the_money_symbols_used_on_chart_labels()
{
@@ -117,6 +176,12 @@ public void The_font_draws_the_money_symbols_used_on_chart_labels()
ColouredPixels(canvas).Should().BeGreaterThan(0);
}
+ private static int BarRow(int index) =>
+ SpendingChartRenderer.Margin
+ + (index * SpendingChartRenderer.RowHeight)
+ + SpendingChartRenderer.BarOffsetInRow
+ + (SpendingChartRenderer.BarHeight / 2);
+
private static int ColouredPixelsInRow(RgbCanvas canvas, int y)
{
var count = 0;