Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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).
Expand Down
21 changes: 15 additions & 6 deletions docs/HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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**

Expand Down
133 changes: 127 additions & 6 deletions src/MyBudget.Telegram/Charts/BitmapFont.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
using System.Globalization;
using System.Text;

namespace MyBudget.Telegram.Charts;

/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
Expand All @@ -15,8 +21,36 @@

private const int Advance = GlyphWidth + 1;

/// <summary>Appended when a string does not fit, so a cut name cannot read as a full one.</summary>
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;

/// <summary>
/// Cuts a string down to the widest it can be in <paramref name="maxWidth"/> pixels. Every
/// character advances the same number of pixels, so the fit is a division rather than a
/// measurement loop.
/// </summary>
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)
{
Expand All @@ -25,7 +59,7 @@

var cursor = x;

foreach (var character in text)
foreach (var character in Normalized(text))
{
if (Glyph(character) is { } rows)
{
Expand All @@ -46,6 +80,31 @@
}
}

/// <summary>
/// Removes the combining marks a five-by-seven glyph cannot hold: <c>á</c> decomposes into
/// <c>a</c> plus a acute, and the acute is what gets dropped.
/// </summary>
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);
}
}
Comment on lines +97 to +103

return builder.ToString().Normalize(NormalizationForm.FormC);
}

private static byte[]? Glyph(char character) => character switch
{
'0' => Rows("01110", "10001", "10011", "10101", "11001", "10001", "01110"),
Expand All @@ -61,10 +120,72 @@
'.' => 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,
};

Expand Down
83 changes: 55 additions & 28 deletions src/MyBudget.Telegram/Charts/SpendingChartRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
namespace MyBudget.Telegram.Charts;

/// <summary>One drawn value. The label is optional and drawn with the bitmap font.</summary>
internal sealed record ChartEntry(long Value, string? Label = null);
/// <summary>
/// One drawn value. <c>Name</c> sits at the left of the row's header line, <c>Label</c> at its
/// right edge, and the bar fills the row underneath.
/// </summary>
internal sealed record ChartEntry(long Value, string? Label = null, string? Name = null);

/// <summary>
/// Turns report numbers into a PNG.
/// <para>
/// 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 <c>total</c> 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.
/// </para>
/// </summary>
internal static class SpendingChartRenderer
Expand All @@ -21,10 +25,22 @@

internal const int Margin = 28;
internal const int BarHeight = 26;
internal const int RowHeight = 44;

/// <summary>Text on a horizontal row is drawn at twice the base glyph size.</summary>
internal const int LabelScale = 2;

internal const int NameLineHeight = BitmapFont.GlyphHeight * LabelScale;
internal const int NameGap = 4;

/// <summary>Where the bar starts inside its row: under the name and the numbers.</summary>
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 =
[
Expand All @@ -38,13 +54,16 @@
new(138, 138, 78),
];

public static byte[] HorizontalBars(IReadOnlyList<ChartEntry> entries) =>
PngEncoder.Encode(DrawHorizontalBars(entries));
/// <param name="total">
/// What the bars are a share of: 100 % fills a row, so a 3 % category is 3 % of the row.
/// </param>
public static byte[] HorizontalBars(IReadOnlyList<ChartEntry> entries, long total) =>
PngEncoder.Encode(DrawHorizontalBars(entries, total));

public static byte[] VerticalBars(IReadOnlyList<ChartEntry> entries) =>
PngEncoder.Encode(DrawVerticalBars(entries));

internal static RgbCanvas DrawHorizontalBars(IReadOnlyList<ChartEntry> entries)
internal static RgbCanvas DrawHorizontalBars(IReadOnlyList<ChartEntry> entries, long total)
{
ArgumentNullException.ThrowIfNull(entries);

Expand All @@ -57,32 +76,40 @@
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;
Expand Down
Loading
Loading