From 25b21db6b060d21cf6dca040551826ac02efa4dd Mon Sep 17 00:00:00 2001 From: santidev21 Date: Wed, 30 Sep 2026 14:42:11 -0500 Subject: [PATCH] feat: scale the category chart by share of the month and add a breakdown back button The horizontal bars were measured against the largest category while the row labelled the budget usage percentage, so a "3 %" category could be drawn almost halfway across the image. Bars are now a share of the total they are given (HorizontalBars(entries, total)), every row shares one scale, and the label carries the amount with that same share. The budget detail stays in the numbered legend under the chart. Drawing the category name needed letters: BitmapFont gained A-Z, a-z and the punctuation a name needs, folds accents (a 5x7 glyph cannot hold a diacritic) and truncates what does not fit with "..". The p, q, g and y glyphs were also reseated on the x-height band, where "queso" used to read as "9ueso". Icons and other scripts still leave a blank, which is why the exact name keeps its place in the caption. The breakdown chooser ends in a back row that hands off to the summary, so the screen has an exit besides the persistent menu, even when the month has no movements. --- AGENTS.md | 12 +- docs/HANDOFF.md | 21 ++- src/MyBudget.Telegram/Charts/BitmapFont.cs | 133 +++++++++++++++++- .../Charts/SpendingChartRenderer.cs | 83 +++++++---- .../CategoryDetailConversation.cs | 15 ++ .../Conversations/StatisticsConversation.cs | 18 ++- .../CategoryDetailConversationTests.cs | 40 ++++++ .../ChartRendererTests.cs | 85 +++++++++-- 8 files changed, 348 insertions(+), 59 deletions(-) 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;