diff --git a/Flashcards.Hillgrove/.csharpierrc b/Flashcards.Hillgrove/.csharpierrc
new file mode 100644
index 00000000..234ca19a
--- /dev/null
+++ b/Flashcards.Hillgrove/.csharpierrc
@@ -0,0 +1,3 @@
+{
+ "printWidth": 100
+}
\ No newline at end of file
diff --git a/Flashcards.Hillgrove/.gitignore b/Flashcards.Hillgrove/.gitignore
new file mode 100644
index 00000000..0808c4ad
--- /dev/null
+++ b/Flashcards.Hillgrove/.gitignore
@@ -0,0 +1,482 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Flashcards.Hillgrove.Tests.csproj b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Flashcards.Hillgrove.Tests.csproj
new file mode 100644
index 00000000..93a5c746
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Flashcards.Hillgrove.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net8.0
+ false
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/GlobalUsings.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/GlobalUsings.cs
new file mode 100644
index 00000000..c802f448
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/Flashcards/DeleteFlashcardActionTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/Flashcards/DeleteFlashcardActionTests.cs
new file mode 100644
index 00000000..d58b8e2f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/Flashcards/DeleteFlashcardActionTests.cs
@@ -0,0 +1,139 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Dtos;
+using Flashcards.Hillgrove.Menu;
+using Flashcards.Hillgrove.Menu.Actions.Flashcards;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Tests.Menu.Actions.Flashcards;
+
+public class DeleteFlashcardActionTests
+{
+ [Fact]
+ public async Task ExecuteAsync_DeletesCardByMappedDisplayIndex_WhenSelectionIsValid()
+ {
+ var repository = new FakeFlashcardRepository(
+ [
+ new Flashcard { Id = 11, StackId = 3, Question = "Q1", Answer = "A1" },
+ new Flashcard { Id = 25, StackId = 3, Question = "Q2", Answer = "A2" },
+ ]
+ );
+ var ui = new FakeAppUi(promptInputs: ["2"], confirmResults: [true]);
+ var action = new DeleteFlashcardAction(repository, ui);
+
+ await action.ExecuteAsync(new Stack { Id = 3, Name = "Math" });
+
+ Assert.Equal([25], repository.DeletedIds);
+ Assert.Equal(2, ui.ShownTables.Count);
+ Assert.Equal([1, 2], ui.ShownTables[0].Select(c => c.DisplayIndex).ToArray());
+ Assert.Equal([1], ui.ShownTables[1].Select(c => c.DisplayIndex).ToArray());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_DoesNotDelete_WhenSelectedDisplayIndexIsInvalid()
+ {
+ var repository = new FakeFlashcardRepository(
+ [new Flashcard { Id = 11, StackId = 3, Question = "Q1", Answer = "A1" }]
+ );
+ var ui = new FakeAppUi(promptInputs: ["9", ""], confirmResults: []);
+ var action = new DeleteFlashcardAction(repository, ui);
+
+ await action.ExecuteAsync(new Stack { Id = 3, Name = "Math" });
+
+ Assert.Empty(repository.DeletedIds);
+ Assert.Contains(ui.ErrorMessages, message => message.Contains("Invalid flashcard number."));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShowsWarning_WhenStackHasNoCards()
+ {
+ var repository = new FakeFlashcardRepository([]);
+ var ui = new FakeAppUi(promptInputs: [], confirmResults: []);
+ var action = new DeleteFlashcardAction(repository, ui);
+
+ await action.ExecuteAsync(new Stack { Id = 3, Name = "Math" });
+
+ Assert.Empty(repository.DeletedIds);
+ Assert.Contains(ui.WarningMessages, message => message.Contains("There are no flashcards"));
+ Assert.Equal(1, ui.WaitForKeyCalls);
+ }
+
+ private sealed class FakeFlashcardRepository(IEnumerable cards) : IFlashcardRepository
+ {
+ private readonly List _cards = cards.ToList();
+
+ public List DeletedIds { get; } = [];
+
+ public Task> GetByStackIdAsync(int stackId)
+ {
+ return Task.FromResult(_cards.Where(card => card.StackId == stackId).AsEnumerable());
+ }
+
+ public Task AddAsync(Flashcard card)
+ {
+ _cards.Add(card);
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteAsync(int id)
+ {
+ DeletedIds.Add(id);
+ _cards.RemoveAll(card => card.Id == id);
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class FakeAppUi(IEnumerable promptInputs, IEnumerable confirmResults)
+ : IAppUi
+ {
+ private readonly Queue _promptInputs = new(promptInputs);
+ private readonly Queue _confirmResults = new(confirmResults);
+
+ public List WarningMessages { get; } = [];
+ public List ErrorMessages { get; } = [];
+ public List> ShownTables { get; } = [];
+ public int WaitForKeyCalls { get; private set; }
+
+ public void Clear() { }
+
+ public void WriteSuccess(string message) { }
+
+ public void WriteWarning(string message)
+ {
+ WarningMessages.Add(message);
+ }
+
+ public void WriteError(string message)
+ {
+ ErrorMessages.Add(message);
+ }
+
+ public void WaitForKey(string message = "[grey]Press any key to continue...[/]")
+ {
+ WaitForKeyCalls++;
+ }
+
+ public string PromptText(string prompt, bool allowEmpty = false)
+ {
+ return _promptInputs.Count > 0 ? _promptInputs.Dequeue() : string.Empty;
+ }
+
+ public bool Confirm(string prompt)
+ {
+ return _confirmResults.Count > 0 && _confirmResults.Dequeue();
+ }
+
+ public Stack PromptStackSelection(string title, IReadOnlyList stacks)
+ {
+ return stacks.First();
+ }
+
+ public void ShowFlashcardsTable(string title, IEnumerable cards)
+ {
+ ShownTables.Add(cards.ToList());
+ }
+
+ public void ShowStudySessionsTable(string title, IEnumerable sessions) { }
+
+ public void ShowStackReportTable(string title, IEnumerable rows) { }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/StartStudySessionActionTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/StartStudySessionActionTests.cs
new file mode 100644
index 00000000..5d8570c7
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/StartStudySessionActionTests.cs
@@ -0,0 +1,39 @@
+using Flashcards.Hillgrove.Menu.Actions.StudySessions;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Tests.Menu.Actions.StudySessions;
+
+public class StartStudySessionActionTests
+{
+ [Fact]
+ public async Task ExecuteAsync_DelegatesToStudySessionService_WithSelectedStack()
+ {
+ var service = new FakeStudySessionService();
+ var action = new StartStudySessionAction(service);
+ var stack = new Stack { Id = 12, Name = "Math" };
+
+ await action.ExecuteAsync(stack);
+
+ Assert.Same(stack, service.RunStack);
+ Assert.Equal(1, service.RunCalls);
+ }
+
+ private sealed class FakeStudySessionService : IStudySessionService
+ {
+ public int RunCalls { get; private set; }
+ public Stack? RunStack { get; private set; }
+
+ public Task RunAsync(Stack stack)
+ {
+ RunCalls++;
+ RunStack = stack;
+ return Task.CompletedTask;
+ }
+
+ public Task> GetHistoryAsync()
+ {
+ return Task.FromResult>([]);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/ViewStudySessionHistoryActionTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/ViewStudySessionHistoryActionTests.cs
new file mode 100644
index 00000000..994b42f7
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/ViewStudySessionHistoryActionTests.cs
@@ -0,0 +1,95 @@
+using Flashcards.Hillgrove.Dtos;
+using Flashcards.Hillgrove.Menu;
+using Flashcards.Hillgrove.Menu.Actions.StudySessions;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Tests.Menu.Actions.StudySessions;
+
+public class ViewStudySessionHistoryActionTests
+{
+ [Fact]
+ public async Task ExecuteAsync_ShowsHistoryTable_WhenSessionsExist()
+ {
+ var sessions = new[]
+ {
+ new StudySession { Id = 1, StackId = 3, Date = DateTime.UtcNow, Score = 2 },
+ new StudySession { Id = 2, StackId = 4, Date = DateTime.UtcNow.AddDays(-1), Score = 4 },
+ };
+ var service = new FakeStudySessionService(sessions);
+ var ui = new FakeAppUi();
+ var action = new ViewStudySessionHistoryAction(service, ui);
+
+ await action.ExecuteAsync();
+
+ Assert.True(ui.HistoryShown);
+ Assert.Equal(2, ui.ShownSessions.Count);
+ Assert.Equal(1, ui.WaitForKeyCalls);
+ Assert.Empty(ui.WarningMessages);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShowsWarning_WhenNoSessionsExist()
+ {
+ var service = new FakeStudySessionService([]);
+ var ui = new FakeAppUi();
+ var action = new ViewStudySessionHistoryAction(service, ui);
+
+ await action.ExecuteAsync();
+
+ Assert.False(ui.HistoryShown);
+ Assert.Contains(ui.WarningMessages, message => message.Contains("There are no study sessions"));
+ Assert.Equal(1, ui.WaitForKeyCalls);
+ }
+
+ private sealed class FakeStudySessionService(IEnumerable sessions) : IStudySessionService
+ {
+ public Task RunAsync(Stack stack) => Task.CompletedTask;
+
+ public Task> GetHistoryAsync()
+ {
+ return Task.FromResult>(sessions.ToList());
+ }
+ }
+
+ private sealed class FakeAppUi : IAppUi
+ {
+ public bool HistoryShown { get; private set; }
+ public List ShownSessions { get; } = [];
+ public List WarningMessages { get; } = [];
+ public int WaitForKeyCalls { get; private set; }
+
+ public void Clear() { }
+
+ public void WriteSuccess(string message) { }
+
+ public void WriteWarning(string message)
+ {
+ WarningMessages.Add(message);
+ }
+
+ public void WriteError(string message) { }
+
+ public void WaitForKey(string message = "[grey]Press any key to continue...[/]")
+ {
+ WaitForKeyCalls++;
+ }
+
+ public string PromptText(string prompt, bool allowEmpty = false) => string.Empty;
+
+ public bool Confirm(string prompt) => false;
+
+ public Stack PromptStackSelection(string title, IReadOnlyList stacks) =>
+ stacks.FirstOrDefault() ?? new Stack { Name = string.Empty };
+
+ public void ShowFlashcardsTable(string title, IEnumerable cards) { }
+
+ public void ShowStudySessionsTable(string title, IEnumerable sessions)
+ {
+ HistoryShown = true;
+ ShownSessions.AddRange(sessions);
+ }
+
+ public void ShowStackReportTable(string title, IEnumerable rows) { }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/CompositeMenuTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/CompositeMenuTests.cs
new file mode 100644
index 00000000..8e1d0ff6
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/CompositeMenuTests.cs
@@ -0,0 +1,122 @@
+using Flashcards.Hillgrove.Helpers;
+using Flashcards.Hillgrove.Menu;
+
+namespace Flashcards.Hillgrove.Tests.Menu;
+
+public class CompositeMenuTests
+{
+ [Fact]
+ public async Task ExecuteAsync_ReturnsContinue_WhenSelectedItemReturnsBack()
+ {
+ var prompt = new FakeMenuPrompt([new FakeMenuItem("Back", NavigationResult.Back)]);
+
+ var sut = new CompositeMenu(
+ "Any Menu",
+ [new FakeMenuItem("Ignored", NavigationResult.Continue)],
+ prompt
+ );
+
+ var result = await sut.ExecuteAsync();
+
+ Assert.Equal(NavigationResult.Continue, result);
+ Assert.Equal(1, prompt.CallCount);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ReturnsExit_WhenSelectedItemReturnsExit()
+ {
+ var prompt = new FakeMenuPrompt([new FakeMenuItem("Exit", NavigationResult.Exit)]);
+
+ var sut = new CompositeMenu(
+ "Any Menu",
+ [new FakeMenuItem("Ignored", NavigationResult.Continue)],
+ prompt
+ );
+
+ var result = await sut.ExecuteAsync();
+
+ Assert.Equal(NavigationResult.Exit, result);
+ Assert.Equal(1, prompt.CallCount);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_KeepsLoopingUntilExit_WhenSelectedItemsReturnContinueThenExit()
+ {
+ var prompt = new FakeMenuPrompt([
+ new FakeMenuItem("Continue", NavigationResult.Continue),
+ new FakeMenuItem("Exit", NavigationResult.Exit),
+ ]);
+
+ var sut = new CompositeMenu(
+ "Any Menu",
+ [new FakeMenuItem("Ignored", NavigationResult.Continue)],
+ prompt
+ );
+
+ var result = await sut.ExecuteAsync();
+
+ Assert.Equal(NavigationResult.Exit, result);
+ Assert.Equal(2, prompt.CallCount);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ReturnsContinue_WhenPromptReturnsNullChoice()
+ {
+ var prompt = new FakeMenuPrompt([null]);
+
+ var sut = new CompositeMenu(
+ "Any Menu",
+ [new FakeMenuItem("Ignored", NavigationResult.Continue)],
+ prompt
+ );
+
+ var result = await sut.ExecuteAsync();
+
+ Assert.Equal(NavigationResult.Continue, result);
+ Assert.Equal(1, prompt.CallCount);
+ }
+
+ private sealed class FakeMenuPrompt : IMenuPrompt
+ {
+ private readonly Queue _choices;
+
+ public int CallCount { get; private set; }
+
+ public FakeMenuPrompt(IEnumerable choices)
+ {
+ _choices = new Queue(choices);
+ }
+
+ public void Clear()
+ {
+ // do nothing
+ }
+
+ public Task PromptAsync(string title, IReadOnlyList items)
+ {
+ CallCount++;
+
+ if (_choices.Count == 0)
+ {
+ throw new InvalidOperationException("No more queued choices in FakeMenuPrompt.");
+ }
+
+ return Task.FromResult(_choices.Dequeue());
+ }
+ }
+
+ private sealed class FakeMenuItem : IMenuItem
+ {
+ private readonly NavigationResult _result;
+
+ public string Label { get; }
+
+ public FakeMenuItem(string label, NavigationResult result)
+ {
+ Label = label;
+ _result = result;
+ }
+
+ public Task ExecuteAsync() => Task.FromResult(_result);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuComposerTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuComposerTests.cs
new file mode 100644
index 00000000..a3bf4954
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuComposerTests.cs
@@ -0,0 +1,256 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Dtos;
+using Flashcards.Hillgrove.Helpers;
+using Flashcards.Hillgrove.Menu;
+using Flashcards.Hillgrove.Menu.Composition;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Tests.Menu;
+
+public class MenuComposerTests
+{
+ [Fact]
+ public void Build_ComposesMenuFromProvidersInOrder_WhenProvidersAreRegistered()
+ {
+ var composer = new MenuComposer(
+ [
+ new FakeMainMenuSectionProvider("Second", 2),
+ new FakeMainMenuSectionProvider("First", 1),
+ ],
+ new FakeMenuPrompt()
+ );
+
+ var menu = composer.Build();
+
+ var root = Assert.IsType(menu);
+ var labels = root.Items.Select(i => i.Label).ToArray();
+
+ Assert.Equal(["First", "Second"], labels);
+ }
+
+ [Fact]
+ public void Build_IncludesNewProviderContribution_WithoutChangingComposer()
+ {
+ var composer = new MenuComposer(
+ [
+ new FakeMainMenuSectionProvider("Manage Stacks", 1),
+ new FakeMainMenuSectionProvider("Custom Reports", 2),
+ ],
+ new FakeMenuPrompt()
+ );
+
+ var menu = composer.Build();
+
+ var root = Assert.IsType(menu);
+ Assert.Contains(root.Items, i => i.Label == "Custom Reports");
+ }
+
+ [Fact]
+ public void Build_ReturnsMainMenuWithExpectedTopLevelLabels_WhenComposingDefaultProviders()
+ {
+ var menu = BuildMenuFromDefaultProviders();
+
+ var root = Assert.IsType(menu);
+ var labels = root.Items.Select(i => i.Label).ToArray();
+
+ Assert.Equal(["Manage Stacks", "Manage Flashcards", "Study Sessions", "Reports", "Exit"], labels);
+ }
+
+ [Fact]
+ public void Build_ReturnsManageStacksMenuWithExpectedItemsInOrder_WhenComposingDefaultProviders()
+ {
+ var menu = BuildMenuFromDefaultProviders();
+
+ var root = Assert.IsType(menu);
+ var manageStacks = Assert.IsType(
+ root.Items.Single(i => i.Label == "Manage Stacks")
+ );
+ var labels = manageStacks.Items.Select(i => i.Label).ToArray();
+
+ Assert.Equal(["Show All Stacks", "Create a new stack", "Delete a stack", "Back"], labels);
+ }
+
+ [Fact]
+ public void Build_ReturnsManageFlashcardsMenuWithExpectedItemsInOrder_WhenComposingDefaultProviders()
+ {
+ var menu = BuildMenuFromDefaultProviders();
+
+ var root = Assert.IsType(menu);
+ var manageFlashcards = Assert.IsType(
+ root.Items.Single(i => i.Label == "Manage Flashcards")
+ );
+ var labels = manageFlashcards.Items.Select(i => i.Label).ToArray();
+
+ Assert.Equal(
+ [
+ "Show All Stacks",
+ "Add Flashcard to Existing Stack",
+ "Delete Flashcard from Stack",
+ "Back",
+ ],
+ labels
+ );
+ }
+
+ [Fact]
+ public void Build_ReturnsStudySessionsMenuWithExpectedItemsInOrder_WhenComposingDefaultProviders()
+ {
+ var menu = BuildMenuFromDefaultProviders();
+
+ var root = Assert.IsType(menu);
+ var studySessions = Assert.IsType(
+ root.Items.Single(i => i.Label == "Study Sessions")
+ );
+ var labels = studySessions.Items.Select(i => i.Label).ToArray();
+
+ Assert.Equal(["Start Study Session", "View Study Session History", "Back"], labels);
+ }
+
+ [Fact]
+ public void Build_ReturnsReportsMenuWithExpectedItemsInOrder_WhenComposingDefaultProviders()
+ {
+ var menu = BuildMenuFromDefaultProviders();
+
+ var root = Assert.IsType(menu);
+ var reports = Assert.IsType(root.Items.Single(i => i.Label == "Reports"));
+ var labels = reports.Items.Select(i => i.Label).ToArray();
+
+ Assert.Equal(
+ [
+ "Sessions per Month per Stack",
+ "Average Score per Month per Stack",
+ "Back",
+ ],
+ labels
+ );
+ }
+
+ private static IMenuItem BuildMenuFromDefaultProviders()
+ {
+ var stackRepository = new FakeStackRepository();
+ var flashcardRepository = new FakeFlashcardRepository();
+ var stackService = new StackService(stackRepository);
+ var flashcardService = new FlashcardService(flashcardRepository);
+ var studySessionService = new FakeStudySessionService();
+ var reportService = new FakeReportService();
+ var ui = new FakeAppUi();
+ var menuPrompt = new FakeMenuPrompt();
+
+ var providers = new IMainMenuSectionProvider[]
+ {
+ new ManageStacksSectionProvider(
+ flashcardRepository,
+ stackRepository,
+ stackService,
+ flashcardService,
+ ui,
+ menuPrompt
+ ),
+ new ManageFlashcardsSectionProvider(
+ stackRepository,
+ flashcardRepository,
+ flashcardService,
+ ui,
+ menuPrompt
+ ),
+ new StudySessionsSectionProvider(stackRepository, studySessionService, ui, menuPrompt),
+ new ReportsSectionProvider(reportService, ui, menuPrompt),
+ new ExitSectionProvider(),
+ };
+
+ var composer = new MenuComposer(providers, menuPrompt);
+ return composer.Build();
+ }
+
+ private sealed class FakeStudySessionService : IStudySessionService
+ {
+ public Task RunAsync(Stack stack) => Task.CompletedTask;
+
+ public Task> GetHistoryAsync()
+ {
+ return Task.FromResult>([]);
+ }
+ }
+
+ private sealed class FakeReportService : IReportService
+ {
+ public Task> GetSessionsPerMonthPerStackAsync(int year)
+ {
+ return Task.FromResult>([]);
+ }
+
+ public Task> GetAverageScorePerMonthPerStackAsync(int year)
+ {
+ return Task.FromResult>([]);
+ }
+ }
+
+ private sealed class FakeMainMenuSectionProvider(string label, int order)
+ : IMainMenuSectionProvider
+ {
+ public int Order => order;
+
+ public IMenuItem Create() => new FakeMenuItem(label);
+ }
+
+ private sealed class FakeMenuItem(string label) : IMenuItem
+ {
+ public string Label => label;
+
+ public Task ExecuteAsync() => Task.FromResult(NavigationResult.Continue);
+ }
+
+ private sealed class FakeAppUi : IAppUi
+ {
+ public void Clear() { }
+
+ public void WriteSuccess(string message) { }
+
+ public void WriteWarning(string message) { }
+
+ public void WriteError(string message) { }
+
+ public void WaitForKey(string message = "[grey]Press any key to continue...[/]") { }
+
+ public string PromptText(string prompt, bool allowEmpty = false) => string.Empty;
+
+ public bool Confirm(string prompt) => false;
+
+ public Stack PromptStackSelection(string title, IReadOnlyList stacks) =>
+ stacks.FirstOrDefault() ?? new Stack { Name = string.Empty };
+
+ public void ShowFlashcardsTable(string title, IEnumerable cards) { }
+
+ public void ShowStudySessionsTable(string title, IEnumerable sessions) { }
+
+ public void ShowStackReportTable(string title, IEnumerable rows) { }
+ }
+
+ private sealed class FakeMenuPrompt : IMenuPrompt
+ {
+ public void Clear() { }
+
+ public Task PromptAsync(string title, IReadOnlyList items) =>
+ Task.FromResult(null);
+ }
+
+ private sealed class FakeStackRepository : IStackRepository
+ {
+ public Task> GetAllAsync() => Task.FromResult(Enumerable.Empty());
+
+ public Task AddAsync(Stack stack) => Task.FromResult(stack);
+
+ public Task DeleteAsync(int id) => Task.CompletedTask;
+ }
+
+ private sealed class FakeFlashcardRepository : IFlashcardRepository
+ {
+ public Task> GetByStackIdAsync(int stackId) =>
+ Task.FromResult(Enumerable.Empty());
+
+ public Task AddAsync(Flashcard card) => Task.CompletedTask;
+
+ public Task DeleteAsync(int id) => Task.CompletedTask;
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuItemsTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuItemsTests.cs
new file mode 100644
index 00000000..5055bcd5
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuItemsTests.cs
@@ -0,0 +1,27 @@
+using Flashcards.Hillgrove.Helpers;
+using Flashcards.Hillgrove.Menu.Items;
+
+namespace Flashcards.Hillgrove.Tests.Menu;
+
+public class MenuItemsTests
+{
+ [Fact]
+ public async Task ExecuteAsync_ReturnsBack_WhenMenuItemIsBack()
+ {
+ var item = new BackMenuItem();
+
+ var result = await item.ExecuteAsync();
+
+ Assert.Equal(NavigationResult.Back, result);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ReturnsExit_WhenMenuItemIsExit()
+ {
+ var item = new ExitMenuItem();
+
+ var result = await item.ExecuteAsync();
+
+ Assert.Equal(NavigationResult.Exit, result);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/FlashcardServiceTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/FlashcardServiceTests.cs
new file mode 100644
index 00000000..d9898d9f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/FlashcardServiceTests.cs
@@ -0,0 +1,90 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Tests.Services;
+
+public class FlashcardServiceTests
+{
+ [Fact]
+ public async Task GetByStackIdAsync_ReturnsMappedDtos_WhenRepositoryContainsCards()
+ {
+ var repository = new FakeFlashcardRepository
+ {
+ CardsByStackId = new Dictionary>
+ {
+ [5] =
+ [
+ new Flashcard { Id = 1, StackId = 5, Question = "Q1", Answer = "A1" },
+ new Flashcard { Id = 2, StackId = 5, Question = "Q2", Answer = "A2" },
+ ],
+ },
+ };
+
+ var service = new FlashcardService(repository);
+
+ var result = (await service.GetByStackIdAsync(5)).ToList();
+
+ Assert.Equal(5, repository.LastRequestedStackId);
+ Assert.Equal(2, result.Count);
+ Assert.Equal(1, result[0].DisplayIndex);
+ Assert.Equal("Q1", result[0].Question);
+ Assert.Equal("A1", result[0].Answer);
+ Assert.Equal(2, result[1].DisplayIndex);
+ Assert.Equal("Q2", result[1].Question);
+ Assert.Equal("A2", result[1].Answer);
+ }
+
+ [Fact]
+ public async Task GetByStackIdAsync_ReturnsContiguousDisplayIndexes_WhenSourceIdsHaveGaps()
+ {
+ var repository = new FakeFlashcardRepository
+ {
+ CardsByStackId = new Dictionary>
+ {
+ [10] =
+ [
+ new Flashcard { Id = 1, StackId = 10, Question = "Q1", Answer = "A1" },
+ new Flashcard { Id = 3, StackId = 10, Question = "Q3", Answer = "A3" },
+ ],
+ },
+ };
+
+ var service = new FlashcardService(repository);
+
+ var result = (await service.GetByStackIdAsync(10)).ToList();
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal(1, result[0].DisplayIndex);
+ Assert.Equal(2, result[1].DisplayIndex);
+ }
+
+ [Fact]
+ public async Task GetByStackIdAsync_ReturnsEmptyCollection_WhenRepositoryHasNoCards()
+ {
+ var repository = new FakeFlashcardRepository();
+ var service = new FlashcardService(repository);
+
+ var result = await service.GetByStackIdAsync(999);
+
+ Assert.Equal(999, repository.LastRequestedStackId);
+ Assert.Empty(result);
+ }
+
+ private sealed class FakeFlashcardRepository : IFlashcardRepository
+ {
+ public Dictionary> CardsByStackId { get; set; } = [];
+ public int? LastRequestedStackId { get; private set; }
+
+ public Task> GetByStackIdAsync(int stackId)
+ {
+ LastRequestedStackId = stackId;
+ CardsByStackId.TryGetValue(stackId, out var cards);
+ return Task.FromResult(cards ?? Enumerable.Empty());
+ }
+
+ public Task AddAsync(Flashcard card) => Task.CompletedTask;
+
+ public Task DeleteAsync(int id) => Task.CompletedTask;
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StackServiceTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StackServiceTests.cs
new file mode 100644
index 00000000..486d2572
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StackServiceTests.cs
@@ -0,0 +1,58 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Tests.Services;
+
+public class StackServiceTests
+{
+ [Fact]
+ public async Task AddAsync_ReturnsNull_WhenStackNameAlreadyExists()
+ {
+ var existingStack = new Stack { Id = 1, Name = "Geography" };
+ var repository = new FakeStackRepository { AllStacks = [existingStack] };
+
+ var service = new StackService(repository);
+
+ var result = await service.AddAsync(new Stack { Name = "Geography" });
+
+ Assert.Null(result);
+ Assert.False(repository.AddCalled);
+ }
+
+ [Fact]
+ public async Task AddAsync_ReturnsAddedStack_WhenStackNameIsUnique()
+ {
+ var repository = new FakeStackRepository
+ {
+ AllStacks = [new Stack { Id = 1, Name = "Math" }],
+ StackToReturnFromAdd = new Stack { Id = 2, Name = "Science" },
+ };
+
+ var service = new StackService(repository);
+
+ var result = await service.AddAsync(new Stack { Name = "Science" });
+
+ Assert.NotNull(result);
+ Assert.True(repository.AddCalled);
+ Assert.Equal("Science", result!.Name);
+ Assert.Equal(2, result.Id);
+ }
+
+ private sealed class FakeStackRepository : IStackRepository
+ {
+ public IEnumerable AllStacks { get; set; } = [];
+ public Stack StackToReturnFromAdd { get; set; } = new Stack { Id = 1, Name = "Default" };
+ public bool AddCalled { get; private set; }
+
+ public Task> GetAllAsync() => Task.FromResult(AllStacks);
+
+ public Task AddAsync(Stack stack)
+ {
+ AddCalled = true;
+ return Task.FromResult(StackToReturnFromAdd);
+ }
+
+ public Task DeleteAsync(int id) => Task.CompletedTask;
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StudySessionServiceTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StudySessionServiceTests.cs
new file mode 100644
index 00000000..7b4ef90c
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StudySessionServiceTests.cs
@@ -0,0 +1,138 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Dtos;
+using Flashcards.Hillgrove.Menu;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Tests.Services;
+
+public class StudySessionServiceTests
+{
+ [Fact]
+ public async Task RunAsync_SavesOneStudySessionWithCalculatedScore_WhenFlashcardsExist()
+ {
+ var flashcardRepository = new FakeFlashcardRepository(
+ [
+ new Flashcard { Id = 1, StackId = 8, Question = "Q1", Answer = "A1" },
+ new Flashcard { Id = 2, StackId = 8, Question = "Q2", Answer = "A2" },
+ ]
+ );
+ var studySessionRepository = new FakeStudySessionRepository();
+ var ui = new FakeAppUi(promptInputs: ["a1", "wrong"]);
+ var service = new StudySessionService(flashcardRepository, studySessionRepository, ui);
+ var beforeRun = DateTime.UtcNow;
+
+ await service.RunAsync(new Stack { Id = 8, Name = "Science" });
+
+ Assert.Single(studySessionRepository.AddedSessions);
+ var saved = studySessionRepository.AddedSessions[0];
+ Assert.Equal(8, saved.StackId);
+ Assert.Equal(1, saved.Score);
+ Assert.True(saved.Date >= beforeRun);
+ Assert.True(saved.Date <= DateTime.UtcNow.AddSeconds(1));
+ }
+
+ [Fact]
+ public async Task RunAsync_DoesNotSaveStudySession_WhenNoFlashcardsExist()
+ {
+ var flashcardRepository = new FakeFlashcardRepository([]);
+ var studySessionRepository = new FakeStudySessionRepository();
+ var ui = new FakeAppUi(promptInputs: []);
+ var service = new StudySessionService(flashcardRepository, studySessionRepository, ui);
+
+ await service.RunAsync(new Stack { Id = 8, Name = "Science" });
+
+ Assert.Empty(studySessionRepository.AddedSessions);
+ Assert.Contains(ui.WarningMessages, message => message.Contains("There are no flashcards"));
+ }
+
+ [Fact]
+ public async Task GetHistoryAsync_ReturnsSessionsFromRepository()
+ {
+ var expectedSessions = new[]
+ {
+ new StudySession { Id = 1, StackId = 8, Date = DateTime.UtcNow.AddDays(-1), Score = 3 },
+ new StudySession { Id = 2, StackId = 9, Date = DateTime.UtcNow, Score = 5 },
+ };
+ var flashcardRepository = new FakeFlashcardRepository([]);
+ var studySessionRepository = new FakeStudySessionRepository(historySessions: expectedSessions);
+ var ui = new FakeAppUi(promptInputs: []);
+ var service = new StudySessionService(flashcardRepository, studySessionRepository, ui);
+
+ var history = await service.GetHistoryAsync();
+
+ Assert.Equal(2, history.Count);
+ Assert.Equal(expectedSessions.Select(s => s.Id), history.Select(s => s.Id));
+ }
+
+ private sealed class FakeFlashcardRepository(IEnumerable cards) : IFlashcardRepository
+ {
+ private readonly List _cards = cards.ToList();
+
+ public Task> GetByStackIdAsync(int stackId)
+ {
+ return Task.FromResult(_cards.Where(c => c.StackId == stackId).AsEnumerable());
+ }
+
+ public Task AddAsync(Flashcard card) => Task.CompletedTask;
+
+ public Task DeleteAsync(int id) => Task.CompletedTask;
+ }
+
+ private sealed class FakeStudySessionRepository(IEnumerable? historySessions = null)
+ : IStudySessionRepository
+ {
+ private readonly List _historySessions = historySessions?.ToList() ?? [];
+
+ public List AddedSessions { get; } = [];
+
+ public Task AddAsync(StudySession studySession)
+ {
+ AddedSessions.Add(studySession);
+ return Task.CompletedTask;
+ }
+
+ public Task> GetAllAsync()
+ {
+ return Task.FromResult(_historySessions.AsEnumerable());
+ }
+ }
+
+ private sealed class FakeAppUi(IEnumerable promptInputs) : IAppUi
+ {
+ private readonly Queue _promptInputs = new(promptInputs);
+
+ public List WarningMessages { get; } = [];
+
+ public void Clear() { }
+
+ public void WriteSuccess(string message) { }
+
+ public void WriteWarning(string message)
+ {
+ WarningMessages.Add(message);
+ }
+
+ public void WriteError(string message) { }
+
+ public void WaitForKey(string message = "[grey]Press any key to continue...[/]") { }
+
+ public string PromptText(string prompt, bool allowEmpty = false)
+ {
+ return _promptInputs.Count > 0 ? _promptInputs.Dequeue() : string.Empty;
+ }
+
+ public bool Confirm(string prompt) => false;
+
+ public Stack PromptStackSelection(string title, IReadOnlyList stacks)
+ {
+ return stacks.FirstOrDefault() ?? new Stack { Name = string.Empty };
+ }
+
+ public void ShowFlashcardsTable(string title, IEnumerable cards) { }
+
+ public void ShowStudySessionsTable(string title, IEnumerable sessions) { }
+
+ public void ShowStackReportTable(string title, IEnumerable rows) { }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.sln b/Flashcards.Hillgrove/Flashcards.Hillgrove.sln
new file mode 100644
index 00000000..bcd3867e
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.sln
@@ -0,0 +1,65 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36930.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashcards.Hillgrove", "Flashcards.Hillgrove\Flashcards.Hillgrove.csproj", "{E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
+ ProjectSection(SolutionItems) = preProject
+ .csharpierrc = .csharpierrc
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sql", "sql", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
+ ProjectSection(SolutionItems) = preProject
+ sql\create_tables.sql = sql\create_tables.sql
+ sql\seed_report_data.sql = sql\seed_report_data.sql
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashcards.Hillgrove.Tests", "Flashcards.Hillgrove.Tests\Flashcards.Hillgrove.Tests.csproj", "{CA996EEE-71B5-437E-80F0-41CBAF45BA07}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|x64.Build.0 = Debug|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|x86.Build.0 = Debug|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|x64.ActiveCfg = Release|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|x64.Build.0 = Release|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|x86.ActiveCfg = Release|Any CPU
+ {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|x86.Build.0 = Release|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|x64.Build.0 = Debug|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|x86.Build.0 = Debug|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|x64.ActiveCfg = Release|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|x64.Build.0 = Release|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|x86.ActiveCfg = Release|Any CPU
+ {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {407D08FC-66A8-4634-9601-175DEFF67F99}
+ EndGlobalSection
+EndGlobal
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/FlashcardRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/FlashcardRepository.cs
new file mode 100644
index 00000000..74b77569
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/FlashcardRepository.cs
@@ -0,0 +1,45 @@
+using System.Data;
+using Dapper;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Data
+{
+ internal class FlashcardRepository : IFlashcardRepository
+ {
+ private readonly IDbConnection _connection;
+
+ public FlashcardRepository(IDbConnection connection)
+ {
+ _connection = connection;
+ }
+
+ public async Task> GetByStackIdAsync(int stackId)
+ {
+ var sql =
+ "SELECT Id, Question, Answer FROM FlashCard WHERE StackId = @StackId ORDER BY Id";
+ return await _connection.QueryAsync(sql, new { StackId = stackId });
+ }
+
+ public async Task AddAsync(Flashcard card)
+ {
+ var sql =
+ @"INSERT INTO FlashCard (StackId, Question, Answer)
+ VALUES (@StackId, @Question, @Answer)";
+ await _connection.ExecuteAsync(
+ sql,
+ new
+ {
+ card.StackId,
+ card.Question,
+ card.Answer,
+ }
+ );
+ }
+
+ public async Task DeleteAsync(int id)
+ {
+ var sql = "DELETE FROM FlashCard WHERE Id = @Id";
+ await _connection.ExecuteAsync(sql, new { Id = id });
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IFlashcardRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IFlashcardRepository.cs
new file mode 100644
index 00000000..a70a0476
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IFlashcardRepository.cs
@@ -0,0 +1,11 @@
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Data
+{
+ internal interface IFlashcardRepository
+ {
+ Task> GetByStackIdAsync(int stackId);
+ Task AddAsync(Flashcard card);
+ Task DeleteAsync(int id);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IReportRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IReportRepository.cs
new file mode 100644
index 00000000..12004f21
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IReportRepository.cs
@@ -0,0 +1,10 @@
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Data
+{
+ internal interface IReportRepository
+ {
+ Task> GetSessionsPerMonthPerStackAsync(int year);
+ Task> GetAverageScorePerMonthPerStackAsync(int year);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStackRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStackRepository.cs
new file mode 100644
index 00000000..0a8fbdbf
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStackRepository.cs
@@ -0,0 +1,11 @@
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Data
+{
+ internal interface IStackRepository
+ {
+ Task> GetAllAsync();
+ Task AddAsync(Stack stack);
+ Task DeleteAsync(int id);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStudySessionRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStudySessionRepository.cs
new file mode 100644
index 00000000..b211b8e0
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStudySessionRepository.cs
@@ -0,0 +1,10 @@
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Data
+{
+ internal interface IStudySessionRepository
+ {
+ Task AddAsync(StudySession studySession);
+ Task> GetAllAsync();
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/ReportRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/ReportRepository.cs
new file mode 100644
index 00000000..badbe5d8
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/ReportRepository.cs
@@ -0,0 +1,90 @@
+using System.Data;
+using Dapper;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Data
+{
+ internal class ReportRepository : IReportRepository
+ {
+ private readonly IDbConnection _connection;
+
+ public ReportRepository(IDbConnection connection)
+ {
+ _connection = connection;
+ }
+
+ public async Task> GetSessionsPerMonthPerStackAsync(int year)
+ {
+ var sql =
+ @"
+ SELECT
+ StackName,
+ ISNULL(CAST([1] AS INT), 0) AS January,
+ ISNULL(CAST([2] AS INT), 0) AS February,
+ ISNULL(CAST([3] AS INT), 0) AS March,
+ ISNULL(CAST([4] AS INT), 0) AS April,
+ ISNULL(CAST([5] AS INT), 0) AS May,
+ ISNULL(CAST([6] AS INT), 0) AS June,
+ ISNULL(CAST([7] AS INT), 0) AS July,
+ ISNULL(CAST([8] AS INT), 0) AS August,
+ ISNULL(CAST([9] AS INT), 0) AS September,
+ ISNULL(CAST([10] AS INT), 0) AS October,
+ ISNULL(CAST([11] AS INT), 0) AS November,
+ ISNULL(CAST([12] AS INT), 0) AS December
+ FROM (
+ SELECT
+ s.Name AS StackName,
+ MONTH(ss.Date) AS Month,
+ COUNT(ss.Id) AS SessionCount
+ FROM Stack s
+ LEFT JOIN StudySession ss ON s.Id = ss.StackId AND YEAR(ss.Date) = @Year
+ GROUP BY s.Name, MONTH(ss.Date)
+ ) SourceTable
+ PIVOT (
+ SUM(SessionCount)
+ FOR [Month] IN ([1], [2], [3], [4], [5], [6], [7], [8], [9], [10], [11], [12])
+ ) PivotTable
+ ORDER BY StackName";
+
+ return await _connection.QueryAsync(sql, new { Year = year });
+ }
+
+ public async Task> GetAverageScorePerMonthPerStackAsync(
+ int year
+ )
+ {
+ var sql =
+ @"
+ SELECT
+ StackName,
+ ISNULL(ROUND([1], 2), 0) AS January,
+ ISNULL(ROUND([2], 2), 0) AS February,
+ ISNULL(ROUND([3], 2), 0) AS March,
+ ISNULL(ROUND([4], 2), 0) AS April,
+ ISNULL(ROUND([5], 2), 0) AS May,
+ ISNULL(ROUND([6], 2), 0) AS June,
+ ISNULL(ROUND([7], 2), 0) AS July,
+ ISNULL(ROUND([8], 2), 0) AS August,
+ ISNULL(ROUND([9], 2), 0) AS September,
+ ISNULL(ROUND([10], 2), 0) AS October,
+ ISNULL(ROUND([11], 2), 0) AS November,
+ ISNULL(ROUND([12], 2), 0) AS December
+ FROM (
+ SELECT
+ s.Name AS StackName,
+ MONTH(ss.Date) AS Month,
+ AVG(CAST(ss.Score AS FLOAT)) AS AverageScore
+ FROM Stack s
+ LEFT JOIN StudySession ss ON s.Id = ss.StackId AND YEAR(ss.Date) = @Year
+ GROUP BY s.Name, MONTH(ss.Date)
+ ) SourceTable
+ PIVOT (
+ AVG(AverageScore)
+ FOR [Month] IN ([1], [2], [3], [4], [5], [6], [7], [8], [9], [10], [11], [12])
+ ) PivotTable
+ ORDER BY StackName";
+
+ return await _connection.QueryAsync(sql, new { Year = year });
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StackRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StackRepository.cs
new file mode 100644
index 00000000..611db2e7
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StackRepository.cs
@@ -0,0 +1,36 @@
+using System.Data;
+using Dapper;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Data
+{
+ internal class StackRepository : IStackRepository
+ {
+ private readonly IDbConnection _connection;
+
+ public StackRepository(IDbConnection connection)
+ {
+ _connection = connection;
+ }
+
+ public async Task> GetAllAsync()
+ {
+ var sql = "SELECT Id, Name FROM Stack";
+ return await _connection.QueryAsync(sql);
+ }
+
+ public async Task AddAsync(Stack stack)
+ {
+ var sql =
+ @"INSERT INTO Stack (Name) VALUES (@Name);
+ SELECT Id, Name FROM Stack WHERE Id = SCOPE_IDENTITY();";
+ return await _connection.QuerySingleAsync(sql, new { stack.Name });
+ }
+
+ public async Task DeleteAsync(int id)
+ {
+ var sql = "DELETE FROM Stack WHERE Id = @Id";
+ await _connection.ExecuteAsync(sql, new { Id = id });
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StudySessionRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StudySessionRepository.cs
new file mode 100644
index 00000000..3844d998
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StudySessionRepository.cs
@@ -0,0 +1,44 @@
+using System.Data;
+using Dapper;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Data
+{
+ internal class StudySessionRepository : IStudySessionRepository
+ {
+ private readonly IDbConnection _connection;
+
+ public StudySessionRepository(IDbConnection connection)
+ {
+ _connection = connection;
+ }
+
+ public async Task AddAsync(StudySession studySession)
+ {
+ var sql =
+ @"INSERT INTO StudySession (StackId, Date, Score)
+ VALUES (@StackId, @Date, @Score)";
+
+ await _connection.ExecuteAsync(
+ sql,
+ new
+ {
+ studySession.StackId,
+ studySession.Date,
+ studySession.Score,
+ }
+ );
+ }
+
+ public async Task> GetAllAsync()
+ {
+ var sql =
+ @"SELECT ss.Id, ss.StackId, s.Name AS StackName, ss.Date, ss.Score
+ FROM StudySession ss
+ INNER JOIN Stack s ON s.Id = ss.StackId
+ ORDER BY ss.Date DESC, ss.Id DESC";
+
+ return await _connection.QueryAsync(sql);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Dtos/FlashCardDto.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Dtos/FlashCardDto.cs
new file mode 100644
index 00000000..304e568b
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Dtos/FlashCardDto.cs
@@ -0,0 +1,9 @@
+namespace Flashcards.Hillgrove.Dtos
+{
+ internal class FlashCardDto
+ {
+ public int DisplayIndex { get; set; }
+ public required string Question { get; set; }
+ public required string Answer { get; set; }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Flashcards.Hillgrove.csproj b/Flashcards.Hillgrove/Flashcards.Hillgrove/Flashcards.Hillgrove.csproj
new file mode 100644
index 00000000..b968e7a0
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Flashcards.Hillgrove.csproj
@@ -0,0 +1,31 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+ <_Parameter1>Flashcards.Hillgrove.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Helpers/NavigationResult.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Helpers/NavigationResult.cs
new file mode 100644
index 00000000..82f97817
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Helpers/NavigationResult.cs
@@ -0,0 +1,9 @@
+namespace Flashcards.Hillgrove.Helpers
+{
+ internal enum NavigationResult
+ {
+ Continue,
+ Back,
+ Exit,
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/AddFlashCardAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/AddFlashCardAction.cs
new file mode 100644
index 00000000..e9b0b16c
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/AddFlashCardAction.cs
@@ -0,0 +1,52 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Menu.Actions.Stacks;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Menu.Actions.Flashcards
+{
+ internal class AddFlashCardAction : IStackAction
+ {
+ private readonly IFlashcardRepository _repository;
+ private readonly IStackAction _onCompleted;
+ private readonly IAppUi _ui;
+
+ public AddFlashCardAction(IFlashcardRepository repository, IStackAction onCreated, IAppUi ui)
+ {
+ _repository = repository;
+ _onCompleted = onCreated;
+ _ui = ui;
+ }
+
+ public async Task ExecuteAsync(Stack stack)
+ {
+ while (true)
+ {
+ _ui.Clear();
+ _ui.WriteSuccess($"Add flashcards to '{stack.Name}'\n");
+
+ var question = _ui.PromptText("Enter question:").Trim();
+ var answer = _ui.PromptText("Enter answer:").Trim();
+
+ if (!_ui.Confirm("\nSave this card?"))
+ continue;
+
+ var card = new Flashcard
+ {
+ Question = question,
+ Answer = answer,
+ StackId = stack.Id,
+ };
+
+ await _repository.AddAsync(card);
+
+ _ui.WriteSuccess("\nFlashcard added!");
+
+ if (!_ui.Confirm("\n\nAdd another card to this stack?"))
+ break;
+ }
+
+ _ui.Clear();
+ await _onCompleted.ExecuteAsync(stack);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/DeleteFlashcardAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/DeleteFlashcardAction.cs
new file mode 100644
index 00000000..6b68593f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/DeleteFlashcardAction.cs
@@ -0,0 +1,85 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Dtos;
+using Flashcards.Hillgrove.Menu.Actions.Stacks;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Menu.Actions.Flashcards
+{
+ internal class DeleteFlashcardAction : IStackAction
+ {
+ private readonly IFlashcardRepository _repository;
+ private readonly IAppUi _ui;
+
+ public DeleteFlashcardAction(IFlashcardRepository repository, IAppUi ui)
+ {
+ _repository = repository;
+ _ui = ui;
+ }
+
+ public async Task ExecuteAsync(Stack stack)
+ {
+ while (true)
+ {
+ _ui.Clear();
+
+ var cards = (await _repository.GetByStackIdAsync(stack.Id)).ToList();
+
+ if (cards.Count == 0)
+ {
+ _ui.WriteWarning($"There are no flashcards in '{stack.Name}'.");
+ _ui.WaitForKey();
+ return;
+ }
+
+ _ui.WriteSuccess($"Delete flashcard from '{stack.Name}'\n");
+ _ui.ShowFlashcardsTable($"{stack.Name} stack", ToDisplayCards(cards));
+
+ var input = _ui
+ .PromptText("\nEnter flashcard # to delete (leave empty to cancel):", true)
+ .Trim();
+
+ if (string.IsNullOrEmpty(input))
+ {
+ return;
+ }
+
+ if (!int.TryParse(input, out var selectedIndex) || selectedIndex < 1 || selectedIndex > cards.Count)
+ {
+ _ui.WriteError("Invalid flashcard number.");
+ _ui.WaitForKey();
+ continue;
+ }
+
+ var cardToDelete = cards[selectedIndex - 1];
+
+ if (!_ui.Confirm($"Delete flashcard #{selectedIndex}?"))
+ {
+ return;
+ }
+
+ await _repository.DeleteAsync(cardToDelete.Id);
+
+ _ui.Clear();
+ _ui.WriteSuccess($"Deleted flashcard #{selectedIndex}.\n");
+
+ var updatedCards = (await _repository.GetByStackIdAsync(stack.Id)).ToList();
+ _ui.ShowFlashcardsTable($"{stack.Name} stack", ToDisplayCards(updatedCards));
+ _ui.WaitForKey();
+ return;
+ }
+ }
+
+ private static IEnumerable ToDisplayCards(IReadOnlyList cards)
+ {
+ return cards.Select(
+ (card, index) =>
+ new FlashCardDto
+ {
+ DisplayIndex = index + 1,
+ Question = card.Question,
+ Answer = card.Answer,
+ }
+ );
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/ShowFlashcardsAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/ShowFlashcardsAction.cs
new file mode 100644
index 00000000..3e3ac5aa
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/ShowFlashcardsAction.cs
@@ -0,0 +1,25 @@
+using Flashcards.Hillgrove.Menu.Actions.Stacks;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Actions.Flashcards
+{
+ internal class ShowFlashcardsAction : IStackAction
+ {
+ private readonly IFlashcardService _service;
+ private readonly IAppUi _ui;
+
+ public ShowFlashcardsAction(IFlashcardService service, IAppUi ui)
+ {
+ _service = service;
+ _ui = ui;
+ }
+
+ public async Task ExecuteAsync(Stack stack)
+ {
+ var cards = await _service.GetByStackIdAsync(stack.Id);
+ _ui.ShowFlashcardsTable($"{stack.Name} stack", cards);
+ _ui.WaitForKey();
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewAverageScorePerMonthPerStackAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewAverageScorePerMonthPerStackAction.cs
new file mode 100644
index 00000000..323fc020
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewAverageScorePerMonthPerStackAction.cs
@@ -0,0 +1,65 @@
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Actions.Reports
+{
+ internal class ViewAverageScorePerMonthPerStackAction
+ {
+ private readonly IReportService _reportService;
+ private readonly IAppUi _ui;
+
+ public ViewAverageScorePerMonthPerStackAction(IReportService reportService, IAppUi ui)
+ {
+ _reportService = reportService;
+ _ui = ui;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ _ui.Clear();
+ _ui.WriteSuccess("Average Score per Month per Stack\n");
+
+ var year = PromptYear();
+
+ if (year is null)
+ {
+ return;
+ }
+
+ var reportRows = await _reportService.GetAverageScorePerMonthPerStackAsync(year.Value);
+
+ if (reportRows.Count == 0)
+ {
+ _ui.WriteWarning($"No report data found for {year}.");
+ _ui.WaitForKey();
+ return;
+ }
+
+ _ui.ShowStackReportTable($"Average Score per Month per Stack ({year})", reportRows);
+ _ui.WaitForKey();
+ }
+
+ private int? PromptYear()
+ {
+ while (true)
+ {
+ var input = _ui.PromptText(
+ "Enter year [grey](leave blank to cancel)[/]",
+ allowEmpty: true
+ )
+ .Trim();
+
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ return null;
+ }
+
+ if (int.TryParse(input, out var year) && year is >= 1 and <= 9999)
+ {
+ return year;
+ }
+
+ _ui.WriteWarning("Please enter a valid year.");
+ }
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewSessionsPerMonthPerStackAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewSessionsPerMonthPerStackAction.cs
new file mode 100644
index 00000000..61915315
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewSessionsPerMonthPerStackAction.cs
@@ -0,0 +1,65 @@
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Actions.Reports
+{
+ internal class ViewSessionsPerMonthPerStackAction
+ {
+ private readonly IReportService _reportService;
+ private readonly IAppUi _ui;
+
+ public ViewSessionsPerMonthPerStackAction(IReportService reportService, IAppUi ui)
+ {
+ _reportService = reportService;
+ _ui = ui;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ _ui.Clear();
+ _ui.WriteSuccess("Sessions per Month per Stack\n");
+
+ var year = PromptYear();
+
+ if (year is null)
+ {
+ return;
+ }
+
+ var reportRows = await _reportService.GetSessionsPerMonthPerStackAsync(year.Value);
+
+ if (reportRows.Count == 0)
+ {
+ _ui.WriteWarning($"No report data found for {year}.");
+ _ui.WaitForKey();
+ return;
+ }
+
+ _ui.ShowStackReportTable($"Sessions per Month per Stack ({year})", reportRows);
+ _ui.WaitForKey();
+ }
+
+ private int? PromptYear()
+ {
+ while (true)
+ {
+ var input = _ui.PromptText(
+ "Enter year [grey](leave blank to cancel)[/]",
+ allowEmpty: true
+ )
+ .Trim();
+
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ return null;
+ }
+
+ if (int.TryParse(input, out var year) && year is >= 1 and <= 9999)
+ {
+ return year;
+ }
+
+ _ui.WriteWarning("Please enter a valid year.");
+ }
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/DeleteStackAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/DeleteStackAction.cs
new file mode 100644
index 00000000..1a765c2b
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/DeleteStackAction.cs
@@ -0,0 +1,41 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Menu.Actions.Stacks
+{
+ internal class DeleteStackAction : IStackAction
+ {
+ private readonly IStackRepository _repository;
+ private readonly IAppUi _ui;
+
+ public DeleteStackAction(IStackRepository repository, IAppUi ui)
+ {
+ _repository = repository;
+ _ui = ui;
+ }
+
+ public async Task ExecuteAsync(Stack stack)
+ {
+ _ui.WriteSuccess($"Deleting '{stack.Name}'\n");
+
+ if (!_ui.Confirm($"Delete stack '{stack.Name}'?"))
+ {
+ return;
+ }
+
+ if (
+ !_ui.Confirm(
+ $"[red]This will permanently delete '{stack.Name}' and related records. Continue?[/]"
+ )
+ )
+ {
+ return;
+ }
+
+ await _repository.DeleteAsync(stack.Id);
+
+ _ui.WriteSuccess($"Deleted stack: '{stack.Name}'");
+ _ui.WaitForKey();
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/IStackAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/IStackAction.cs
new file mode 100644
index 00000000..c1291372
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/IStackAction.cs
@@ -0,0 +1,9 @@
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Menu.Actions.Stacks
+{
+ internal interface IStackAction
+ {
+ Task ExecuteAsync(Stack stack);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/StartStudySessionAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/StartStudySessionAction.cs
new file mode 100644
index 00000000..c35ffb7f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/StartStudySessionAction.cs
@@ -0,0 +1,21 @@
+using Flashcards.Hillgrove.Menu.Actions.Stacks;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Actions.StudySessions
+{
+ internal class StartStudySessionAction : IStackAction
+ {
+ private readonly IStudySessionService _studySessionService;
+
+ public StartStudySessionAction(IStudySessionService studySessionService)
+ {
+ _studySessionService = studySessionService;
+ }
+
+ public Task ExecuteAsync(Stack stack)
+ {
+ return _studySessionService.RunAsync(stack);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/ViewStudySessionHistoryAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/ViewStudySessionHistoryAction.cs
new file mode 100644
index 00000000..41924844
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/ViewStudySessionHistoryAction.cs
@@ -0,0 +1,31 @@
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Actions.StudySessions
+{
+ internal class ViewStudySessionHistoryAction
+ {
+ private readonly IStudySessionService _studySessionService;
+ private readonly IAppUi _ui;
+
+ public ViewStudySessionHistoryAction(IStudySessionService studySessionService, IAppUi ui)
+ {
+ _studySessionService = studySessionService;
+ _ui = ui;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ var sessions = await _studySessionService.GetHistoryAsync();
+
+ if (sessions.Count == 0)
+ {
+ _ui.WriteWarning("There are no study sessions available.");
+ _ui.WaitForKey();
+ return;
+ }
+
+ _ui.ShowStudySessionsTable("Study Session History", sessions);
+ _ui.WaitForKey();
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/CompositeMenu.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/CompositeMenu.cs
new file mode 100644
index 00000000..a55b8ae9
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/CompositeMenu.cs
@@ -0,0 +1,42 @@
+using Flashcards.Hillgrove.Helpers;
+
+namespace Flashcards.Hillgrove.Menu
+{
+ internal class CompositeMenu : IMenuItem
+ {
+ private readonly IMenuPrompt _prompt;
+ internal IReadOnlyList Items { get; }
+
+ public string Label { get; }
+
+ public CompositeMenu(string label, IEnumerable items, IMenuPrompt prompt)
+ {
+ _prompt = prompt;
+ Items = items.ToList();
+ Label = label;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ while (true)
+ {
+ _prompt.Clear();
+
+ var choice = await _prompt.PromptAsync(Label, Items);
+
+ if (choice is null)
+ {
+ return NavigationResult.Continue;
+ }
+
+ var result = await choice.ExecuteAsync();
+
+ if (result == NavigationResult.Back)
+ return NavigationResult.Continue;
+
+ if (result == NavigationResult.Exit)
+ return NavigationResult.Exit;
+ }
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ExitSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ExitSectionProvider.cs
new file mode 100644
index 00000000..9ca04172
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ExitSectionProvider.cs
@@ -0,0 +1,11 @@
+using Flashcards.Hillgrove.Menu.Items;
+
+namespace Flashcards.Hillgrove.Menu.Composition
+{
+ internal class ExitSectionProvider : IMainMenuSectionProvider
+ {
+ public int Order => 5;
+
+ public IMenuItem Create() => new ExitMenuItem();
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/IMainMenuSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/IMainMenuSectionProvider.cs
new file mode 100644
index 00000000..c22c87d0
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/IMainMenuSectionProvider.cs
@@ -0,0 +1,8 @@
+namespace Flashcards.Hillgrove.Menu.Composition
+{
+ internal interface IMainMenuSectionProvider
+ {
+ int Order { get; }
+ IMenuItem Create();
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageFlashcardsSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageFlashcardsSectionProvider.cs
new file mode 100644
index 00000000..7275a60b
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageFlashcardsSectionProvider.cs
@@ -0,0 +1,57 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Menu.Actions.Flashcards;
+using Flashcards.Hillgrove.Menu.Items;
+using Flashcards.Hillgrove.Menu.Items.Flashcards;
+using Flashcards.Hillgrove.Menu.Items.Stacks;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Composition
+{
+ internal class ManageFlashcardsSectionProvider : IMainMenuSectionProvider
+ {
+ private readonly IStackRepository _stackRepository;
+ private readonly IFlashcardRepository _flashcardRepository;
+ private readonly IFlashcardService _flashcardService;
+ private readonly IAppUi _ui;
+ private readonly IMenuPrompt _menuPrompt;
+
+ public int Order => 2;
+
+ public ManageFlashcardsSectionProvider(
+ IStackRepository stackRepository,
+ IFlashcardRepository flashcardRepository,
+ IFlashcardService flashcardService,
+ IAppUi ui,
+ IMenuPrompt menuPrompt
+ )
+ {
+ _stackRepository = stackRepository;
+ _flashcardRepository = flashcardRepository;
+ _flashcardService = flashcardService;
+ _ui = ui;
+ _menuPrompt = menuPrompt;
+ }
+
+ public IMenuItem Create()
+ {
+ var showFlashcardsAction = new ShowFlashcardsAction(_flashcardService, _ui);
+ var deleteFlashcardAction = new DeleteFlashcardAction(_flashcardRepository, _ui);
+
+ var showStacks = new ShowAllStacksMenuItem(_stackRepository, showFlashcardsAction, _ui);
+ var addFlashcard = new AddFlashcardToExistingStackMenuItem(_ui);
+ var deleteFlashcard = new ShowAllStacksMenuItem(
+ _stackRepository,
+ deleteFlashcardAction,
+ _ui,
+ "Delete Flashcard from Stack",
+ "[green]Choose a stack[/]"
+ );
+
+ return new CompositeMenu(
+ "Manage Flashcards",
+ [showStacks, addFlashcard, deleteFlashcard, new BackMenuItem()],
+ _menuPrompt
+ );
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageStacksSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageStacksSectionProvider.cs
new file mode 100644
index 00000000..cb5cf79f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageStacksSectionProvider.cs
@@ -0,0 +1,65 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Menu.Actions.Flashcards;
+using Flashcards.Hillgrove.Menu.Actions.Stacks;
+using Flashcards.Hillgrove.Menu.Items;
+using Flashcards.Hillgrove.Menu.Items.Stacks;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Composition
+{
+ internal class ManageStacksSectionProvider : IMainMenuSectionProvider
+ {
+ private readonly IFlashcardRepository _flashcardRepository;
+ private readonly IStackRepository _stackRepository;
+ private readonly IStackService _stackService;
+ private readonly IFlashcardService _flashcardService;
+ private readonly IAppUi _ui;
+ private readonly IMenuPrompt _menuPrompt;
+
+ public int Order => 1;
+
+ public ManageStacksSectionProvider(
+ IFlashcardRepository flashcardRepository,
+ IStackRepository stackRepository,
+ IStackService stackService,
+ IFlashcardService flashcardService,
+ IAppUi ui,
+ IMenuPrompt menuPrompt
+ )
+ {
+ _flashcardRepository = flashcardRepository;
+ _stackRepository = stackRepository;
+ _stackService = stackService;
+ _flashcardService = flashcardService;
+ _ui = ui;
+ _menuPrompt = menuPrompt;
+ }
+
+ public IMenuItem Create()
+ {
+ var showFlashcardsAction = new ShowFlashcardsAction(_flashcardService, _ui);
+ var addFlashcardAction = new AddFlashCardAction(
+ _flashcardRepository,
+ showFlashcardsAction,
+ _ui
+ );
+ var deleteStackAction = new DeleteStackAction(_stackRepository, _ui);
+
+ var showStacks = new ShowAllStacksMenuItem(_stackRepository, showFlashcardsAction, _ui);
+ var createStack = new CreateStackMenuItem(_stackService, addFlashcardAction, _ui);
+ var deleteStack = new ShowAllStacksMenuItem(
+ _stackRepository,
+ deleteStackAction,
+ _ui,
+ "Delete a stack",
+ "[green]Choose a stack to delete[/]"
+ );
+
+ return new CompositeMenu(
+ "Manage Stacks",
+ [showStacks, createStack, deleteStack, new BackMenuItem()],
+ _menuPrompt
+ );
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ReportsSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ReportsSectionProvider.cs
new file mode 100644
index 00000000..bf14a7bd
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ReportsSectionProvider.cs
@@ -0,0 +1,43 @@
+using Flashcards.Hillgrove.Menu.Actions.Reports;
+using Flashcards.Hillgrove.Menu.Items;
+using Flashcards.Hillgrove.Menu.Items.Reports;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Composition
+{
+ internal class ReportsSectionProvider : IMainMenuSectionProvider
+ {
+ private readonly IReportService _reportService;
+ private readonly IAppUi _ui;
+ private readonly IMenuPrompt _menuPrompt;
+
+ public int Order => 4;
+
+ public ReportsSectionProvider(IReportService reportService, IAppUi ui, IMenuPrompt menuPrompt)
+ {
+ _reportService = reportService;
+ _ui = ui;
+ _menuPrompt = menuPrompt;
+ }
+
+ public IMenuItem Create()
+ {
+ var viewSessionsAction = new ViewSessionsPerMonthPerStackAction(_reportService, _ui);
+ var viewAverageScoreAction = new ViewAverageScorePerMonthPerStackAction(
+ _reportService,
+ _ui
+ );
+
+ var sessionsReport = new ViewSessionsPerMonthPerStackMenuItem(viewSessionsAction);
+ var averageScoreReport = new ViewAverageScorePerMonthPerStackMenuItem(
+ viewAverageScoreAction
+ );
+
+ return new CompositeMenu(
+ "Reports",
+ [sessionsReport, averageScoreReport, new BackMenuItem()],
+ _menuPrompt
+ );
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/StudySessionsSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/StudySessionsSectionProvider.cs
new file mode 100644
index 00000000..891c3385
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/StudySessionsSectionProvider.cs
@@ -0,0 +1,53 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Menu.Actions.StudySessions;
+using Flashcards.Hillgrove.Menu.Items;
+using Flashcards.Hillgrove.Menu.Items.Stacks;
+using Flashcards.Hillgrove.Menu.Items.StudySessions;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Composition
+{
+ internal class StudySessionsSectionProvider : IMainMenuSectionProvider
+ {
+ private readonly IStackRepository _stackRepository;
+ private readonly IStudySessionService _studySessionService;
+ private readonly IAppUi _ui;
+ private readonly IMenuPrompt _menuPrompt;
+
+ public int Order => 3;
+
+ public StudySessionsSectionProvider(
+ IStackRepository stackRepository,
+ IStudySessionService studySessionService,
+ IAppUi ui,
+ IMenuPrompt menuPrompt
+ )
+ {
+ _stackRepository = stackRepository;
+ _studySessionService = studySessionService;
+ _ui = ui;
+ _menuPrompt = menuPrompt;
+ }
+
+ public IMenuItem Create()
+ {
+ var startStudySessionAction = new StartStudySessionAction(_studySessionService);
+ var viewStudySessionHistoryAction = new ViewStudySessionHistoryAction(_studySessionService, _ui);
+
+ var startStudySession = new ShowAllStacksMenuItem(
+ _stackRepository,
+ startStudySessionAction,
+ _ui,
+ "Start Study Session",
+ "[green]Choose a stack to study[/]"
+ );
+ var viewStudySessionHistory = new ViewStudySessionHistoryMenuItem(viewStudySessionHistoryAction);
+
+ return new CompositeMenu(
+ "Study Sessions",
+ [startStudySession, viewStudySessionHistory, new BackMenuItem()],
+ _menuPrompt
+ );
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IAppUi.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IAppUi.cs
new file mode 100644
index 00000000..5006eda1
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IAppUi.cs
@@ -0,0 +1,20 @@
+using Flashcards.Hillgrove.Dtos;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Menu
+{
+ internal interface IAppUi
+ {
+ void Clear();
+ void WriteSuccess(string message);
+ void WriteWarning(string message);
+ void WriteError(string message);
+ void WaitForKey(string message = "[grey]Press any key to continue...[/]");
+ string PromptText(string prompt, bool allowEmpty = false);
+ bool Confirm(string prompt);
+ Stack PromptStackSelection(string title, IReadOnlyList stacks);
+ void ShowFlashcardsTable(string title, IEnumerable cards);
+ void ShowStudySessionsTable(string title, IEnumerable sessions);
+ void ShowStackReportTable(string title, IEnumerable rows);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuItem.cs
new file mode 100644
index 00000000..2d816565
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuItem.cs
@@ -0,0 +1,10 @@
+using Flashcards.Hillgrove.Helpers;
+
+namespace Flashcards.Hillgrove.Menu
+{
+ internal interface IMenuItem
+ {
+ string Label { get; }
+ Task ExecuteAsync();
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuPrompt.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuPrompt.cs
new file mode 100644
index 00000000..d0efef2b
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuPrompt.cs
@@ -0,0 +1,8 @@
+namespace Flashcards.Hillgrove.Menu
+{
+ internal interface IMenuPrompt
+ {
+ void Clear();
+ Task PromptAsync(string title, IReadOnlyList items);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/BackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/BackMenuItem.cs
new file mode 100644
index 00000000..513880d6
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/BackMenuItem.cs
@@ -0,0 +1,11 @@
+using Flashcards.Hillgrove.Helpers;
+
+namespace Flashcards.Hillgrove.Menu.Items
+{
+ internal class BackMenuItem : IMenuItem
+ {
+ public string Label => "Back";
+
+ public Task ExecuteAsync() => Task.FromResult(NavigationResult.Back);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/ExitMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/ExitMenuItem.cs
new file mode 100644
index 00000000..52189055
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/ExitMenuItem.cs
@@ -0,0 +1,11 @@
+using Flashcards.Hillgrove.Helpers;
+
+namespace Flashcards.Hillgrove.Menu.Items
+{
+ internal class ExitMenuItem : IMenuItem
+ {
+ public string Label => "Exit";
+
+ public Task ExecuteAsync() => Task.FromResult(NavigationResult.Exit);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Flashcards/AddFlashcardToExistingStackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Flashcards/AddFlashcardToExistingStackMenuItem.cs
new file mode 100644
index 00000000..c4bd3c65
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Flashcards/AddFlashcardToExistingStackMenuItem.cs
@@ -0,0 +1,23 @@
+using Flashcards.Hillgrove.Helpers;
+
+namespace Flashcards.Hillgrove.Menu.Items.Flashcards
+{
+ internal class AddFlashcardToExistingStackMenuItem : IMenuItem
+ {
+ private readonly IAppUi _ui;
+
+ public string Label => "Add Flashcard to Existing Stack";
+
+ public AddFlashcardToExistingStackMenuItem(IAppUi ui)
+ {
+ _ui = ui;
+ }
+
+ public Task ExecuteAsync()
+ {
+ _ui.WriteWarning("Add Flashcard to Existing Stack is not implemented yet.");
+ _ui.WaitForKey();
+ return Task.FromResult(NavigationResult.Continue);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewAverageScorePerMonthPerStackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewAverageScorePerMonthPerStackMenuItem.cs
new file mode 100644
index 00000000..501a6d3f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewAverageScorePerMonthPerStackMenuItem.cs
@@ -0,0 +1,23 @@
+using Flashcards.Hillgrove.Helpers;
+using Flashcards.Hillgrove.Menu.Actions.Reports;
+
+namespace Flashcards.Hillgrove.Menu.Items.Reports
+{
+ internal class ViewAverageScorePerMonthPerStackMenuItem : IMenuItem
+ {
+ private readonly ViewAverageScorePerMonthPerStackAction _action;
+
+ public string Label => "Average Score per Month per Stack";
+
+ public ViewAverageScorePerMonthPerStackMenuItem(ViewAverageScorePerMonthPerStackAction action)
+ {
+ _action = action;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ await _action.ExecuteAsync();
+ return NavigationResult.Continue;
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewSessionsPerMonthPerStackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewSessionsPerMonthPerStackMenuItem.cs
new file mode 100644
index 00000000..91b2925f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewSessionsPerMonthPerStackMenuItem.cs
@@ -0,0 +1,23 @@
+using Flashcards.Hillgrove.Helpers;
+using Flashcards.Hillgrove.Menu.Actions.Reports;
+
+namespace Flashcards.Hillgrove.Menu.Items.Reports
+{
+ internal class ViewSessionsPerMonthPerStackMenuItem : IMenuItem
+ {
+ private readonly ViewSessionsPerMonthPerStackAction _action;
+
+ public string Label => "Sessions per Month per Stack";
+
+ public ViewSessionsPerMonthPerStackMenuItem(ViewSessionsPerMonthPerStackAction action)
+ {
+ _action = action;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ await _action.ExecuteAsync();
+ return NavigationResult.Continue;
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/CreateStackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/CreateStackMenuItem.cs
new file mode 100644
index 00000000..dfc8b898
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/CreateStackMenuItem.cs
@@ -0,0 +1,74 @@
+using Flashcards.Hillgrove.Helpers;
+using Flashcards.Hillgrove.Menu.Actions.Stacks;
+using Flashcards.Hillgrove.Models;
+using Flashcards.Hillgrove.Services;
+
+namespace Flashcards.Hillgrove.Menu.Items.Stacks
+{
+ internal class CreateStackMenuItem : IMenuItem
+ {
+ private readonly IStackService _service;
+ private readonly IStackAction _onCreated;
+ private readonly IAppUi _ui;
+
+ public string Label => "Create a new stack";
+
+ public CreateStackMenuItem(IStackService service, IStackAction onCreated, IAppUi ui)
+ {
+ _service = service;
+ _onCreated = onCreated;
+ _ui = ui;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ string input;
+
+ while (true)
+ {
+ _ui.Clear();
+ _ui.WriteSuccess("Create a new stack\n");
+
+ input = _ui.PromptText(
+ "What should the stack be called? [grey](leave blank to cancel)[/]",
+ allowEmpty: true
+ );
+
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ return NavigationResult.Continue;
+ }
+
+ if (!_ui.Confirm($"Use this name: '{input}'? "))
+ {
+ _ui.Clear();
+ continue;
+ }
+
+ Stack newStack = new Stack { Name = input };
+ Stack? stackAdded = await _service.AddAsync(newStack);
+
+ if (stackAdded == null)
+ {
+ _ui.WriteError($"\nName already exists: '{input}'");
+
+ if (_ui.Confirm("\nTry again with another name?"))
+ {
+ continue;
+ }
+ }
+ else
+ {
+ _ui.WriteSuccess($"\nStack added: '{input}'");
+
+ if (_ui.Confirm("\nAdd flashcards to the new stack now?"))
+ {
+ await _onCreated.ExecuteAsync(stackAdded);
+ }
+ }
+
+ return NavigationResult.Continue;
+ }
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/ShowAllStacksMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/ShowAllStacksMenuItem.cs
new file mode 100644
index 00000000..7e9a7e7c
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/ShowAllStacksMenuItem.cs
@@ -0,0 +1,49 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Helpers;
+using Flashcards.Hillgrove.Menu.Actions.Stacks;
+
+namespace Flashcards.Hillgrove.Menu.Items.Stacks
+{
+ internal class ShowAllStacksMenuItem : IMenuItem
+ {
+ private readonly IStackRepository _repository;
+ private readonly IStackAction _onSelect;
+ private readonly IAppUi _ui;
+ private readonly string _label;
+ private readonly string _promptTitle;
+
+ public string Label => _label;
+
+ public ShowAllStacksMenuItem(
+ IStackRepository repository,
+ IStackAction onSelect,
+ IAppUi ui,
+ string label = "Show All Stacks",
+ string promptTitle = "[green]Choose a stack[/]"
+ )
+ {
+ _repository = repository;
+ _onSelect = onSelect;
+ _ui = ui;
+ _label = label;
+ _promptTitle = promptTitle;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ var stacks = (await _repository.GetAllAsync()).ToList();
+
+ if (stacks.Count == 0)
+ {
+ _ui.WriteWarning("There are no stacks available.");
+ _ui.WaitForKey();
+ return NavigationResult.Continue;
+ }
+
+ var selected = _ui.PromptStackSelection(_promptTitle, stacks);
+ await _onSelect.ExecuteAsync(selected);
+
+ return NavigationResult.Continue;
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/StartStudySessionMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/StartStudySessionMenuItem.cs
new file mode 100644
index 00000000..bbb1f09e
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/StartStudySessionMenuItem.cs
@@ -0,0 +1,23 @@
+using Flashcards.Hillgrove.Helpers;
+
+namespace Flashcards.Hillgrove.Menu.Items.StudySessions
+{
+ internal class StartStudySessionMenuItem : IMenuItem
+ {
+ private readonly IAppUi _ui;
+
+ public string Label => "Start Study Session";
+
+ public StartStudySessionMenuItem(IAppUi ui)
+ {
+ _ui = ui;
+ }
+
+ public Task ExecuteAsync()
+ {
+ _ui.WriteWarning("Start Study Session is not implemented yet.");
+ _ui.WaitForKey();
+ return Task.FromResult(NavigationResult.Continue);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/ViewStudySessionHistoryMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/ViewStudySessionHistoryMenuItem.cs
new file mode 100644
index 00000000..93bec920
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/ViewStudySessionHistoryMenuItem.cs
@@ -0,0 +1,23 @@
+using Flashcards.Hillgrove.Helpers;
+using Flashcards.Hillgrove.Menu.Actions.StudySessions;
+
+namespace Flashcards.Hillgrove.Menu.Items.StudySessions
+{
+ internal class ViewStudySessionHistoryMenuItem : IMenuItem
+ {
+ private readonly ViewStudySessionHistoryAction _viewStudySessionHistoryAction;
+
+ public string Label => "View Study Session History";
+
+ public ViewStudySessionHistoryMenuItem(ViewStudySessionHistoryAction viewStudySessionHistoryAction)
+ {
+ _viewStudySessionHistoryAction = viewStudySessionHistoryAction;
+ }
+
+ public async Task ExecuteAsync()
+ {
+ await _viewStudySessionHistoryAction.ExecuteAsync();
+ return NavigationResult.Continue;
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/MenuComposer.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/MenuComposer.cs
new file mode 100644
index 00000000..84f08564
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/MenuComposer.cs
@@ -0,0 +1,28 @@
+using Flashcards.Hillgrove.Menu.Composition;
+
+namespace Flashcards.Hillgrove.Menu
+{
+ internal class MenuComposer
+ {
+ private readonly IEnumerable _sectionProviders;
+ private readonly IMenuPrompt _menuPrompt;
+
+ public MenuComposer(
+ IEnumerable sectionProviders,
+ IMenuPrompt menuPrompt
+ )
+ {
+ _sectionProviders = sectionProviders;
+ _menuPrompt = menuPrompt;
+ }
+
+ public IMenuItem Build()
+ {
+ var sections = _sectionProviders
+ .OrderBy(provider => provider.Order)
+ .Select(provider => provider.Create());
+
+ return new CompositeMenu("Main Menu", sections, _menuPrompt);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreAppUi.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreAppUi.cs
new file mode 100644
index 00000000..d5597528
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreAppUi.cs
@@ -0,0 +1,143 @@
+using Flashcards.Hillgrove.Dtos;
+using Flashcards.Hillgrove.Models;
+using Spectre.Console;
+
+namespace Flashcards.Hillgrove.Menu
+{
+ internal class SpectreAppUi : IAppUi
+ {
+ public void Clear() => AnsiConsole.Clear();
+
+ public void WriteSuccess(string message) => AnsiConsole.MarkupLine($"[green]{message}[/]");
+
+ public void WriteWarning(string message) => AnsiConsole.MarkupLine($"[yellow]{message}[/]");
+
+ public void WriteError(string message) => AnsiConsole.MarkupLine($"[red]{message}[/]");
+
+ public void WaitForKey(string message = "[grey]Press any key to continue...[/]")
+ {
+ AnsiConsole.Markup($"\n{message}");
+ Console.ReadKey(true);
+ }
+
+ public string PromptText(string prompt, bool allowEmpty = false)
+ {
+ var textPrompt = new TextPrompt(prompt);
+
+ if (allowEmpty)
+ {
+ textPrompt.AllowEmpty();
+ }
+
+ return AnsiConsole.Prompt(textPrompt);
+ }
+
+ public bool Confirm(string prompt) => AnsiConsole.Confirm(prompt);
+
+ public Stack PromptStackSelection(string title, IReadOnlyList stacks)
+ {
+ return AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title(title)
+ .UseConverter(stack => stack.Name)
+ .AddChoices(stacks)
+ );
+ }
+
+ public void ShowFlashcardsTable(string title, IEnumerable cards)
+ {
+ var table = new Table();
+ table.AddColumn("#");
+ table.AddColumn("Question");
+ table.AddColumn("Answer");
+
+ foreach (var card in cards)
+ {
+ table.AddRow(
+ card.DisplayIndex.ToString(),
+ card.Question ?? string.Empty,
+ card.Answer ?? string.Empty
+ );
+ }
+
+ WriteSuccess(title);
+ AnsiConsole.Write(table);
+ }
+
+ public void ShowStudySessionsTable(string title, IEnumerable sessions)
+ {
+ var table = new Table();
+ table.AddColumn("Date (UTC)");
+ table.AddColumn("Stack");
+ table.AddColumn("Score");
+
+ foreach (var session in sessions)
+ {
+ table.AddRow(
+ session.Date.ToString("u"),
+ session.StackName ?? session.StackId.ToString(),
+ session.Score.ToString()
+ );
+ }
+
+ WriteSuccess(title);
+ AnsiConsole.Write(table);
+ }
+
+ public void ShowStackReportTable(string title, IEnumerable rows)
+ {
+ var table = new Table();
+ table.AddColumn("Stack");
+ table.AddColumn("Jan");
+ table.AddColumn("Feb");
+ table.AddColumn("Mar");
+ table.AddColumn("Apr");
+ table.AddColumn("May");
+ table.AddColumn("Jun");
+ table.AddColumn("Jul");
+ table.AddColumn("Aug");
+ table.AddColumn("Sep");
+ table.AddColumn("Oct");
+ table.AddColumn("Nov");
+ table.AddColumn("Dec");
+ table.AddColumn("Total");
+ table.AddColumn("Average");
+
+ foreach (var row in rows)
+ {
+ table.AddRow(
+ row.StackName,
+ FormatReportValue(row.January),
+ FormatReportValue(row.February),
+ FormatReportValue(row.March),
+ FormatReportValue(row.April),
+ FormatReportValue(row.May),
+ FormatReportValue(row.June),
+ FormatReportValue(row.July),
+ FormatReportValue(row.August),
+ FormatReportValue(row.September),
+ FormatReportValue(row.October),
+ FormatReportValue(row.November),
+ FormatReportValue(row.December),
+ FormatReportValue(row.Total),
+ FormatReportValue(row.Average)
+ );
+ }
+
+ WriteSuccess(title);
+ AnsiConsole.Write(table);
+ }
+
+ private static string FormatReportValue(double value)
+ {
+ var roundedValue = Math.Round(value, 2);
+
+ if (Math.Abs(roundedValue % 1) < 0.000001)
+ {
+ return roundedValue.ToString("0");
+ }
+
+ return roundedValue.ToString("0.##");
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreMenuPrompt.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreMenuPrompt.cs
new file mode 100644
index 00000000..dd283f83
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreMenuPrompt.cs
@@ -0,0 +1,33 @@
+using Spectre.Console;
+
+namespace Flashcards.Hillgrove.Menu
+{
+ internal class SpectreMenuPrompt : IMenuPrompt
+ {
+ public void Clear() => AnsiConsole.Clear();
+
+ public Task PromptAsync(string label, IReadOnlyList items)
+ {
+ ArgumentNullException.ThrowIfNull(label);
+ ArgumentNullException.ThrowIfNull(items);
+
+ if (items.Count == 0)
+ {
+ AnsiConsole.MarkupLine($"[green]{label}[/]\n");
+ AnsiConsole.MarkupLine("[yellow]No options available yet.[/]");
+ AnsiConsole.Markup("\n[grey]Press any key to go back...[/]");
+ Console.ReadKey(true);
+ return Task.FromResult(null);
+ }
+
+ var choice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title($"[green]{label}[/]")
+ .AddChoices(items)
+ .UseConverter(item => item.Label)
+ );
+
+ return Task.FromResult(choice);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Flashcard.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Flashcard.cs
new file mode 100644
index 00000000..f46b5ce9
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Flashcard.cs
@@ -0,0 +1,10 @@
+namespace Flashcards.Hillgrove.Models
+{
+ public class Flashcard
+ {
+ public int Id { get; set; }
+ public int StackId { get; set; }
+ public required string Question { get; set; }
+ public required string Answer { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Stack.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Stack.cs
new file mode 100644
index 00000000..0408974b
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Stack.cs
@@ -0,0 +1,8 @@
+namespace Flashcards.Hillgrove.Models
+{
+ public class Stack
+ {
+ public int Id { get; set; }
+ public required string Name { get; set; }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StackReportRow.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StackReportRow.cs
new file mode 100644
index 00000000..92ab9f00
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StackReportRow.cs
@@ -0,0 +1,58 @@
+namespace Flashcards.Hillgrove.Models
+{
+ internal class StackReportRow
+ {
+ public string StackName { get; set; } = string.Empty;
+ public double January { get; set; }
+ public double February { get; set; }
+ public double March { get; set; }
+ public double April { get; set; }
+ public double May { get; set; }
+ public double June { get; set; }
+ public double July { get; set; }
+ public double August { get; set; }
+ public double September { get; set; }
+ public double October { get; set; }
+ public double November { get; set; }
+ public double December { get; set; }
+
+ public double Total =>
+ January
+ + February
+ + March
+ + April
+ + May
+ + June
+ + July
+ + August
+ + September
+ + October
+ + November
+ + December;
+
+ public double Average
+ {
+ get
+ {
+ var monthValues = new[]
+ {
+ January,
+ February,
+ March,
+ April,
+ May,
+ June,
+ July,
+ August,
+ September,
+ October,
+ November,
+ December,
+ };
+
+ var activeMonthValues = monthValues.Where(value => value > 0).ToArray();
+ return activeMonthValues.Length == 0 ? 0 : activeMonthValues.Average();
+ }
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StudySession.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StudySession.cs
new file mode 100644
index 00000000..0e43d810
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StudySession.cs
@@ -0,0 +1,11 @@
+namespace Flashcards.Hillgrove.Models
+{
+ public class StudySession
+ {
+ public int Id { get; set; }
+ public int StackId { get; set; }
+ public string? StackName { get; set; }
+ public DateTime Date { get; set; }
+ public int Score { get; set; }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Program.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Program.cs
new file mode 100644
index 00000000..463996a7
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Program.cs
@@ -0,0 +1,47 @@
+using System.Data;
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Menu;
+using Flashcards.Hillgrove.Menu.Composition;
+using Flashcards.Hillgrove.Services;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+var config = new ConfigurationBuilder()
+ .SetBasePath(AppContext.BaseDirectory)
+ .AddJsonFile("appsettings.json", optional: false)
+ .Build();
+
+string connectionString =
+ config.GetConnectionString("DefaultConnection")
+ ?? throw new InvalidOperationException("Connection string is missing.");
+
+var services = new ServiceCollection();
+services.AddSingleton(_ => new SqlConnection(connectionString));
+
+services.AddSingleton();
+services.AddSingleton();
+services.AddSingleton();
+services.AddSingleton();
+
+services.AddSingleton();
+services.AddSingleton();
+services.AddSingleton();
+services.AddSingleton();
+
+services.AddSingleton();
+services.AddSingleton();
+
+services.AddSingleton();
+services.AddSingleton();
+services.AddSingleton();
+services.AddSingleton();
+services.AddSingleton();
+
+services.AddSingleton();
+
+using var provider = services.BuildServiceProvider();
+
+var menuComposer = provider.GetRequiredService();
+var menu = menuComposer.Build();
+await menu.ExecuteAsync();
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/FlashcardService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/FlashcardService.cs
new file mode 100644
index 00000000..6b079c96
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/FlashcardService.cs
@@ -0,0 +1,32 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Dtos;
+
+namespace Flashcards.Hillgrove.Services
+{
+ internal class FlashcardService : IFlashcardService
+ {
+ private readonly IFlashcardRepository _repository;
+
+ public FlashcardService(IFlashcardRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task> GetByStackIdAsync(int stackId)
+ {
+ var foundCards = await _repository.GetByStackIdAsync(stackId);
+
+ var cards = foundCards.Select(
+ (fc, index) =>
+ new FlashCardDto
+ {
+ DisplayIndex = index + 1,
+ Question = fc.Question,
+ Answer = fc.Answer,
+ }
+ );
+
+ return cards;
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IFlashcardService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IFlashcardService.cs
new file mode 100644
index 00000000..583d8d1d
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IFlashcardService.cs
@@ -0,0 +1,9 @@
+using Flashcards.Hillgrove.Dtos;
+
+namespace Flashcards.Hillgrove.Services
+{
+ internal interface IFlashcardService
+ {
+ Task> GetByStackIdAsync(int stackId);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IReportService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IReportService.cs
new file mode 100644
index 00000000..a4af86de
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IReportService.cs
@@ -0,0 +1,10 @@
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Services
+{
+ internal interface IReportService
+ {
+ Task> GetSessionsPerMonthPerStackAsync(int year);
+ Task> GetAverageScorePerMonthPerStackAsync(int year);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStackService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStackService.cs
new file mode 100644
index 00000000..d3212f87
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStackService.cs
@@ -0,0 +1,9 @@
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Services
+{
+ internal interface IStackService
+ {
+ Task AddAsync(Stack newStack);
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStudySessionService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStudySessionService.cs
new file mode 100644
index 00000000..b5361a8f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStudySessionService.cs
@@ -0,0 +1,10 @@
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Services
+{
+ internal interface IStudySessionService
+ {
+ Task RunAsync(Stack stack);
+ Task> GetHistoryAsync();
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/ReportService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/ReportService.cs
new file mode 100644
index 00000000..a27bcd2f
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/ReportService.cs
@@ -0,0 +1,27 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Services
+{
+ internal class ReportService : IReportService
+ {
+ private readonly IReportRepository _reportRepository;
+
+ public ReportService(IReportRepository reportRepository)
+ {
+ _reportRepository = reportRepository;
+ }
+
+ public async Task> GetSessionsPerMonthPerStackAsync(int year)
+ {
+ var reportRows = await _reportRepository.GetSessionsPerMonthPerStackAsync(year);
+ return reportRows.ToList();
+ }
+
+ public async Task> GetAverageScorePerMonthPerStackAsync(int year)
+ {
+ var reportRows = await _reportRepository.GetAverageScorePerMonthPerStackAsync(year);
+ return reportRows.ToList();
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StackService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StackService.cs
new file mode 100644
index 00000000..0ec0bcc2
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StackService.cs
@@ -0,0 +1,28 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Services
+{
+ internal class StackService : IStackService
+ {
+ private readonly IStackRepository _repository;
+
+ public StackService(IStackRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task AddAsync(Stack newStack)
+ {
+ IEnumerable existingStacks = await _repository.GetAllAsync();
+ bool stackNameExist = existingStacks.Any(s => s.Name == newStack.Name);
+
+ if (stackNameExist)
+ {
+ return null;
+ }
+
+ return await _repository.AddAsync(newStack);
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StudySessionService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StudySessionService.cs
new file mode 100644
index 00000000..3e05c172
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StudySessionService.cs
@@ -0,0 +1,79 @@
+using Flashcards.Hillgrove.Data;
+using Flashcards.Hillgrove.Menu;
+using Flashcards.Hillgrove.Models;
+
+namespace Flashcards.Hillgrove.Services
+{
+ internal class StudySessionService : IStudySessionService
+ {
+ private readonly IFlashcardRepository _flashcardRepository;
+ private readonly IStudySessionRepository _studySessionRepository;
+ private readonly IAppUi _ui;
+
+ public StudySessionService(
+ IFlashcardRepository flashcardRepository,
+ IStudySessionRepository studySessionRepository,
+ IAppUi ui
+ )
+ {
+ _flashcardRepository = flashcardRepository;
+ _studySessionRepository = studySessionRepository;
+ _ui = ui;
+ }
+
+ public async Task RunAsync(Stack stack)
+ {
+ var flashcards = (await _flashcardRepository.GetByStackIdAsync(stack.Id)).ToList();
+
+ if (flashcards.Count == 0)
+ {
+ _ui.WriteWarning($"There are no flashcards in '{stack.Name}'.");
+ _ui.WaitForKey();
+ return;
+ }
+
+ _ui.Clear();
+ _ui.WriteSuccess($"Study session for '{stack.Name}'\n");
+
+ var score = 0;
+
+ for (var index = 0; index < flashcards.Count; index++)
+ {
+ var card = flashcards[index];
+ _ui.WriteSuccess($"Question {index + 1} of {flashcards.Count}");
+ _ui.WriteSuccess(card.Question ?? string.Empty);
+
+ var userAnswer = _ui.PromptText("Your answer:").Trim();
+ var correctAnswer = (card.Answer ?? string.Empty).Trim();
+
+ if (string.Equals(userAnswer, correctAnswer, StringComparison.OrdinalIgnoreCase))
+ {
+ score++;
+ _ui.WriteSuccess("Correct!\n");
+ }
+ else
+ {
+ _ui.WriteWarning($"Incorrect. Correct answer: {card.Answer}\n");
+ }
+ }
+
+ await _studySessionRepository.AddAsync(
+ new StudySession
+ {
+ StackId = stack.Id,
+ Date = DateTime.UtcNow,
+ Score = score,
+ }
+ );
+
+ _ui.WriteSuccess($"Session complete. Score: {score}/{flashcards.Count}");
+ _ui.WaitForKey();
+ }
+
+ public async Task> GetHistoryAsync()
+ {
+ var sessions = await _studySessionRepository.GetAllAsync();
+ return sessions.ToList();
+ }
+ }
+}
diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/appsettings.json b/Flashcards.Hillgrove/Flashcards.Hillgrove/appsettings.json
new file mode 100644
index 00000000..c489c6c3
--- /dev/null
+++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/appsettings.json
@@ -0,0 +1,5 @@
+{
+ "ConnectionStrings": {
+ "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=FlashcardsDB;Integrated Security=True"
+ }
+}
diff --git a/Flashcards.Hillgrove/sql/create_tables.sql b/Flashcards.Hillgrove/sql/create_tables.sql
new file mode 100644
index 00000000..547c0424
--- /dev/null
+++ b/Flashcards.Hillgrove/sql/create_tables.sql
@@ -0,0 +1,22 @@
+CREATE TABLE Stack (
+ Id INT NOT NULL IDENTITY(1,1) PRIMARY KEY,
+ Name NVARCHAR(255) NOT NULL UNIQUE
+);
+
+CREATE TABLE Flashcard (
+ Id INT NOT NULL IDENTITY(1,1) PRIMARY KEY,
+ StackId INT NOT NULL,
+ Question NVARCHAR(MAX) NOT NULL,
+ Answer NVARCHAR(MAX) NOT NULL,
+
+ CONSTRAINT FK_FlashCard_Stack FOREIGN KEY (StackId) REFERENCES Stack(Id) ON DELETE CASCADE
+);
+
+CREATE TABLE StudySession (
+ Id INT NOT NULL IDENTITY(1,1) PRIMARY KEY,
+ StackId INT NOT NULL,
+ Date DATETIME2 NOT NULL,
+ Score INT NOT NULL,
+
+ CONSTRAINT FK_StudySession_Stack FOREIGN KEY (StackId) REFERENCES Stack(Id) ON DELETE CASCADE
+);
\ No newline at end of file
diff --git a/Flashcards.Hillgrove/sql/seed_report_data.sql b/Flashcards.Hillgrove/sql/seed_report_data.sql
new file mode 100644
index 00000000..ae99470c
--- /dev/null
+++ b/Flashcards.Hillgrove/sql/seed_report_data.sql
@@ -0,0 +1,104 @@
+BEGIN TRAN;
+
+SET NOCOUNT ON;
+
+DECLARE @BaseYear INT = YEAR(SYSUTCDATETIME());
+
+-- 1) Seed stacks (Stack: Id, Name)
+INSERT INTO Stack (Name)
+SELECT src.Name
+FROM (VALUES (N'C#'), (N'SQL'), (N'JavaScript')) AS src(Name)
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM Stack s
+ WHERE s.Name = src.Name
+);
+
+-- 2) Seed flashcards (Flashcard: Id, StackId, Question, Answer)
+INSERT INTO Flashcard (StackId, Question, Answer)
+SELECT s.Id, src.Question, src.Answer
+FROM (
+ VALUES
+ (N'C#', N'What keyword defines a class?', N'class'),
+ (N'C#', N'What does var do?', N'Implicit local typing'),
+ (N'C#', N'How do you declare an async method return type?', N'Task'),
+ (N'SQL', N'What clause filters rows?', N'WHERE'),
+ (N'SQL', N'What clause groups rows?', N'GROUP BY'),
+ (N'SQL', N'How do you sort result rows?', N'ORDER BY'),
+ (N'JavaScript', N'How do you declare a constant?', N'const'),
+ (N'JavaScript', N'How do you create an arrow function?', N'=>'),
+ (N'JavaScript', N'What method parses JSON text?', N'JSON.parse')
+) AS src(StackName, Question, Answer)
+INNER JOIN Stack s ON s.Name = src.StackName
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM Flashcard f
+ WHERE f.StackId = s.Id
+ AND f.Question = src.Question
+ AND f.Answer = src.Answer
+);
+
+-- 3) Seed study sessions for report output (StudySession: Id, StackId, Date, Score)
+WITH Numbers AS (
+ SELECT 1 AS N
+ UNION ALL SELECT 2
+ UNION ALL SELECT 3
+ UNION ALL SELECT 4
+ UNION ALL SELECT 5
+),
+Seed AS (
+ SELECT *
+ FROM (
+ VALUES
+ (N'C#', 0, 1, 3, 3),
+ (N'C#', 0, 2, 2, 2),
+ (N'C#', 0, 3, 4, 3),
+ (N'C#', 0, 5, 3, 3),
+ (N'C#', 0, 7, 2, 2),
+ (N'C#', 0, 10, 4, 3),
+ (N'SQL', 0, 1, 2, 2),
+ (N'SQL', 0, 4, 3, 2),
+ (N'SQL', 0, 6, 4, 3),
+ (N'SQL', 0, 9, 3, 2),
+ (N'SQL', 0, 12, 2, 2),
+ (N'JavaScript', 0, 2, 3, 1),
+ (N'JavaScript', 0, 3, 2, 2),
+ (N'JavaScript', 0, 6, 3, 2),
+ (N'JavaScript', 0, 8, 4, 3),
+ (N'JavaScript', 0, 11, 2, 2),
+ (N'C#', -1, 1, 2, 2),
+ (N'C#', -1, 6, 3, 3),
+ (N'C#', -1, 12, 2, 2),
+ (N'SQL', -1, 2, 2, 2),
+ (N'SQL', -1, 7, 3, 2),
+ (N'SQL', -1, 10, 2, 2),
+ (N'JavaScript',-1, 3, 2, 1),
+ (N'JavaScript',-1, 8, 3, 2),
+ (N'JavaScript',-1, 11, 2, 2)
+ ) AS t(StackName, YearOffset, MonthNumber, SessionCount, BaseScore)
+),
+Expanded AS (
+ SELECT
+ s.Id AS StackId,
+ DATEFROMPARTS(@BaseYear + seed.YearOffset, seed.MonthNumber, 2 + (num.N * 4)) AS SessionDate,
+ CASE
+ WHEN seed.BaseScore + (((num.N + seed.MonthNumber) % 3) - 1) < 0 THEN 0
+ WHEN seed.BaseScore + (((num.N + seed.MonthNumber) % 3) - 1) > 3 THEN 3
+ ELSE seed.BaseScore + (((num.N + seed.MonthNumber) % 3) - 1)
+ END AS SessionScore
+ FROM Seed seed
+ INNER JOIN Stack s ON s.Name = seed.StackName
+ INNER JOIN Numbers num ON num.N <= seed.SessionCount
+)
+INSERT INTO StudySession (StackId, Date, Score)
+SELECT exp.StackId, exp.SessionDate, exp.SessionScore
+FROM Expanded exp
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM StudySession ss
+ WHERE ss.StackId = exp.StackId
+ AND ss.Date = exp.SessionDate
+ AND ss.Score = exp.SessionScore
+);
+
+ROLLBACK;
\ No newline at end of file