feat(help): add offline guidance and resumable first-use setup (U14)

This commit is contained in:
2026-11-02 19:35:28 +01:00
parent 9f8c4b7af1
commit be3eb6b674
8 changed files with 184 additions and 0 deletions
+4
View File
@@ -85,3 +85,7 @@ Neue Konten erhalten einen erklärten Anfangsbestand ohne künstliche Einnahme.
Grundschrift 16 px, beschriftete Textgrößenwahl bis 200 % (lokal gemerkt), keine kleinen Großbuchstaben in Formular/Tabellenkopf. Lange Namen umbrechen, Dialoge passen ins Fenster und scrollen; bei schmaler Ansicht einspaltige Formulare und Buchungskarten. Licht-/Dunkelrollen für Text und Beträge getrennt; Gelöscht bleibt zusätzlich als Text erkennbar. Keine Änderung von Windows-DPI-Einstellungen und keine daraus abgeleitete vollständige Barrierefreiheitszusage. Kontrastmessung und Layout-Stichproben werden im Abschluss ergänzt. Grundschrift 16 px, beschriftete Textgrößenwahl bis 200 % (lokal gemerkt), keine kleinen Großbuchstaben in Formular/Tabellenkopf. Lange Namen umbrechen, Dialoge passen ins Fenster und scrollen; bei schmaler Ansicht einspaltige Formulare und Buchungskarten. Licht-/Dunkelrollen für Text und Beträge getrennt; Gelöscht bleibt zusätzlich als Text erkennbar. Keine Änderung von Windows-DPI-Einstellungen und keine daraus abgeleitete vollständige Barrierefreiheitszusage. Kontrastmessung und Layout-Stichproben werden im Abschluss ergänzt.
U13-Kontrastmessung: Text-, Hilfstext-, Einnahme-, Ausgabe- und Umbuchungsfarben gegen Hintergrund/Oberfläche jeweils mindestens 5.91:1 (hell) und 8.37:1 (dunkel); beschriftete primäre/Erfassen-/Umbuchungs-/Löschbuttons mindestens 6.47:1. Dies sind berechnete Rollen-Stichproben, keine vollständige Messung sämtlicher Zustände. U13-Kontrastmessung: Text-, Hilfstext-, Einnahme-, Ausgabe- und Umbuchungsfarben gegen Hintergrund/Oberfläche jeweils mindestens 5.91:1 (hell) und 8.37:1 (dunkel); beschriftete primäre/Erfassen-/Umbuchungs-/Löschbuttons mindestens 6.47:1. Dies sind berechnete Rollen-Stichproben, keine vollständige Messung sämtlicher Zustände.
## U14 – Lokale Hilfe und Einführung
Hilfe steht in der Navigation und wird als Teil der Anwendung offline ausgeliefert. Sie beschreibt die aktuellen Abläufe einschließlich Anfangsbestand, Undo, Suche, Sicherungsumfang und Fehlerauswegen. Überspringbare Einführung mit Vereinsname, erstem Konto/erklärtem Anfangsbestand, echter erster Buchung und Sicherung; Fortschritt wird in den lokalen Einstellungen gespeichert, keine Registrierung und keine automatischen Beispielbuchungen. Der Integrationstest überspringt/öffnet erneut, legt ein selbst benanntes Konto mit Anfangsbestand an und bestätigt die leere Buchungsliste.
@@ -9,6 +9,7 @@
<strong>Dümpelkas</strong> <small class="text-muted">Kassenbuch</small> <strong>Dümpelkas</strong> <small class="text-muted">Kassenbuch</small>
</a> </a>
<div class="reading-controls"> <div class="reading-controls">
<a class="btn btn-outline-secondary" href="hilfe">Hilfe</a>
<label for="text-scale">Textgröße</label> <label for="text-scale">Textgröße</label>
<select id="text-scale" @bind="fontScale" @bind:after="ApplyTextScale"> <select id="text-scale" @bind="fontScale" @bind:after="ApplyTextScale">
<option value="100">Normal</option><option value="125">Groß (125 %)</option> <option value="100">Normal</option><option value="125">Groß (125 %)</option>
+1
View File
@@ -71,6 +71,7 @@
<div class="text-center py-5 text-muted"> <div class="text-center py-5 text-muted">
<h4>Noch keine Konten vorhanden</h4> <h4>Noch keine Konten vorhanden</h4>
<p>Erstelle dein erstes Konto, um loszulegen.</p> <p>Erstelle dein erstes Konto, um loszulegen.</p>
<a class="btn btn-primary" href="einstieg">Kurze Einführung starten oder fortsetzen</a>
</div> </div>
} }
else else
@@ -0,0 +1,96 @@
@page "/einstieg"
@inject ISettingsService Settings
@inject IAccountService Accounts
@inject IBackupService Backups
@inject ToastService Toasts
@inject NavigationManager Navigation
<h1>Erste Schritte</h1>
<p>Schritt @(step + 1) von 5. Du kannst die Einführung jederzeit überspringen und über Hilfe fortsetzen.</p>
@if (loading) { <p role="status">Einführung wird geladen …</p> }
else
{
@if (step == 0)
{
<h2>Dein Verein</h2>
<label for="setup-club">Vereinsname</label><input id="setup-club" class="form-control" @bind="clubName" @bind:event="oninput" />
<button class="btn btn-primary setup-next" disabled="@(busy || string.IsNullOrWhiteSpace(clubName))" @onclick="SaveClub">Speichern und weiter</button>
}
else if (step == 1)
{
<h2>Dein erstes Konto</h2>
<p>Zum Beispiel Barkasse oder Girokonto. Der Anfangsbestand ist das bereits vorhandene Geld, keine neue Einnahme.</p>
<button class="btn btn-primary" @onclick="() => showAccount = true">Konto anlegen</button>
@if (accounts.Count > 0)
{
<p>Ein Konto ist bereits eingerichtet.</p><button class="btn btn-outline-secondary setup-next" @onclick="Next">Vorhandenes Konto verwenden</button>
}
}
else if (step == 2)
{
<h2>Die erste Buchung</h2>
<p>Wähle ein Konto und erfasse einen tatsächlichen Mitgliedsbeitrag oder eine Ausgabe, etwa Porto. Es werden keine Beispielbuchungen erzeugt.</p>
@foreach (var account in accounts) { <p><a href="@($"accounts/{account.Id}")">@account.Name öffnen und buchen</a></p> }
<button class="btn btn-primary setup-next" @onclick="Next">Weiter zur Sicherung</button>
}
else if (step == 3)
{
<h2>Deine Daten sichern</h2>
<p>Sichere die Buchungen zusätzlich auf einem unabhängigen Datenträger. Vereinsname und Darstellung sind nicht enthalten. Zur Einrichtung muss keine Sicherung zurückgespielt werden.</p>
<button class="btn btn-primary" disabled="@busy" @onclick="Backup">Sicherung erstellen</button>
<OperationNotice Result="backupResult" />
<button class="btn btn-outline-secondary setup-next" @onclick="Next">Weiter</button>
}
else
{
<h2>Bereit für das Kassenbuch</h2><p>Die Hilfe bleibt jederzeit erreichbar. Prüfe regelmäßig Kontostände und Sicherungen.</p>
<a class="btn btn-primary" href="/">Zur Kontenübersicht</a>
<button class="btn btn-outline-secondary" @onclick="Restart">Einführung erneut durchgehen</button>
}
<p><button class="btn btn-outline-secondary" disabled="@busy" @onclick="Skip">Einführung später fortsetzen</button> <a href="hilfe">Zur Hilfe</a></p>
}
@if (showAccount)
{
<AddAccountDialog OnSave="CreateAccount" OnCancel="() => showAccount = false" />
}
@code {
private int step;
private bool loading = true, busy, showAccount;
private string clubName = string.Empty;
private List<AccountSummaryDto> accounts = [];
private FileOperationResult? backupResult;
protected override async Task OnInitializedAsync()
{
step = Math.Clamp(await Settings.GetSetupStepAsync(), 0, 4);
clubName = await Settings.GetClubNameAsync() ?? string.Empty;
accounts = await Accounts.GetAllAccountsAsync();
loading = false;
}
private async Task Next() { step = Math.Min(step + 1, 4); await Settings.SetSetupStepAsync(step); }
private async Task SaveClub()
{
if (busy || string.IsNullOrWhiteSpace(clubName)) return;
busy = true;
try { await Settings.SetClubNameAsync(clubName); await Next(); }
catch (Exception ex) { Toasts.Show("Der Schritt konnte nicht gespeichert werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
finally { busy = false; }
}
private async Task CreateAccount(AccountSetup setup)
{
await Accounts.CreateAccountAsync(setup.Name, setup.InitialBalance);
showAccount = false;
accounts = await Accounts.GetAllAccountsAsync();
await Next();
}
private async Task Backup()
{
if (busy) return;
busy = true;
try { backupResult = await Backups.CreateBackupAsync(); }
catch (Exception ex) { backupResult = FileOperationResult.Error(ex); }
finally { busy = false; }
}
private Task Skip() { Navigation.NavigateTo("/"); return Task.CompletedTask; }
private async Task Restart() { step = 0; await Settings.SetSetupStepAsync(0); }
}
+35
View File
@@ -0,0 +1,35 @@
@page "/hilfe"
<h1>Hilfe für Dümpelkas</h1>
<p>Passend zu dieser Programmversion. Diese Hilfe ist auch ohne Internet verfügbar.</p>
<p><a class="btn btn-primary" href="einstieg">Erste Schritte öffnen oder fortsetzen</a> <a href="/">Zur Kontenübersicht</a></p>
<details open><summary>Konten einrichten</summary>
<p>Auf der Übersicht „Neues Konto“ wählen. Beispiele: Barkasse, Girokonto, Sparbuch. Den tatsächlich vorhandenen Anfangsbestand vor der ersten erfassten Buchung eingeben; 0 oder ein negativer Bestand sind möglich. Es werden keine Beispielbuchungen angelegt.</p>
</details>
<details><summary>Einnahme oder Ausgabe erfassen</summary>
<p>Konto öffnen, „Einnahme oder Ausgabe erfassen“ wählen, Art, echtes Datum, Beschreibung und Betrag eingeben. Beispiele sind Mitgliedsbeitrag und Porto. Komma und Punkt sind als Dezimaltrenner möglich; das Control verhindert zusätzliche Dezimalstellen und übernimmt ungültige Einfügungen nicht. Beträge ohne Minuszeichen erfassen; die Art legt die Richtung fest.</p>
<p>Beim Speichern kurz warten. Ein Toast bestätigt Art und Betrag. Bei Fehlern bleiben Eingaben erhalten. Beim Bearbeiten bleibt die Art fest; eine falsche Art durch Löschen und erneutes Erfassen korrigieren.</p>
</details>
<details><summary>Jahr, Kontostand und Anfangsbestand verstehen</summary>
<p>„Jahr“ begrenzt Liste und Summen; „Alle Jahre“ zeigt den gesamten Zeitraum. Die Auswahl bleibt beim Navigieren erhalten. Ein abweichendes Buchungsdatum bleibt unverändert und wird vor dem Speichern erklärt. Die Meldung bietet den Aufruf des gespeicherten Jahres.</p>
<p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand. Der Übertrag ist der Bestand vor Beginn des gewählten Jahres. Spätere Bestandskorrekturen ändern den gemeinsamen Anfangsbestand und damit auch frühere und spätere Jahresstände. Vorher/nachher prüfen und erst dann bestätigen; eine fehlende Buchung gewöhnlich als Buchung erfassen.</p>
</details>
<details><summary>Geld zwischen Konten verschieben</summary>
<p>Die Richtung wird immer als Von → Nach gezeigt, auch beim Öffnen der empfangenen Seite. Das aktuelle Konto ist fest, das Gegenkonto änderbar. Beispiel: Barkasse −50,00 €, Girokonto +50,00 €; die Vereinssumme bleibt gleich. Es wird nur die Buchung erfasst, keine Banküberweisung ausgeführt.</p>
</details>
<details><summary>Bearbeiten, Löschen und Wiederherstellen</summary>
<p>Das Aktionsmenü der richtigen Zeile öffnen. Bestätigungen nennen Datum, Betrag und Konto; bei Umbuchungen wirken Löschen und Wiederherstellen gemeinsam auf beide Konten. Gelöschte Zeilen sind zunächst ausgeblendet und zählen nicht zum Kontostand. „Gelöschte Buchungen anzeigen“ öffnet die Historie. Der Erfolgstoast bietet für die letzte Änderung „Rückgängig“.</p>
</details>
<details><summary>Buchungen finden</summary>
<p>Jahr auswählen und die Suche über der Liste verwenden. Sie findet Beschreibung und Buchungsnummer, zusätzlich Datum und Betrag. Die Sortierung kann geändert werden. „Filter zurücksetzen“ zeigt die vollständige Liste des gewählten Jahres. Der Kontostand bleibt unverändert; eine Treffermengensumme ist gesondert beschriftet.</p>
</details>
<details><summary>Als PDF speichern</summary>
<p>Auf der Kontoansicht wird ein Einzelkonto, auf der Übersicht werden alle Konten exportiert. Der gewählte Zeitraum wird übernommen. Normale PDFs enthalten nur wirksame Buchungen. Umbuchungen sind im Einzelkonto Zu-/Abgänge und in der Gesamtübersicht eine Zeile mit beiden Konten. Der Toast nennt den Speicherort und bietet „Ordner öffnen“.</p>
</details>
<details><summary>Sichern und zurückspielen</summary>
<p>„Sicherung erstellen“ speichert Konten und Buchungen einschließlich Historie. Vereinsname, Textgröße, Farbdarstellung und Fenstergröße sind nicht enthalten. Eine Kopie auf derselben Festplatte schützt nicht vor Geräteausfall; eine weitere Kopie auf einem unabhängigen Datenträger aufbewahren.</p>
<p>„Weitere Aktionen → Sicherung zurückspielen“ ersetzt die aktuellen Konten und Buchungen. Nur passende geprüfte Sicherungen werden angenommen. Vorher legt Dümpelkas eine Rückfallsicherung neben der aktiven Datenbank an; diese wird nicht automatisch gelöscht. Eine alte Sicherung enthält spätere Buchungen nicht. Rückspielen nicht zum Ausprobieren auf dem Vereinsbestand verwenden.</p>
</details>
<details><summary>Tastatur, Darstellung und Fehler</summary>
<p>Tab und Umschalt-Tab wechseln zwischen Bedienelementen. Enter öffnet Links oder speichert passende Formulare. Escape schließt Dialoge; geänderte Eingaben werden vorher abgefangen. Unsichere Bestätigungen beginnen auf Abbrechen.</p>
<p>Textgröße steht oben, der Farbmodus in der Übersicht. Meldungen erscheinen oben, verschwinden zeitgesteuert und pausieren bei Maus/Fokus. Bei erwartbaren Speicherfehlern bleibt die Eingabe erhalten. Technische Details sind bei Bedarf aufklappbar. Ein unerwarteter Fehler bestätigt keine Speicherung: vor einem erneuten Eintrag zuerst die Liste prüfen.</p>
</details>
@@ -2,6 +2,8 @@ namespace Duempelkas.App.Services;
public interface ISettingsService public interface ISettingsService
{ {
Task<int> GetSetupStepAsync() => Task.FromResult(0);
Task SetSetupStepAsync(int step) => Task.CompletedTask;
Task<string?> GetClubNameAsync(); Task<string?> GetClubNameAsync();
Task SetClubNameAsync(string? clubName); Task SetClubNameAsync(string? clubName);
} }
@@ -25,6 +25,14 @@ public class SettingsService : ISettingsService
await SaveAsync(settings); await SaveAsync(settings);
} }
public async Task<int> GetSetupStepAsync() => (await LoadAsync()).SetupStep;
public async Task SetSetupStepAsync(int step)
{
var settings = await LoadAsync();
settings.SetupStep = Math.Clamp(step, 0, 4);
await SaveAsync(settings);
}
private async Task<AppSettings> LoadAsync() private async Task<AppSettings> LoadAsync()
{ {
if (!File.Exists(SettingsPath)) if (!File.Exists(SettingsPath))
@@ -52,5 +60,6 @@ public class SettingsService : ISettingsService
private class AppSettings private class AppSettings
{ {
public string? ClubName { get; set; } public string? ClubName { get; set; }
public int SetupStep { get; set; }
} }
} }
@@ -0,0 +1,36 @@
using Bunit;
using Duempelkas.App.Components;
using Duempelkas.App.Services;
using Microsoft.AspNetCore.Components;
using Xunit;
namespace Duempelkas.Tests;
public class GettingStartedTests
{
[Fact]
public async Task Introduction_CanBeSkippedAndResumed_AndCreatesNoSampleBookings()
{
using var f = new LedgerUiFixture(false);
var navigation = f.Service<NavigationManager>();
navigation.NavigateTo("/einstieg");
var page = f.Context.Render<Routes>();
page.WaitForElement("#setup-club").Input("Mein Testverein");
page.Find(".setup-next").Click();
page.WaitForAssertion(() => Assert.Contains("Dein erstes Konto", page.Markup));
page.FindAll("button").Single(b => b.TextContent.Contains("später fortsetzen")).Click();
navigation.NavigateTo("/einstieg");
page.WaitForAssertion(() => Assert.Contains("Dein erstes Konto", page.Markup));
page.FindAll("button").Single(b => b.TextContent.Trim() == "Konto anlegen").Click();
page.Find("#account-name").Input("Barkasse");
page.Find("#account-initial").Input("25,00");
page.Find("dialog form").Submit();
page.WaitForAssertion(() => Assert.Contains("Die erste Buchung", page.Markup));
var account = Assert.Single(await f.Service<IAccountService>().GetAllAccountsAsync());
Assert.Equal(25m, account.TotalBalance);
Assert.Empty(await f.Service<IEntryService>().GetEntriesAsync(account.Id, (int?)null));
Assert.Equal(2, await f.Service<ISettingsService>().GetSetupStepAsync());
navigation.NavigateTo("/hilfe");
page.WaitForAssertion(() => Assert.Contains("auch ohne Internet", page.Markup));
}
}