From 86a73b862a1dc2318aa3e746a23ff858785ec7e8 Mon Sep 17 00:00:00 2001 From: troogs Date: Mon, 2 Nov 2026 19:19:07 +0100 Subject: [PATCH] feat(ui): constrain decimal input and add stacked timed toasts with uniform actions --- docs/usability-umsetzung.md | 10 +++ .../Components/AmountInput.razor | 24 +++++++ .../Components/Dialogs/AddEntryDialog.razor | 12 +--- .../Dialogs/AddEntryDialog.razor.cs | 4 +- .../Dialogs/AddTransferDialog.razor | 12 +--- .../Dialogs/AddTransferDialog.razor.cs | 4 +- .../Components/Layout/MainLayout.razor | 2 + .../Components/OperationNotice.razor | 43 ++++-------- src/Duempelkas.App/Components/ToastHost.razor | 38 +++++++++++ .../Pages/Accounts/AccountDetail.razor | 27 ++------ .../Pages/Accounts/AccountDetail.razor.cs | 19 +++++- src/Duempelkas.App/Pages/Dashboard.razor | 16 ++--- src/Duempelkas.App/Pages/Dashboard.razor.cs | 12 ++-- src/Duempelkas.App/Services/BookingInput.cs | 3 + src/Duempelkas.App/Services/ToastService.cs | 65 +++++++++++++++++++ src/Duempelkas.Desktop/Program.cs | 20 ++++-- src/Duempelkas.Desktop/wwwroot/css/app.css | 18 +++++ src/Duempelkas.Desktop/wwwroot/index.html | 1 + src/Duempelkas.Desktop/wwwroot/js/app.js | 19 ++++++ .../DependencyInjection.cs | 5 +- .../Services/SettingsService.cs | 11 ++-- tests/Duempelkas.Tests/BookingFormTests.cs | 4 +- tests/Duempelkas.Tests/BookingSaveTests.cs | 6 +- tests/Duempelkas.Tests/ToastTests.cs | 43 ++++++++++++ 24 files changed, 315 insertions(+), 103 deletions(-) create mode 100644 src/Duempelkas.App/Components/AmountInput.razor create mode 100644 src/Duempelkas.App/Components/ToastHost.razor create mode 100644 src/Duempelkas.App/Services/ToastService.cs create mode 100644 src/Duempelkas.Desktop/wwwroot/js/app.js create mode 100644 tests/Duempelkas.Tests/ToastTests.cs diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index 2b3c154..2f4c72d 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -49,3 +49,13 @@ Prüfung: 40/40 Tests in Debug und Release. Verzögerte normale Buchung und Umbu ## Abschluss und Grenzen Desktop-Projekte in Debug und Release gebaut, jeweils ohne Warnungen und Fehler. PDF-Inhalte und gerenderte Seiten sind geprüft. Die neue Windows-Debug-Anwendung wurde gestartet und ihr Accessibility-Baum mit den neuen Beschriftungen gelesen. **Die native visuelle/Interaktionsprüfung konnte nicht abgeschlossen werden: Windows war gesperrt; die Aufnahme zeigte den Sperrbildschirm.** Es wurden dort keine Buchungen angelegt oder Sicherungen zurückgespielt. Desktop-Sichtprüfung der umbrechenden Leisten, native Dateidialoge und reale Hilfstechnologien bleiben manuelle Abnahmepunkte. Die automatisierten Formular-/Navigationsprüfungen ersetzen keinen Test mit der Zielgruppe und keine vollständige U08-/U13-Abnahme. + +## Nacharbeit vom 2. November – Eingabe, Toasts und Aktionsleiste + +Beträge verwenden ein gemeinsames Control: Browser-beforeinput/Paste und die Komponentenbindung weisen mehr als zwei Dezimalstellen vollständig ab. Kein Kürzen oder Runden; der vorherige Wert bleibt erhalten. Der dauerhafte Nachkommastellen-Kommentar entfällt. Fortschritt, Dateiergebnisse und Speichermeldungen stehen in einem globalen Toast-Stapel oben rechts. Laufende Vorgänge bleiben bis zum Abschluss sichtbar; Ergebnisse laufen nach 9 Sekunden, Fehler nach 15 Sekunden ab. Hover/Fokus pausiert, Schließen bleibt möglich. Feldbezogene Validierung bleibt am Feld. Aktionsbuttons haben feste gemeinsame Höhe/Breite mit reservierter Symbolzeile und zwei Textzeilen. + +Prüfung: 42/42 Debug-Tests, Desktop-Build ohne Warnungen. Computer Use in separatem Debug-Datenordner: Übersicht und Konto geprüft; Eingabe `12,501` vollständig abgewiesen, `12,50` angenommen, künstliche Einnahme gespeichert, Kontostand von 88,30 auf 100,80 € erhöht und Toast oben rechts beobachtet. Stapeln, unabhängiges Ablaufen und Pausieren sind automatisiert geprüft. Keine Vereinsdaten geändert. Der zusätzliche Debug-Startparameter `--data-directory` isoliert Datenbank, Vereinsname und Fensterkonfiguration; Release erhält ihn nicht. + +## Weiterer Umsetzungsplan + +U08: gemeinsame native Modaldialoge mit Fokus/Escape/Verlustschutz, semantische Kontolinks. U09: lesbare Zeilenaktionen mit größeren Treffern. U10: Löschstatus, Bestätigung beider Umbuchungsseiten und gezieltes Rückgängig. U11: Richtung und Auswirkungen von beiden Seiten identisch erklären. U12: bestehende gemeinsame Anfangsbestands-Semantik beibehalten und Korrektur vorab erklären/bestätigen. Danach U13: skalierbare lesbare Darstellung/Kontrast; U14: lokale Hilfe und überspringbare Einführung; U15: Suche und Sortierung mit getrennten Treffermengen; U16: lokale Darstellungsressourcen und sichere Fehlerauswege. Nach jeder Maßnahme bauen und testen; Paketgrenzen pushen und PR aktualisieren. diff --git a/src/Duempelkas.App/Components/AmountInput.razor b/src/Duempelkas.App/Components/AmountInput.razor new file mode 100644 index 0000000..0b454df --- /dev/null +++ b/src/Duempelkas.App/Components/AmountInput.razor @@ -0,0 +1,24 @@ + +@rejection + +@code { + [Parameter] public string Id { get; set; } = "amount"; + [Parameter] public string Value { get; set; } = string.Empty; + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public bool AllowNegative { get; set; } + private string? rejection; + private async Task SetValue(string value) + { + if (!BookingInput.IsAmountDraft(value, AllowNegative)) + { + rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben."; + return; + } + rejection = null; + await ValueChanged.InvokeAsync(value); + } +} diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor index 576ea25..8823f24 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor @@ -1,3 +1,4 @@ +@inject ToastService Toasts @inject IEntryService EntryService
@@ -36,8 +37,8 @@
- -

