Usability U01–U16: sichere Abläufe und Bedienkorrekturen #1

Merged
troogs merged 25 commits from docs/usability-analyse into main 2026-11-02 21:47:44 +01:00
24 changed files with 315 additions and 103 deletions
Showing only changes of commit 86a73b862a - Show all commits
+10
View File
@@ -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.
@@ -0,0 +1,24 @@
<input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard
data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()"
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled"
aria-describedby="@(Id + "-rejected")" />
<span id="@(Id + "-rejected")" class="input-rejection text-danger" role="status">@rejection</span>
@code {
[Parameter] public string Id { get; set; } = "amount";
[Parameter] public string Value { get; set; } = string.Empty;
[Parameter] public EventCallback<string> 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);
}
}
@@ -1,3 +1,4 @@
@inject ToastService Toasts
@inject IEntryService EntryService
<div class="dialog-backdrop">
@@ -36,8 +37,8 @@
<label class="form-label" for="entry-amount">Betrag (€)</label>
<div>
<input id="entry-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" placeholder="12,50" aria-describedby="entry-amount-help" aria-invalid="@(!ValidAmount ? "true" : "false")" />
<p id="entry-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">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.</p>
<AmountInput Id="entry-amount" @bind-Value="amountText" />
<p class="text-muted">Betrag ohne Minuszeichen.</p>
</div>
</div>
@@ -50,13 +51,6 @@
}
</fieldset>
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
@if (saveError != null)
{
<div class="alert alert-danger" role="alert">@saveError
<details><summary>Technische Details</summary>@saveDetails</details>
</div>
}
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
@@ -73,6 +73,7 @@ public partial class AddEntryDialog
private async Task Save()
{
if (isSaving || completed || (committedResult == null && !CanSave)) return;
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
isSaving = true;
saveError = null;
saveDetails = null;
@@ -98,8 +99,9 @@ public partial class AddEntryDialog
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
saveDetails = ex.Message;
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
}
finally { isSaving = false; }
finally { isSaving = false; Toasts.Dismiss(busyToast); }
}
private async Task Cancel()
@@ -1,3 +1,4 @@
@inject ToastService Toasts
@inject IEntryService EntryService
@inject IAccountService AccountService
@@ -26,8 +27,8 @@
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
<label class="form-label">Betrag (€)</label>
<div><input id="transfer-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" aria-describedby="transfer-amount-help" />
<p id="transfer-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0; Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen; keine Rundung.</p></div>
<div><AmountInput Id="transfer-amount" @bind-Value="amountText" />
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
</div>
@if (OutsideYear)
@@ -39,13 +40,6 @@
}
</fieldset>
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
@if (saveError != null)
{
<div class="alert alert-danger" role="alert">@saveError
<details><summary>Technische Details</summary>@saveDetails</details>
</div>
}
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
@@ -81,6 +81,7 @@ public partial class AddTransferDialog
private async Task Save()
{
if (isSaving || completed || (committedResult == null && !CanSave)) return;
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
isSaving = true;
saveError = null;
saveDetails = null;
@@ -106,8 +107,9 @@ public partial class AddTransferDialog
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
saveDetails = ex.Message;
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
}
finally { isSaving = false; }
finally { isSaving = false; Toasts.Dismiss(busyToast); }
}
private async Task Cancel()
@@ -1,5 +1,7 @@
@inherits LayoutComponentBase
<ToastHost />
<div class="d-flex flex-column vh-100">
<nav class="app-navbar d-flex justify-content-between align-items-center">
<a class="navbar-brand" href="">
@@ -1,40 +1,19 @@
@inject ToastService Toasts
@inject IFileSaveService FileSaveService
@if (Result != null)
{
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
<p>@Result.Message</p>
@if (Result.Path != null)
{
<p class="text-break">@Result.Path</p>
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
}
@if (Result.Details != null)
{
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
}
@if (openResult != null)
{
<p role="alert">@openResult.Message</p>
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
}
</div>
}
@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);
}
}
@@ -0,0 +1,38 @@
@implements IDisposable
@inject ToastService Toasts
<div class="toast-stack" aria-label="Meldungen">
@foreach (var toast in Toasts.Messages)
{
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
role="@(toast.Kind == ToastKind.Error ? "alert" : "status")" aria-live="@(toast.Kind == ToastKind.Error ? "assertive" : "polite")" aria-atomic="true"
@onmouseenter="() => Toasts.Pause(toast.Id)" @onmouseleave="() => Toasts.Resume(toast.Id)"
@onfocusin="() => Toasts.Pause(toast.Id)" @onfocusout="() => Toasts.Resume(toast.Id)">
<button type="button" class="toast-close" aria-label="Meldung schließen" @onclick="() => Toasts.Dismiss(toast.Id)">×</button>
<p>@toast.Message</p>
@if (toast.Action != null)
{
<button type="button" class="btn btn-outline-secondary" @onclick="() => RunAction(toast)">@toast.ActionLabel</button>
}
@if (toast.Details != null)
{
<details><summary>Technische Details</summary><p class="text-break">@toast.Details</p></details>
}
</section>
}
</div>
@code {
private readonly HashSet<Guid> 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;
}
@@ -1,3 +1,4 @@
@inject ToastService Toasts
@inject ReportingPeriod Period
@page "/accounts/{AccountId:int}"
@inject IAccountService AccountService
@@ -9,21 +10,7 @@
<div class="container-fluid">
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
@if (lastSaved != null)
{
<div class="alert alert-success" role="status" aria-live="polite">
@lastSaved.Kind über @FormatAmount(lastSaved.Amount) @(lastSaved.Edited ? "aktualisiert" : "gespeichert").
</div>
}
@if (savedOutsideYear.HasValue)
{
<div class="alert alert-info" role="status">
Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht.
<button class="btn btn-outline-primary" @onclick="ShowSavedYear">Buchung im Jahr @savedOutsideYear anzeigen</button>
</div>
}
@if (account == null)
{
<div class="text-center py-5">
@@ -41,21 +28,21 @@
</div>
</div>
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
<div class="action-toolbar">
<button class="btn btn-secondary btn-nav" @onclick="NavigateBack">
<i class="bi bi-arrow-left"></i> Zurück
<i class="bi bi-arrow-left"></i><span>Zurück</span>
</button>
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
<i class="bi bi-plus-lg"></i> Einnahme oder Ausgabe erfassen
<i class="bi bi-plus-lg"></i> <span>Einnahme oder<br /> Ausgabe erfassen</span>
</button>
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
<i class="bi bi-arrow-left-right"></i> Geld zwischen Konten verschieben
<i class="bi bi-arrow-left-right"></i> <span>Geld zwischen<br /> Konten verschieben</span>
</button>
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
</button>
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> @GetFilterLabel()
<i class="bi bi-funnel"></i><span>@GetFilterLabel()</span>
</button>
</div>
@@ -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()
+8 -8
View File
@@ -1,3 +1,4 @@
@inject ToastService Toasts
@inject ReportingPeriod Period
@page "/"
@inject IAccountService AccountService
@@ -10,7 +11,6 @@
<div class="container-fluid">
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
<div class="d-flex justify-content-between align-items-center">
@@ -24,25 +24,25 @@
</div>
</div>
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
<div class="action-toolbar">
<button class="btn-nav btn-primary" @onclick="() => showAddAccount = true">
<i class="bi bi-plus-lg"></i> Neues Konto
<i class="bi bi-plus-lg"></i><span>Neues Konto</span>
</button>
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
<i class="bi bi-save"></i> Sicherung erstellen
<i class="bi bi-save"></i> <span>Sicherung<br /> erstellen</span>
</button>
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
</button>
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
<i class="bi bi-funnel"></i> @GetFilterLabel()
<i class="bi bi-funnel"></i><span>@GetFilterLabel()</span>
</button>
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
<i class="@ThemeButtonIconClass"></i><span>Modus<br />@ThemeButtonLabel</span>
</button>
<details class="more-actions">
<summary class="btn btn-outline-secondary">Weitere Aktionen</summary>
<summary class="btn btn-nav btn-outline-secondary"><i class="bi bi-three-dots"></i><span>Weitere<br /> Aktionen</span></summary>
<button class="btn btn-warning mt-2" @onclick="HandleRestoreAsync" disabled="@fileBusy">
<i class="bi bi-arrow-counterclockwise"></i> Sicherung zurückspielen
</button>
+8 -4
View File
@@ -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()
@@ -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;
@@ -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<Task>? Action);
public sealed class ToastService : IDisposable
{
private readonly object sync = new();
private readonly Dictionary<Guid, (ToastMessage Message, Timer? Timer, TimeSpan? Remaining, DateTimeOffset Started)> items = [];
public event Action? Changed;
public IReadOnlyList<ToastMessage> 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<Task>? 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(); }
}
}
+14 -6
View File
@@ -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<Duempelkas.App.Components.App>("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);
@@ -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; }
@@ -126,6 +126,7 @@
<a class="dismiss">×</a>
</div>
<script src="js/app.js"></script>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>
+19
View File
@@ -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();
});
})();
@@ -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<IFileDialogService, FileDialogService>();
services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<ISettingsService>(new SettingsService(settingsDirectory));
services.AddSingleton<ReportingPeriod>();
services.AddSingleton<ToastService>();
return services;
}
@@ -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<AppSettings> LoadAsync()
private async Task<AppSettings> 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);
+3 -1
View File
@@ -15,6 +15,7 @@ public class BookingFormTests
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
{
using var context = new BunitContext();
context.Services.AddSingleton<ToastService>();
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var dialog = context.Render<AddEntryDialog>(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<ToastService>();
context.Services.AddSingleton<IEntryService>(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<AddEntryDialog>(p => p.Add(x => x.EditEntry, entry));
+3 -3
View File
@@ -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<ToastService>().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);
+43
View File
@@ -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<ToastService>();
var host = context.Render<ToastHost>();
var service = context.Services.GetRequiredService<ToastService>();
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<AmountInput>(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);
}
}