Betrag ohne Minuszeichen, größer als 0. Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen, auch beim Einfügen. Es wird nicht gerundet.

+ +

Betrag ohne Minuszeichen.

@@ -50,13 +51,6 @@ } - @if (isSaving) {

Wird gespeichert …

} - @if (saveError != null) - { - - }
@if (OutsideYear) @@ -39,13 +40,6 @@ } - @if (isSaving) {

Wird gespeichert …

} - @if (saveError != null) - { - - }
- } - @if (Result.Details != null) - { -
Technische Details

@Result.Details

- } - @if (openResult != null) - { -

@openResult.Message

- @if (openResult.Details != null) {
Technische Details@openResult.Details
} - } -
-} - @code { [Parameter] public FileOperationResult? Result { get; set; } - private FileOperationResult? openResult; private FileOperationResult? previous; - private bool busy; protected override void OnParametersSet() { - if (previous != Result) { openResult = null; previous = Result; } - } - private async Task OpenFolder() - { - if (busy || Result?.Path == null) return; - busy = true; - try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); } - finally { busy = false; } + if (Result == null || ReferenceEquals(previous, Result)) return; + previous = Result; + var result = Result; + Toasts.Show(result.Message + (result.Path != null ? " " + result.Path : ""), + result.Status == FileOperationStatus.Error ? ToastKind.Error : result.Status == FileOperationStatus.Success ? ToastKind.Success : ToastKind.Info, + result.Details, result.Path != null ? "Ordner öffnen" : null, + result.Path != null ? async () => { + var opened = await FileSaveService.OpenFolderAsync(result.Path); + Toasts.Show(opened.Message, opened.Status == FileOperationStatus.Error ? ToastKind.Error : ToastKind.Info, opened.Details); + } : null); } } diff --git a/src/Duempelkas.App/Components/ToastHost.razor b/src/Duempelkas.App/Components/ToastHost.razor new file mode 100644 index 0000000..0d0b7f2 --- /dev/null +++ b/src/Duempelkas.App/Components/ToastHost.razor @@ -0,0 +1,38 @@ +@implements IDisposable +@inject ToastService Toasts + +
+ @foreach (var toast in Toasts.Messages) + { +
+ +

@toast.Message

+ @if (toast.Action != null) + { + + } + @if (toast.Details != null) + { +
Technische Details

@toast.Details

+ } +
+ } +
+ +@code { + private readonly HashSet running = []; + protected override void OnInitialized() => Toasts.Changed += Refresh; + private void Refresh() => _ = InvokeAsync(StateHasChanged); + private async Task RunAction(ToastMessage toast) + { + if (!running.Add(toast.Id)) return; + Toasts.Pause(toast.Id); + try { await toast.Action!(); } + catch (Exception ex) { Toasts.Show("Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); } + finally { running.Remove(toast.Id); Toasts.Resume(toast.Id); } + } + public void Dispose() => Toasts.Changed -= Refresh; +} diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index 5bb215a..72cdd4e 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -1,3 +1,4 @@ +@inject ToastService Toasts @inject ReportingPeriod Period @page "/accounts/{AccountId:int}" @inject IAccountService AccountService @@ -9,21 +10,7 @@
- @if (fileBusy) {

Dateiaktion läuft … Bitte warten.

} - @if (lastSaved != null) - { -
- @lastSaved.Kind über @FormatAmount(lastSaved.Amount) @(lastSaved.Edited ? "aktualisiert" : "gespeichert"). -
- } - @if (savedOutsideYear.HasValue) - { -
- Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht. - -
- } @if (account == null) {
@@ -41,21 +28,21 @@
-
+
diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs index be18c7d..685a5a8 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs @@ -1,3 +1,4 @@ +using Duempelkas.App.Services; using Duempelkas.App.Services.Models; using Microsoft.AspNetCore.Components; @@ -10,6 +11,10 @@ public partial class AccountDetail [Parameter] public int AccountId { get; set; } + [SupplyParameterFromQuery(Name = "jahr")] + public int? QueryYear { get; set; } + private int? previousQueryYear; + #endregion #region Fields @@ -53,6 +58,8 @@ public partial class AccountDetail protected override async Task OnParametersSetAsync() { + if (QueryYear.HasValue && QueryYear != previousQueryYear) selectedYear = QueryYear; + previousQueryYear = QueryYear; await LoadAll(); } @@ -115,6 +122,12 @@ public partial class AccountDetail await LoadAll(); lastSaved = saved; savedOutsideYear = selectedYear.HasValue && selectedYear.Value != saved.Date.Year ? saved.Date.Year : null; + var nav = Navigation; + var period = Period; + var accountId = AccountId; + Toasts.Show($"{saved.Kind} über {FormatAmount(saved.Amount)} {(saved.Edited ? "aktualisiert" : "gespeichert")}.", ToastKind.Success, + actionLabel: savedOutsideYear.HasValue ? $"Buchung im Jahr {saved.Date.Year} anzeigen" : null, + action: savedOutsideYear.HasValue ? () => { period.Year = saved.Date.Year; nav.NavigateTo($"/accounts/{accountId}?jahr={saved.Date.Year}"); return Task.CompletedTask; } : null); showAddEntry = false; showAddTransfer = false; editingEntry = null; @@ -187,6 +200,7 @@ public partial class AccountDetail private async Task HandleExport() { if (fileBusy) return; + var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); fileBusy = true; fileResult = null; try @@ -199,12 +213,13 @@ public partial class AccountDetail } catch (Exception ex) { fileResult = FileOperationResult.Error(ex); } - finally { fileBusy = false; } + finally { fileBusy = false; Toasts.Dismiss(busyToast); } } private async Task HandleOpenSavedPdf() { if (fileBusy) return; + var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); fileBusy = true; fileResult = null; try @@ -218,7 +233,7 @@ public partial class AccountDetail } catch (Exception ex) { fileResult = FileOperationResult.Error(ex); } - finally { fileBusy = false; } + finally { fileBusy = false; Toasts.Dismiss(busyToast); } } private void CancelOpenSavedPdf() diff --git a/src/Duempelkas.App/Pages/Dashboard.razor b/src/Duempelkas.App/Pages/Dashboard.razor index 83d6662..99c0406 100644 --- a/src/Duempelkas.App/Pages/Dashboard.razor +++ b/src/Duempelkas.App/Pages/Dashboard.razor @@ -1,3 +1,4 @@ +@inject ToastService Toasts @inject ReportingPeriod Period @page "/" @inject IAccountService AccountService @@ -10,7 +11,6 @@
- @if (fileBusy) {

Dateiaktion läuft … Bitte warten.

}
@@ -24,25 +24,25 @@
-
+
- Weitere Aktionen + Weitere
Aktionen
diff --git a/src/Duempelkas.App/Pages/Dashboard.razor.cs b/src/Duempelkas.App/Pages/Dashboard.razor.cs index 9629c83..38021e3 100644 --- a/src/Duempelkas.App/Pages/Dashboard.razor.cs +++ b/src/Duempelkas.App/Pages/Dashboard.razor.cs @@ -101,6 +101,7 @@ public partial class Dashboard private async Task HandleBackupAsync() { if (fileBusy) return; + var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); fileBusy = true; fileResult = null; try @@ -109,7 +110,7 @@ public partial class Dashboard } catch (Exception ex) { fileResult = FileOperationResult.Error(ex); } - finally { fileBusy = false; } + finally { fileBusy = false; Toasts.Dismiss(busyToast); } } private Task HandleRestoreAsync() @@ -126,6 +127,7 @@ public partial class Dashboard private async Task HandleConfirmRestore() { if (fileBusy) return; + var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); fileBusy = true; fileResult = null; try @@ -136,12 +138,13 @@ public partial class Dashboard } catch (Exception ex) { fileResult = FileOperationResult.Error(ex); } - finally { fileBusy = false; } + finally { fileBusy = false; Toasts.Dismiss(busyToast); } } private async Task HandleDashboardExportAsync() { if (fileBusy) return; + var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); fileBusy = true; fileResult = null; try @@ -154,7 +157,7 @@ public partial class Dashboard } catch (Exception ex) { fileResult = FileOperationResult.Error(ex); } - finally { fileBusy = false; } + finally { fileBusy = false; Toasts.Dismiss(busyToast); } } private void OpenYearFilterDialog() @@ -177,6 +180,7 @@ public partial class Dashboard private async Task HandleOpenSavedPdf() { if (fileBusy) return; + var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true); fileBusy = true; fileResult = null; try @@ -190,7 +194,7 @@ public partial class Dashboard } catch (Exception ex) { fileResult = FileOperationResult.Error(ex); } - finally { fileBusy = false; } + finally { fileBusy = false; Toasts.Dismiss(busyToast); } } private void CancelOpenSavedPdf() diff --git a/src/Duempelkas.App/Services/BookingInput.cs b/src/Duempelkas.App/Services/BookingInput.cs index d3c7780..920d843 100644 --- a/src/Duempelkas.App/Services/BookingInput.cs +++ b/src/Duempelkas.App/Services/BookingInput.cs @@ -8,6 +8,9 @@ public static partial class BookingInput [GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)] private static partial Regex AmountPattern(); + public static bool IsAmountDraft(string value, bool allowNegative = false) => + Regex.IsMatch(value, allowNegative ? @"^-?\d*([,.]\d{0,2})?$" : @"^\d*([,.]\d{0,2})?$", RegexOptions.CultureInvariant); + public static bool TryAmount(string text, out decimal amount) { amount = 0; diff --git a/src/Duempelkas.App/Services/ToastService.cs b/src/Duempelkas.App/Services/ToastService.cs new file mode 100644 index 0000000..abd5a92 --- /dev/null +++ b/src/Duempelkas.App/Services/ToastService.cs @@ -0,0 +1,65 @@ +namespace Duempelkas.App.Services; + +public enum ToastKind { Info, Success, Error } +public sealed record ToastMessage(Guid Id, string Message, ToastKind Kind, string? Details, + string? ActionLabel, Func? Action); + +public sealed class ToastService : IDisposable +{ + private readonly object sync = new(); + private readonly Dictionary items = []; + public event Action? Changed; + public IReadOnlyList Messages { get { lock (sync) return items.Values.Select(x => x.Message).ToArray(); } } + + public Guid Show(string message, ToastKind kind = ToastKind.Info, string? details = null, + string? actionLabel = null, Func? action = null, TimeSpan? duration = null, bool persistent = false) + { + var id = Guid.NewGuid(); + var lifetime = persistent ? (TimeSpan?)null : duration ?? TimeSpan.FromSeconds(kind == ToastKind.Error ? 15 : 9); + var toast = new ToastMessage(id, message, kind, details, actionLabel, action); + lock (sync) + { + Timer? timer = lifetime.HasValue ? new Timer(_ => Dismiss(id), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) : null; + items[id] = (toast, timer, lifetime, DateTimeOffset.UtcNow); + if (lifetime.HasValue) timer!.Change(lifetime.Value, Timeout.InfiniteTimeSpan); + } + Changed?.Invoke(); + return id; + } + + public void Pause(Guid id) + { + lock (sync) + { + if (!items.TryGetValue(id, out var item) || item.Remaining == null || item.Started == default) return; + item.Timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + var left = item.Remaining.Value - (DateTimeOffset.UtcNow - item.Started); + items[id] = (item.Message, item.Timer, left > TimeSpan.Zero ? left : TimeSpan.FromMilliseconds(1), default); + } + } + + public void Resume(Guid id) + { + lock (sync) + { + if (!items.TryGetValue(id, out var item) || item.Remaining == null || item.Started != default) return; + items[id] = (item.Message, item.Timer, item.Remaining, DateTimeOffset.UtcNow); + item.Timer?.Change(item.Remaining.Value, Timeout.InfiniteTimeSpan); + } + } + + public void Dismiss(Guid id) + { + lock (sync) + { + if (!items.Remove(id, out var item)) return; + item.Timer?.Dispose(); + } + Changed?.Invoke(); + } + + public void Dispose() + { + lock (sync) { foreach (var item in items.Values) item.Timer?.Dispose(); items.Clear(); } + } +} diff --git a/src/Duempelkas.Desktop/Program.cs b/src/Duempelkas.Desktop/Program.cs index 0b5559b..360c7d9 100644 --- a/src/Duempelkas.Desktop/Program.cs +++ b/src/Duempelkas.Desktop/Program.cs @@ -27,6 +27,16 @@ class Program var appDataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Duempelkas"); + var isolated = false; +#if DEBUG + var directoryIndex = Array.IndexOf(args, "--data-directory"); + if (directoryIndex >= 0) + { + if (directoryIndex + 1 >= args.Length) throw new ArgumentException("Datenordner fehlt."); + appDataDir = Path.GetFullPath(args[directoryIndex + 1]); + isolated = true; + } +#endif Directory.CreateDirectory(appDataDir); var dbPath = Path.Combine(appDataDir, "duempelkas.db"); @@ -34,7 +44,7 @@ class Program // One-time compatibility: keep existing data if older versions stored DB next to the app executable. var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory; var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db"); - if (!File.Exists(dbPath) && File.Exists(legacyDbPath)) + if (!isolated && !File.Exists(dbPath) && File.Exists(legacyDbPath)) { try { @@ -50,7 +60,7 @@ class Program } } - appBuilder.Services.AddInfrastructure($"Data Source={dbPath}"); + appBuilder.Services.AddInfrastructure(new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder { DataSource = dbPath }.ToString(), appDataDir); appBuilder.RootComponents.Add("app"); @@ -72,9 +82,7 @@ class Program } // Load saved window size or use defaults - var settingsPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Duempelkas", "window.json"); + var settingsPath = Path.Combine(appDataDir, "window.json"); int savedWidth = 1280, savedHeight = 800; if (File.Exists(settingsPath)) @@ -90,7 +98,7 @@ class Program } app.MainWindow - .SetTitle("Dümpelkas – Kassenbuch") + .SetTitle(isolated ? "Dümpelkas – Testdaten" : "Dümpelkas – Kassenbuch") .SetSize(savedWidth, savedHeight) .SetUseOsDefaultSize(false); diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css index e336c91..af81a27 100644 --- a/src/Duempelkas.Desktop/wwwroot/css/app.css +++ b/src/Duempelkas.Desktop/wwwroot/css/app.css @@ -612,3 +612,21 @@ fieldset { min-width: 0; } @media (prefers-reduced-motion: reduce) { .entry-recent > td { animation: none; border-bottom: 2px solid var(--color-accent); } } + +/* One icon row plus two reserved text rows on every page. */ +.action-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; padding: 1rem 0; align-items: flex-start; } +.action-toolbar .btn-nav { box-sizing: border-box; width: 10.5rem; min-width: 10.5rem; max-width: 10.5rem; height: 6.25rem; min-height: 6.25rem; padding: .45rem .6rem; font-size: .9rem !important; line-height: 1.15rem; gap: .2rem; border-radius: .5rem; border: 1px solid transparent; overflow-wrap: normal; } +.action-toolbar .btn-nav i { display: block; font-size: 1.65rem; line-height: 1.85rem; } +.action-toolbar .btn-nav span { display: block; min-height: 2.3rem; } +.action-toolbar .more-actions { align-self: flex-start; position: relative; } +.action-toolbar .more-actions[open] { padding: 0; border: 0; } +.more-actions > button { position: absolute; z-index: 20; right: 0; min-width: 15rem; box-shadow: 0 4px 16px #0004; } +.toast-stack { position: fixed; z-index: 3000; inset: .75rem .75rem auto auto; width: min(28rem, calc(100vw - 1.5rem)); max-height: calc(100vh - 1.5rem); overflow-y: auto; display: flex; flex-direction: column; gap: .65rem; pointer-events: none; } +.app-toast { pointer-events: auto; padding: 1rem 2.5rem 1rem 1rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-left: .35rem solid var(--color-accent); border-radius: .65rem; box-shadow: 0 5px 22px #0004; position: relative; animation: toast-enter .2s ease-out; overflow-wrap: anywhere; } +.toast-success { border-left-color: var(--color-income); } +.toast-error { border-left-color: var(--color-expense); } +.app-toast p { margin: 0 0 .5rem; } +.toast-close { position: absolute; right: .3rem; top: .3rem; background: transparent; border: 0; color: inherit; width: 2rem; height: 2rem; font-size: 1.5rem; } +@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } } +.input-rejection:empty { display: none; } diff --git a/src/Duempelkas.Desktop/wwwroot/index.html b/src/Duempelkas.Desktop/wwwroot/index.html index 2f14137..06bce7c 100644 --- a/src/Duempelkas.Desktop/wwwroot/index.html +++ b/src/Duempelkas.Desktop/wwwroot/index.html @@ -126,6 +126,7 @@
×
+ diff --git a/src/Duempelkas.Desktop/wwwroot/js/app.js b/src/Duempelkas.Desktop/wwwroot/js/app.js new file mode 100644 index 0000000..ff7b5d1 --- /dev/null +++ b/src/Duempelkas.Desktop/wwwroot/js/app.js @@ -0,0 +1,19 @@ +// Keep decimal editing predictable: reject the complete invalid edit/paste, never round it. +(() => { + function allowed(input, value) { + return (input.dataset.allowNegative === 'true' ? /^-?\d*([,.]\d{0,2})?$/ : /^\d*([,.]\d{0,2})?$/).test(value); + } + function accept(input, text) { + const next = input.value.slice(0, input.selectionStart) + text + input.value.slice(input.selectionEnd); + const ok = allowed(input, next); + const hint = document.getElementById(input.id + '-rejected'); + if (hint) hint.textContent = ok ? '' : 'Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.'; + return ok; + } + document.addEventListener('beforeinput', e => { + if (e.target.matches('[data-amount-guard]') && e.data != null && !accept(e.target, e.data)) e.preventDefault(); + }); + document.addEventListener('paste', e => { + if (e.target.matches('[data-amount-guard]') && !accept(e.target, e.clipboardData.getData('text').trim())) e.preventDefault(); + }); +})(); diff --git a/src/Duempelkas.Infrastructure/DependencyInjection.cs b/src/Duempelkas.Infrastructure/DependencyInjection.cs index 853929f..6264d02 100644 --- a/src/Duempelkas.Infrastructure/DependencyInjection.cs +++ b/src/Duempelkas.Infrastructure/DependencyInjection.cs @@ -8,7 +8,7 @@ namespace Duempelkas.Infrastructure; public static class DependencyInjection { - public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString) + public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString, string? settingsDirectory = null) { services.AddSingleton(new DatabaseLocation(connectionString)); // The factory also registers FinanceDbContext as scoped. Registering @@ -23,8 +23,9 @@ public static class DependencyInjection services.AddSingleton(); services.AddScoped(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(new SettingsService(settingsDirectory)); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/Duempelkas.Infrastructure/Services/SettingsService.cs b/src/Duempelkas.Infrastructure/Services/SettingsService.cs index 78da1ab..3c1f852 100644 --- a/src/Duempelkas.Infrastructure/Services/SettingsService.cs +++ b/src/Duempelkas.Infrastructure/Services/SettingsService.cs @@ -5,9 +5,10 @@ namespace Duempelkas.Infrastructure.Services; public class SettingsService : ISettingsService { - private static readonly string SettingsPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Duempelkas", "settings.json"); + private readonly string SettingsPath; + + public SettingsService(string? directory = null) => SettingsPath = Path.Combine(directory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Duempelkas"), "settings.json"); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; @@ -24,7 +25,7 @@ public class SettingsService : ISettingsService await SaveAsync(settings); } - private static async Task LoadAsync() + private async Task LoadAsync() { if (!File.Exists(SettingsPath)) return new AppSettings(); @@ -40,7 +41,7 @@ public class SettingsService : ISettingsService } } - private static async Task SaveAsync(AppSettings settings) + private async Task SaveAsync(AppSettings settings) { var dir = Path.GetDirectoryName(SettingsPath)!; Directory.CreateDirectory(dir); diff --git a/tests/Duempelkas.Tests/BookingFormTests.cs b/tests/Duempelkas.Tests/BookingFormTests.cs index af09f31..020c76f 100644 --- a/tests/Duempelkas.Tests/BookingFormTests.cs +++ b/tests/Duempelkas.Tests/BookingFormTests.cs @@ -15,6 +15,7 @@ public class BookingFormTests public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput() { using var context = new BunitContext(); + context.Services.AddSingleton(); context.Services.AddSingleton(new EntryService(null!)); var dialog = context.Render(p => p.Add(x => x.AccountName, "Barkasse")); Assert.Contains("Barkasse", dialog.Find("h5").TextContent); @@ -22,7 +23,7 @@ public class BookingFormTests Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); dialog.Find("#entry-title").Input("Porto"); dialog.Find("#entry-amount").Input("12,501"); - Assert.Equal("12,501", dialog.Find("#entry-amount").GetAttribute("value")); + Assert.Equal("", dialog.Find("#entry-amount").GetAttribute("value")); Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); dialog.Find("#entry-amount").Input("-12,50"); Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); @@ -39,6 +40,7 @@ public class BookingFormTests public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput() { using var context = new BunitContext(); + context.Services.AddSingleton(); context.Services.AddSingleton(new EntryService(null!)); var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null); var dialog = context.Render(p => p.Add(x => x.EditEntry, entry)); diff --git a/tests/Duempelkas.Tests/BookingSaveTests.cs b/tests/Duempelkas.Tests/BookingSaveTests.cs index fd802cc..80a4394 100644 --- a/tests/Duempelkas.Tests/BookingSaveTests.cs +++ b/tests/Duempelkas.Tests/BookingSaveTests.cs @@ -37,7 +37,7 @@ public class BookingSaveTests Assert.Equal(1, fixture.Entries.WriteCalls); delayed.SetResult(); await pending; - page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".alert-success").TextContent)); + page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".toast-success").TextContent)); Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null)); Assert.Single(page.FindAll("tr.entry-recent")); if (transfer) Assert.Single(await fixture.Entries.GetEntriesAsync(2, (int?)null)); @@ -62,7 +62,7 @@ public class BookingSaveTests fixture.Entries.BeforeWrite = () => Task.FromException(new IOException("Simulierter Speicherfehler")); await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); Assert.Contains("Eingaben bleiben erhalten", page.Markup); - Assert.Empty(page.FindAll(".alert-success")); + Assert.Empty(page.FindAll(".toast-success")); Assert.Equal("Testbuchung", page.Find(transfer ? "#transfer-title" : "#entry-title").GetAttribute("value")); Assert.Equal("12,50", page.Find(transfer ? "#transfer-amount" : "#entry-amount").GetAttribute("value")); Assert.Empty(await fixture.Entries.GetEntriesAsync(1, (int?)null)); @@ -86,7 +86,7 @@ public class BookingSaveTests dialog.Find("#entry-title").Input("Testbuchung"); dialog.Find("#entry-amount").Input("12,50"); await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); - Assert.Contains("Die Buchung ist gespeichert", dialog.Markup); + Assert.Contains(fixture.Context.Services.GetRequiredService().Messages, t => t.Message.Contains("Die Buchung ist gespeichert")); await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); Assert.Equal(1, fixture.Entries.WriteCalls); Assert.Equal(2, notifications); diff --git a/tests/Duempelkas.Tests/ToastTests.cs b/tests/Duempelkas.Tests/ToastTests.cs new file mode 100644 index 0000000..f0c3aaf --- /dev/null +++ b/tests/Duempelkas.Tests/ToastTests.cs @@ -0,0 +1,43 @@ +using Bunit; +using Duempelkas.App.Components; +using Duempelkas.App.Services; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Duempelkas.Tests; + +public class ToastTests +{ + [Fact] + public async Task ToastsStack_ExpireIndependently_AndPauseDuringInteraction() + { + using var context = new BunitContext(); + context.Services.AddSingleton(); + var host = context.Render(); + var service = context.Services.GetRequiredService(); + var first = service.Show("Erste Meldung", duration: TimeSpan.FromMilliseconds(100)); + service.Pause(first); + service.Show("Zweite Meldung", ToastKind.Success, persistent: true); + host.WaitForAssertion(() => Assert.Equal(2, host.FindAll(".app-toast").Count)); + await Task.Delay(200); + Assert.Equal(2, service.Messages.Count); + service.Resume(first); + host.WaitForAssertion(() => Assert.Single(host.FindAll(".app-toast"))); + Assert.Contains("Zweite Meldung", host.Markup); + host.Find(".toast-close").Click(); + Assert.Empty(service.Messages); + } + + [Fact] + public void AmountControl_RejectsWholePasteWithoutRounding() + { + using var context = new BunitContext(); + string amount = "12,50"; + var control = context.Render(p => p.Add(x => x.Value, amount).Add(x => x.ValueChanged, value => amount = value)); + control.Find("input").Input("12,501"); + Assert.Equal("12,50", amount); + Assert.Contains("nicht übernommen", control.Markup); + control.Find("input").Input("12.25"); + Assert.Equal("12.25", amount); + } +}