From 043757e650ab276ed4b3e819f2b117a4dab4901f Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Fri, 10 Jul 2026 13:43:37 +0200 Subject: [PATCH 01/17] initial commit --- CodingTracker/CodingTracker.csproj | 14 ++++++++++++++ CodingTracker/CodingTracker.sln | 25 +++++++++++++++++++++++++ CodingTracker/Program.cs | 16 ++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 CodingTracker/CodingTracker.csproj create mode 100644 CodingTracker/CodingTracker.sln create mode 100644 CodingTracker/Program.cs diff --git a/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker.csproj new file mode 100644 index 000000000..04af11543 --- /dev/null +++ b/CodingTracker/CodingTracker.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/CodingTracker/CodingTracker.sln b/CodingTracker/CodingTracker.sln new file mode 100644 index 000000000..5a48df943 --- /dev/null +++ b/CodingTracker/CodingTracker.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36429.23 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker", "CodingTracker.csproj", "{594963C2-24C3-4E63-8C77-61866EE59BA0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {594963C2-24C3-4E63-8C77-61866EE59BA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {594963C2-24C3-4E63-8C77-61866EE59BA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {594963C2-24C3-4E63-8C77-61866EE59BA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {594963C2-24C3-4E63-8C77-61866EE59BA0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CB79C781-C2D7-4707-8D9E-F58220752302} + EndGlobalSection +EndGlobal diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs new file mode 100644 index 000000000..a1e722739 --- /dev/null +++ b/CodingTracker/Program.cs @@ -0,0 +1,16 @@ +using System; +using Spectre.Console; + +namespace CodeReviews.Console.CodingTracker +{ + class Program + { + static void Main(string[] args) + { + AnsiConsole.MarkupLine("[bold green]Welcome to the Coding Tracker![/]"); + AnsiConsole.MarkupLine("[bold yellow]This application helps you track your coding sessions.[/]"); + AnsiConsole.MarkupLine("[bold blue]Press any key to exit...[/]"); + AnsiConsole.Console.Input.ReadKey(true); + } + } +} \ No newline at end of file From 17137290d6d2fa4d5c15ceb8845985b3c9bf6aa4 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Sat, 11 Jul 2026 14:54:55 +0200 Subject: [PATCH 02/17] commit tree cleanup --- CodingTracker/.vscode/launch.json | 30 ++++++++++++++++++++++ CodingTracker/.vscode/tasks.json | 41 ++++++++++++++++++++++++++++++ CodingTracker/CodingTracker.csproj | 1 + CodingTracker/appsettings.json | 5 ++++ README.md | 0 5 files changed, 77 insertions(+) create mode 100644 CodingTracker/.vscode/launch.json create mode 100644 CodingTracker/.vscode/tasks.json create mode 100644 CodingTracker/appsettings.json create mode 100644 README.md diff --git a/CodingTracker/.vscode/launch.json b/CodingTracker/.vscode/launch.json new file mode 100644 index 000000000..808ad2a05 --- /dev/null +++ b/CodingTracker/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/CodeReviews.Console.CodingTracker/CodingTracker/bin/Debug/net9.0/CodingTracker.dll", + "args": [], + "cwd": "${workspaceFolder}/TCSA.OOP.LibraryManagementSystem", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "externalTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + + ] +} \ No newline at end of file diff --git a/CodingTracker/.vscode/tasks.json b/CodingTracker/.vscode/tasks.json new file mode 100644 index 000000000..8b680739f --- /dev/null +++ b/CodingTracker/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "/CodeReviews.Console.CodingTracker/CodingTracker/CodingTracker.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "/CodeReviews.Console.CodingTracker/CodingTracker/CodingTracker.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "/CodeReviews.Console.CodingTracker/CodingTracker/CodingTracker.sln", + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker.csproj index 04af11543..12e84c598 100644 --- a/CodingTracker/CodingTracker.csproj +++ b/CodingTracker/CodingTracker.csproj @@ -8,6 +8,7 @@ + diff --git a/CodingTracker/appsettings.json b/CodingTracker/appsettings.json new file mode 100644 index 000000000..8ff46b7c4 --- /dev/null +++ b/CodingTracker/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=coding-tracker.db" + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..e69de29bb From b5420bde7399d90e5d98f9e1f34081d53a00e47c Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Sat, 11 Jul 2026 16:16:50 +0200 Subject: [PATCH 03/17] initial menu setup --- .../{ => Configuration}/appsettings.json | 0 CodingTracker/Controller/AppController.cs | 56 +++++++++++++++++++ CodingTracker/Enums/MenuOptionClass.cs | 9 +++ CodingTracker/Model/CodingSession.cs | 12 ++++ CodingTracker/Program.cs | 8 +-- CodingTracker/View/AppView.cs | 36 ++++++++++++ 6 files changed, 117 insertions(+), 4 deletions(-) rename CodingTracker/{ => Configuration}/appsettings.json (100%) create mode 100644 CodingTracker/Controller/AppController.cs create mode 100644 CodingTracker/Enums/MenuOptionClass.cs create mode 100644 CodingTracker/Model/CodingSession.cs create mode 100644 CodingTracker/View/AppView.cs diff --git a/CodingTracker/appsettings.json b/CodingTracker/Configuration/appsettings.json similarity index 100% rename from CodingTracker/appsettings.json rename to CodingTracker/Configuration/appsettings.json diff --git a/CodingTracker/Controller/AppController.cs b/CodingTracker/Controller/AppController.cs new file mode 100644 index 000000000..5a434fa62 --- /dev/null +++ b/CodingTracker/Controller/AppController.cs @@ -0,0 +1,56 @@ +using System; +using Spectre.Console; +namespace CodeReviews.Console.CodingTracker; + +public sealed class AppController +{ + private readonly AppView _appView; + + public AppController(AppView _view) => _appView = _view; + + public void Run() + { + bool isRunning = true; + + while (isRunning) + { + MenuOption selectedOption = _appView.DisplayMainMenu(); + + switch (selectedOption) + { + case MenuOption.View: + ShowPlaceholder("View sessions"); + break; + + case MenuOption.Add: + ShowPlaceholder("Add session"); + break; + + case MenuOption.Edit: + ShowPlaceholder("Update session"); + break; + + case MenuOption.Delete: + ShowPlaceholder("Delete session"); + break; + + case MenuOption.Close: + isRunning = false; + break; + + default: + throw new ArgumentOutOfRangeException( + nameof(selectedOption), + selectedOption, + "Unknown menu option."); + } + } + } + + private static void ShowPlaceholder(string action) + { + AnsiConsole.WriteLine($"{action} is not implemented yet."); + AnsiConsole.WriteLine("Press any key to return to the menu."); + AnsiConsole.Console.Input.ReadKey(true); + } +} \ No newline at end of file diff --git a/CodingTracker/Enums/MenuOptionClass.cs b/CodingTracker/Enums/MenuOptionClass.cs new file mode 100644 index 000000000..8bd61f332 --- /dev/null +++ b/CodingTracker/Enums/MenuOptionClass.cs @@ -0,0 +1,9 @@ +public enum MenuOption +{ + Close, + View, + Add, + Edit, + Delete +} + diff --git a/CodingTracker/Model/CodingSession.cs b/CodingTracker/Model/CodingSession.cs new file mode 100644 index 000000000..5faaf02ca --- /dev/null +++ b/CodingTracker/Model/CodingSession.cs @@ -0,0 +1,12 @@ +using System; +using Spectre.Console; + +namespace CodeReviews.Console.CodingTracker; + +public class CodingSession +{ + public int Id { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public TimeSpan Duration => EndTime - StartTime; +} \ No newline at end of file diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs index a1e722739..7ceb9d8bd 100644 --- a/CodingTracker/Program.cs +++ b/CodingTracker/Program.cs @@ -7,10 +7,10 @@ class Program { static void Main(string[] args) { - AnsiConsole.MarkupLine("[bold green]Welcome to the Coding Tracker![/]"); - AnsiConsole.MarkupLine("[bold yellow]This application helps you track your coding sessions.[/]"); - AnsiConsole.MarkupLine("[bold blue]Press any key to exit...[/]"); - AnsiConsole.Console.Input.ReadKey(true); + var appView = new AppView(); + var appController = new AppController(appView); + + appController.Run(); } } } \ No newline at end of file diff --git a/CodingTracker/View/AppView.cs b/CodingTracker/View/AppView.cs new file mode 100644 index 000000000..3f120b347 --- /dev/null +++ b/CodingTracker/View/AppView.cs @@ -0,0 +1,36 @@ +using System; +using Spectre.Console; + +namespace CodeReviews.Console.CodingTracker; + +public class AppView +{ + public MenuOption DisplayMainMenu() + { + AnsiConsole.Clear(); + + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[green]Coding Tracker[/]") + .AddChoices(Enum.GetValues()) + .UseConverter(FormatMenuOption)); + } + + private static string FormatMenuOption(MenuOption option) + { + return option switch + { + MenuOption.View => "View coding sessions", + MenuOption.Add => "Add coding session", + MenuOption.Edit => "Update coding session", + MenuOption.Delete => "Delete coding session", + MenuOption.Close => "Exit", + _ => option.ToString() + }; + } + + public void DisplayGoodbye() + { + AnsiConsole.MarkupLine("[green]Goodbye![/]"); + } +} From 5c3bb1179541a7c7784829fbc7edfa6d7d43a27d Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Sun, 12 Jul 2026 16:39:52 +0200 Subject: [PATCH 04/17] app view base and interface are introduced --- CodingTracker/CodingTracker.csproj | 1 + CodingTracker/Controller/AppController.cs | 4 ++-- CodingTracker/Enums/MenuOptionClass.cs | 4 ++-- CodingTracker/Model/CodingSession.cs | 3 --- CodingTracker/Program.cs | 7 ++----- CodingTracker/View/AppView.cs | 21 ++++++++++++++++----- CodingTracker/View/IAppView.cs | 9 +++++++++ 7 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 CodingTracker/View/IAppView.cs diff --git a/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker.csproj index 12e84c598..cbdfcd702 100644 --- a/CodingTracker/CodingTracker.csproj +++ b/CodingTracker/CodingTracker.csproj @@ -9,6 +9,7 @@ + diff --git a/CodingTracker/Controller/AppController.cs b/CodingTracker/Controller/AppController.cs index 5a434fa62..2df0034c9 100644 --- a/CodingTracker/Controller/AppController.cs +++ b/CodingTracker/Controller/AppController.cs @@ -4,9 +4,9 @@ namespace CodeReviews.Console.CodingTracker; public sealed class AppController { - private readonly AppView _appView; + private readonly IAppView _appView; - public AppController(AppView _view) => _appView = _view; + public AppController(IAppView _view) => _appView = _view; public void Run() { diff --git a/CodingTracker/Enums/MenuOptionClass.cs b/CodingTracker/Enums/MenuOptionClass.cs index 8bd61f332..53c142f0b 100644 --- a/CodingTracker/Enums/MenuOptionClass.cs +++ b/CodingTracker/Enums/MenuOptionClass.cs @@ -1,9 +1,9 @@ public enum MenuOption { - Close, View, Add, Edit, - Delete + Delete, + Close, } diff --git a/CodingTracker/Model/CodingSession.cs b/CodingTracker/Model/CodingSession.cs index 5faaf02ca..d0e4ade22 100644 --- a/CodingTracker/Model/CodingSession.cs +++ b/CodingTracker/Model/CodingSession.cs @@ -1,6 +1,3 @@ -using System; -using Spectre.Console; - namespace CodeReviews.Console.CodingTracker; public class CodingSession diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs index 7ceb9d8bd..086572b1d 100644 --- a/CodingTracker/Program.cs +++ b/CodingTracker/Program.cs @@ -1,13 +1,10 @@ -using System; -using Spectre.Console; - -namespace CodeReviews.Console.CodingTracker +namespace CodeReviews.Console.CodingTracker { class Program { static void Main(string[] args) { - var appView = new AppView(); + IAppView appView = new AppView(); var appController = new AppController(appView); appController.Run(); diff --git a/CodingTracker/View/AppView.cs b/CodingTracker/View/AppView.cs index 3f120b347..4ae82a505 100644 --- a/CodingTracker/View/AppView.cs +++ b/CodingTracker/View/AppView.cs @@ -3,7 +3,7 @@ namespace CodeReviews.Console.CodingTracker; -public class AppView +public sealed class AppView : IAppView { public MenuOption DisplayMainMenu() { @@ -16,6 +16,21 @@ public MenuOption DisplayMainMenu() .UseConverter(FormatMenuOption)); } + public void DisplaySessions(List sessions) + { + throw new NotImplementedException(); + } + + public void DisplayMessage(string message) + { + AnsiConsole.MarkupLine(Markup.Escape(message)); + } + + public void DisplayGoodbye() + { + AnsiConsole.MarkupLine("[green]Goodbye![/]"); + } + private static string FormatMenuOption(MenuOption option) { return option switch @@ -29,8 +44,4 @@ private static string FormatMenuOption(MenuOption option) }; } - public void DisplayGoodbye() - { - AnsiConsole.MarkupLine("[green]Goodbye![/]"); - } } diff --git a/CodingTracker/View/IAppView.cs b/CodingTracker/View/IAppView.cs new file mode 100644 index 000000000..84f8644ea --- /dev/null +++ b/CodingTracker/View/IAppView.cs @@ -0,0 +1,9 @@ +namespace CodeReviews.Console.CodingTracker; + +public interface IAppView +{ + MenuOption DisplayMainMenu(); + void DisplaySessions(List sessions); + void DisplayMessage(string message); + void DisplayGoodbye(); +} \ No newline at end of file From fac009d8ddbcb147ef011b39472f05e465b9c070 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Mon, 13 Jul 2026 12:46:23 +0200 Subject: [PATCH 05/17] setup db config, connection factories and repositories --- CodingTracker/CodingTracker.csproj | 6 + CodingTracker/DB/DatabaseInitializer.cs | 26 ++++ .../DB/IDatabaseConnectionFactory.cs | 7 + CodingTracker/DB/SQLiteConnectionFactory.cs | 21 +++ CodingTracker/Model/CodingSession.cs | 2 +- CodingTracker/Model/CodingSessionDTO.cs | 9 ++ CodingTracker/Program.cs | 29 +++- .../Repositories/CodingSessionRepo.cs | 147 ++++++++++++++++++ .../Repositories/ICodingSessionRepo.cs | 10 ++ CodingTracker/coding-tracker.db | Bin 0 -> 12288 bytes 10 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 CodingTracker/DB/DatabaseInitializer.cs create mode 100644 CodingTracker/DB/IDatabaseConnectionFactory.cs create mode 100644 CodingTracker/DB/SQLiteConnectionFactory.cs create mode 100644 CodingTracker/Model/CodingSessionDTO.cs create mode 100644 CodingTracker/Repositories/CodingSessionRepo.cs create mode 100644 CodingTracker/Repositories/ICodingSessionRepo.cs create mode 100644 CodingTracker/coding-tracker.db diff --git a/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker.csproj index cbdfcd702..dc679a002 100644 --- a/CodingTracker/CodingTracker.csproj +++ b/CodingTracker/CodingTracker.csproj @@ -10,7 +10,13 @@ + + + + + PreserveNewest + diff --git a/CodingTracker/DB/DatabaseInitializer.cs b/CodingTracker/DB/DatabaseInitializer.cs new file mode 100644 index 000000000..731181945 --- /dev/null +++ b/CodingTracker/DB/DatabaseInitializer.cs @@ -0,0 +1,26 @@ +using Dapper; +namespace CodeReviews.Console.CodingTracker; + +public sealed class DatabaseInitializer +{ + private readonly IDatabaseConnectionFactory _connectionFactory; + public DatabaseInitializer(IDatabaseConnectionFactory _cf) => _connectionFactory = _cf; + + public void Initialize() + { + const string sql = """ + CREATE TABLE IF NOT EXISTS CodingSessions + ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + StartTime TEXT NOT NULL, + EndTime TEXT NOT NULL + ); + """; + + using var connection = _connectionFactory.CreateConnection(); + + connection.Open(); + connection.Execute(sql); + connection.Close(); + } +} \ No newline at end of file diff --git a/CodingTracker/DB/IDatabaseConnectionFactory.cs b/CodingTracker/DB/IDatabaseConnectionFactory.cs new file mode 100644 index 000000000..707ab6a7d --- /dev/null +++ b/CodingTracker/DB/IDatabaseConnectionFactory.cs @@ -0,0 +1,7 @@ +using System.Data.Common; +namespace CodeReviews.Console.CodingTracker; + +public interface IDatabaseConnectionFactory +{ + DbConnection CreateConnection(); +} \ No newline at end of file diff --git a/CodingTracker/DB/SQLiteConnectionFactory.cs b/CodingTracker/DB/SQLiteConnectionFactory.cs new file mode 100644 index 000000000..72ee439a2 --- /dev/null +++ b/CodingTracker/DB/SQLiteConnectionFactory.cs @@ -0,0 +1,21 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; + +namespace CodeReviews.Console.CodingTracker.Data; + +public sealed class SQLiteConnectionFactory : IDatabaseConnectionFactory +{ + private readonly string _connectionString; + + public SQLiteConnectionFactory(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException(); + + _connectionString = connectionString; + } + public DbConnection CreateConnection() + { + return new SqliteConnection(_connectionString); + } +} \ No newline at end of file diff --git a/CodingTracker/Model/CodingSession.cs b/CodingTracker/Model/CodingSession.cs index d0e4ade22..9a059fdb3 100644 --- a/CodingTracker/Model/CodingSession.cs +++ b/CodingTracker/Model/CodingSession.cs @@ -6,4 +6,4 @@ public class CodingSession public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public TimeSpan Duration => EndTime - StartTime; -} \ No newline at end of file +} diff --git a/CodingTracker/Model/CodingSessionDTO.cs b/CodingTracker/Model/CodingSessionDTO.cs new file mode 100644 index 000000000..7c6b7899c --- /dev/null +++ b/CodingTracker/Model/CodingSessionDTO.cs @@ -0,0 +1,9 @@ +namespace CodeReviews.Console.CodingTracker; +// the version that is returned by SQL +public class CodingSessionDTO +{ + public long Id { get; set; } + public string StartTime { get; set; } + public string EndTime { get; set; } + +} \ No newline at end of file diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs index 086572b1d..18705a9ec 100644 --- a/CodingTracker/Program.cs +++ b/CodingTracker/Program.cs @@ -1,13 +1,40 @@ -namespace CodeReviews.Console.CodingTracker +using CodeReviews.Console.CodingTracker.Data; +using Microsoft.Extensions.Configuration; + +namespace CodeReviews.Console.CodingTracker { class Program { static void Main(string[] args) { + IConfigurationRoot config = Setup(); + + string connectionString = config.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException( + "The DefaultConnection connection string is missing."); + + IDatabaseConnectionFactory connectionFactory = new SQLiteConnectionFactory(connectionString); + + var dbInitializer = new DatabaseInitializer(connectionFactory); + dbInitializer.Initialize(); + IAppView appView = new AppView(); var appController = new AppController(appView); appController.Run(); } + + private static IConfigurationRoot Setup() + { + IConfigurationRoot config = + new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile( + "Configuration/appsettings.json", + optional: false) + .Build(); + + return config; + } } } \ No newline at end of file diff --git a/CodingTracker/Repositories/CodingSessionRepo.cs b/CodingTracker/Repositories/CodingSessionRepo.cs new file mode 100644 index 000000000..7748cf260 --- /dev/null +++ b/CodingTracker/Repositories/CodingSessionRepo.cs @@ -0,0 +1,147 @@ +// data access layer: write sql queries here +namespace CodeReviews.Console.CodingTracker; + +using Dapper; +using Spectre.Console; +using System.Globalization; +using System.Linq; + +public sealed class CodingSessionRepo : ICodingSessionRepo +{ + private const string _dateFormat = "yyyy-MM-dd HH:mm:ss"; + private readonly IDatabaseConnectionFactory _connectionFactory; + + public CodingSessionRepo(IDatabaseConnectionFactory _cf) => _connectionFactory = _cf; + + public List GetAll() + { + List sessions = new(); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query); + + foreach (var row in rows) + { + sessions.Add(new CodingSession + { + Id = (int)row.Id, + StartTime = ParseDateFromString(row.StartTime), + EndTime = ParseDateFromString(row.EndTime), + }); + } + + connection.Close(); + } + + return sessions; + } + public CodingSession? GetOne(long id) + { + CodingSession? session; + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE Id = @Id; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + var record = connection.QuerySingleOrDefault(query, new { Id = id }); + + session = record == null + ? null + : new CodingSession + { + Id = (int)record.Id, + StartTime = ParseDateFromString(record.StartTime), + EndTime = ParseDateFromString(record.EndTime), + }; + connection.Close(); + } + + return session; + } + public void Add(CodingSession session) + { + if (session == null) + throw new ArgumentNullException(); + + const string query = @" + INSERT INTO CodingSessions (StartTime, EndTime) + VALUES (@StartTime, @EndTime) + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + var dto = new CodingSessionDTO + { + StartTime = FormatDateTimeToString(session.StartTime), + EndTime = FormatDateTimeToString(session.EndTime) + }; + connection.Open(); + connection.Execute(query, dto); + connection.Close(); + } + + } + public void Delete(long id) + { + const string query = @" + DELETE FROM CodingSessions + WHERE Id = @Id; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + connection.Execute(query, new { Id = id }); + connection.Close(); + } + } + public void Update(CodingSession session) + { + if (session == null) + throw new ArgumentNullException(); + + const string query = @" + UPDATE CodingSessions + SET StartTime = @StartTime, EndTime = @EndTime + WHERE Id = @Id; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + var dto = new CodingSessionDTO + { + StartTime = FormatDateTimeToString(session.StartTime), + EndTime = FormatDateTimeToString(session.EndTime) + }; + connection.Open(); + connection.Execute(query, dto); + connection.Close(); + } + } + + private static DateTime ParseDateFromString(string value) + => DateTime.ParseExact( + value, + _dateFormat, + CultureInfo.InvariantCulture); + + + private static string FormatDateTimeToString(DateTime obj) + => obj.ToString(_dateFormat, CultureInfo.InvariantCulture); + +} \ No newline at end of file diff --git a/CodingTracker/Repositories/ICodingSessionRepo.cs b/CodingTracker/Repositories/ICodingSessionRepo.cs new file mode 100644 index 000000000..64d7b0a1b --- /dev/null +++ b/CodingTracker/Repositories/ICodingSessionRepo.cs @@ -0,0 +1,10 @@ +namespace CodeReviews.Console.CodingTracker; + +public interface ICodingSessionRepo +{ + List GetAll(); + CodingSession? GetOne(long id); + void Add(CodingSession session); + void Update(CodingSession session); + void Delete(long id); +} \ No newline at end of file diff --git a/CodingTracker/coding-tracker.db b/CodingTracker/coding-tracker.db new file mode 100644 index 0000000000000000000000000000000000000000..36382d497fd58c64b6b29c27bdc588e671266416 GIT binary patch literal 12288 zcmeI%!AiqG5C-7gRulqJZ@JE~p`{mJz*rUurb$hrz=uXq!FY|Q%2RL_Uy za26|1E7IOgpy=T2sLDz+;V0eqsXTLBSAIC#nX@`Cs@KFcO;J}(zZ&!=_ItK9vMu89 zbMiy2ZdFW+*q?iGN_Q$HZ>b|cdEeC(q@}h7o{Ps9yEm!=Q0R>x~j^5_iSss zoAc6-fB*y_009U<00Izz00bZa0SG|ghy}WT^Vt87_ygnBAOHafKmY;|fB*y_009U< I00J!V4K_kp`2YX_ literal 0 HcmV?d00001 From 83c09aa1e8e2db231df9853ae89d92b1aedf1c34 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Mon, 13 Jul 2026 12:48:02 +0200 Subject: [PATCH 06/17] little fix --- CodingTracker/Model/CodingSession.cs | 2 +- CodingTracker/Repositories/CodingSessionRepo.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CodingTracker/Model/CodingSession.cs b/CodingTracker/Model/CodingSession.cs index 9a059fdb3..b35d1113a 100644 --- a/CodingTracker/Model/CodingSession.cs +++ b/CodingTracker/Model/CodingSession.cs @@ -2,7 +2,7 @@ namespace CodeReviews.Console.CodingTracker; public class CodingSession { - public int Id { get; set; } + public long Id { get; set; } public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public TimeSpan Duration => EndTime - StartTime; diff --git a/CodingTracker/Repositories/CodingSessionRepo.cs b/CodingTracker/Repositories/CodingSessionRepo.cs index 7748cf260..6e880715e 100644 --- a/CodingTracker/Repositories/CodingSessionRepo.cs +++ b/CodingTracker/Repositories/CodingSessionRepo.cs @@ -34,7 +34,7 @@ FROM CodingSessions { sessions.Add(new CodingSession { - Id = (int)row.Id, + Id = row.Id, StartTime = ParseDateFromString(row.StartTime), EndTime = ParseDateFromString(row.EndTime), }); @@ -64,7 +64,7 @@ FROM CodingSessions ? null : new CodingSession { - Id = (int)record.Id, + Id = record.Id, StartTime = ParseDateFromString(record.StartTime), EndTime = ParseDateFromString(record.EndTime), }; @@ -125,6 +125,7 @@ UPDATE CodingSessions { var dto = new CodingSessionDTO { + Id = session.Id, StartTime = FormatDateTimeToString(session.StartTime), EndTime = FormatDateTimeToString(session.EndTime) }; From be3a50a8a2fa9c47b8569192a1e48b59053e2559 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Mon, 13 Jul 2026 14:41:55 +0200 Subject: [PATCH 07/17] add services --- CodingTracker/Controller/AppController.cs | 1 - CodingTracker/Model/CodingSessionDTO.cs | 4 +- .../Repositories/CodingSessionRepo.cs | 20 ++++------ .../Repositories/ICodingSessionRepo.cs | 4 +- .../Services/CodingSessionService.cs | 40 +++++++++++++++++++ .../Services/ICodingSessionService.cs | 10 +++++ CodingTracker/Validators/Validators.cs | 13 ++++++ CodingTracker/View/AppView.cs | 1 - 8 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 CodingTracker/Services/CodingSessionService.cs create mode 100644 CodingTracker/Services/ICodingSessionService.cs create mode 100644 CodingTracker/Validators/Validators.cs diff --git a/CodingTracker/Controller/AppController.cs b/CodingTracker/Controller/AppController.cs index 2df0034c9..0ef15440e 100644 --- a/CodingTracker/Controller/AppController.cs +++ b/CodingTracker/Controller/AppController.cs @@ -1,4 +1,3 @@ -using System; using Spectre.Console; namespace CodeReviews.Console.CodingTracker; diff --git a/CodingTracker/Model/CodingSessionDTO.cs b/CodingTracker/Model/CodingSessionDTO.cs index 7c6b7899c..a860822a1 100644 --- a/CodingTracker/Model/CodingSessionDTO.cs +++ b/CodingTracker/Model/CodingSessionDTO.cs @@ -3,7 +3,7 @@ namespace CodeReviews.Console.CodingTracker; public class CodingSessionDTO { public long Id { get; set; } - public string StartTime { get; set; } - public string EndTime { get; set; } + public string? StartTime { get; set; } + public string? EndTime { get; set; } } \ No newline at end of file diff --git a/CodingTracker/Repositories/CodingSessionRepo.cs b/CodingTracker/Repositories/CodingSessionRepo.cs index 6e880715e..1346b9c6f 100644 --- a/CodingTracker/Repositories/CodingSessionRepo.cs +++ b/CodingTracker/Repositories/CodingSessionRepo.cs @@ -2,9 +2,7 @@ namespace CodeReviews.Console.CodingTracker; using Dapper; -using Spectre.Console; using System.Globalization; -using System.Linq; public sealed class CodingSessionRepo : ICodingSessionRepo { @@ -39,8 +37,6 @@ FROM CodingSessions EndTime = ParseDateFromString(row.EndTime), }); } - - connection.Close(); } return sessions; @@ -68,7 +64,6 @@ FROM CodingSessions StartTime = ParseDateFromString(record.StartTime), EndTime = ParseDateFromString(record.EndTime), }; - connection.Close(); } return session; @@ -92,25 +87,25 @@ INSERT INTO CodingSessions (StartTime, EndTime) }; connection.Open(); connection.Execute(query, dto); - connection.Close(); } } - public void Delete(long id) + public bool Delete(long id) { const string query = @" DELETE FROM CodingSessions WHERE Id = @Id; "; + int affectedRows; using (var connection = _connectionFactory.CreateConnection()) { connection.Open(); - connection.Execute(query, new { Id = id }); - connection.Close(); + affectedRows = connection.Execute(query, new { Id = id }); } + return affectedRows == 1; } - public void Update(CodingSession session) + public bool Update(CodingSession session) { if (session == null) throw new ArgumentNullException(); @@ -121,6 +116,7 @@ UPDATE CodingSessions WHERE Id = @Id; "; + int affectedRows; using (var connection = _connectionFactory.CreateConnection()) { var dto = new CodingSessionDTO @@ -130,9 +126,9 @@ UPDATE CodingSessions EndTime = FormatDateTimeToString(session.EndTime) }; connection.Open(); - connection.Execute(query, dto); - connection.Close(); + affectedRows = connection.Execute(query, dto); } + return affectedRows == 1; } private static DateTime ParseDateFromString(string value) diff --git a/CodingTracker/Repositories/ICodingSessionRepo.cs b/CodingTracker/Repositories/ICodingSessionRepo.cs index 64d7b0a1b..a366ad0f8 100644 --- a/CodingTracker/Repositories/ICodingSessionRepo.cs +++ b/CodingTracker/Repositories/ICodingSessionRepo.cs @@ -5,6 +5,6 @@ public interface ICodingSessionRepo List GetAll(); CodingSession? GetOne(long id); void Add(CodingSession session); - void Update(CodingSession session); - void Delete(long id); + bool Update(CodingSession session); + bool Delete(long id); } \ No newline at end of file diff --git a/CodingTracker/Services/CodingSessionService.cs b/CodingTracker/Services/CodingSessionService.cs new file mode 100644 index 000000000..bfaaa6746 --- /dev/null +++ b/CodingTracker/Services/CodingSessionService.cs @@ -0,0 +1,40 @@ +namespace CodeReviews.Console.CodingTracker; + +public sealed class CodingSessionService : ICodingSessionService +{ + private readonly ICodingSessionRepo _repository; + public CodingSessionService(ICodingSessionRepo repo) => _repository = repo; + + public void Add(DateTime startTime, DateTime endTime) + { + Validators.ValidateSessionTimes(startTime, endTime); + _repository.Add(new CodingSession + { + StartTime = startTime, + EndTime = endTime + }); + } + + public bool Delete(long id) => id <= 0 ? false : _repository.Delete(id); + + public List GetAll() => _repository.GetAll(); + + public CodingSession? GetOne(long id) => id <= 0 ? null : _repository.GetOne(id); + + public bool Update(long id, DateTime startTime, DateTime endTime) + { + if (id <= 0) return false; + + Validators.ValidateSessionTimes(startTime, endTime); + + CodingSession? session = + _repository.GetOne(id); + + if (session is null) return false; + + session.StartTime = startTime; + session.EndTime = endTime; + + return _repository.Update(session); + } +} \ No newline at end of file diff --git a/CodingTracker/Services/ICodingSessionService.cs b/CodingTracker/Services/ICodingSessionService.cs new file mode 100644 index 000000000..4b633b75c --- /dev/null +++ b/CodingTracker/Services/ICodingSessionService.cs @@ -0,0 +1,10 @@ +namespace CodeReviews.Console.CodingTracker; + +public interface ICodingSessionService +{ + List GetAll(); + CodingSession? GetOne(long id); + void Add(DateTime startTime, DateTime endTime); + bool Update(long id, DateTime startTime, DateTime endTime); + bool Delete(long id); +} \ No newline at end of file diff --git a/CodingTracker/Validators/Validators.cs b/CodingTracker/Validators/Validators.cs new file mode 100644 index 000000000..ad0e6959c --- /dev/null +++ b/CodingTracker/Validators/Validators.cs @@ -0,0 +1,13 @@ +public static class Validators +{ + public static void ValidateSessionTimes( + DateTime startTime, + DateTime endTime) + { + if (endTime <= startTime) + { + throw new ArgumentException( + "The end time must be later than the start time."); + } + } +} \ No newline at end of file diff --git a/CodingTracker/View/AppView.cs b/CodingTracker/View/AppView.cs index 4ae82a505..cda3af0db 100644 --- a/CodingTracker/View/AppView.cs +++ b/CodingTracker/View/AppView.cs @@ -1,4 +1,3 @@ -using System; using Spectre.Console; namespace CodeReviews.Console.CodingTracker; From 489b3c302e10aab1134147b878d1de0f65fc9337 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Mon, 13 Jul 2026 15:49:28 +0200 Subject: [PATCH 08/17] CRUD + views and controllers --- CodingTracker/Controller/AppController.cs | 24 ++--- .../Controller/CodingSessionController.cs | 80 ++++++++++++++ .../Controller/ICodingSessionController.cs | 10 ++ CodingTracker/Program.cs | 6 +- .../Repositories/CodingSessionRepo.cs | 43 +++++--- CodingTracker/View/AppView.cs | 8 -- CodingTracker/View/CodingSessionView.cs | 99 ++++++++++++++++++ CodingTracker/View/IAppView.cs | 1 - CodingTracker/View/ICodingSessionView.cs | 10 ++ CodingTracker/coding-tracker.db | Bin 12288 -> 12288 bytes 10 files changed, 246 insertions(+), 35 deletions(-) create mode 100644 CodingTracker/Controller/CodingSessionController.cs create mode 100644 CodingTracker/Controller/ICodingSessionController.cs create mode 100644 CodingTracker/View/CodingSessionView.cs create mode 100644 CodingTracker/View/ICodingSessionView.cs diff --git a/CodingTracker/Controller/AppController.cs b/CodingTracker/Controller/AppController.cs index 0ef15440e..6859a596d 100644 --- a/CodingTracker/Controller/AppController.cs +++ b/CodingTracker/Controller/AppController.cs @@ -4,8 +4,12 @@ namespace CodeReviews.Console.CodingTracker; public sealed class AppController { private readonly IAppView _appView; - - public AppController(IAppView _view) => _appView = _view; + private readonly ICodingSessionController _codingController; + public AppController(IAppView _view, ICodingSessionController _controller) + { + _appView = _view; + _codingController = _controller; + } public void Run() { @@ -18,19 +22,19 @@ public void Run() switch (selectedOption) { case MenuOption.View: - ShowPlaceholder("View sessions"); + _codingController.ViewSessions(); break; case MenuOption.Add: - ShowPlaceholder("Add session"); + _codingController.AddSession(); break; case MenuOption.Edit: - ShowPlaceholder("Update session"); + _codingController.UpdateSession(); break; case MenuOption.Delete: - ShowPlaceholder("Delete session"); + _codingController.DeleteSession(); break; case MenuOption.Close: @@ -44,12 +48,6 @@ public void Run() "Unknown menu option."); } } - } - - private static void ShowPlaceholder(string action) - { - AnsiConsole.WriteLine($"{action} is not implemented yet."); - AnsiConsole.WriteLine("Press any key to return to the menu."); - AnsiConsole.Console.Input.ReadKey(true); + _appView.DisplayGoodbye(); } } \ No newline at end of file diff --git a/CodingTracker/Controller/CodingSessionController.cs b/CodingTracker/Controller/CodingSessionController.cs new file mode 100644 index 000000000..1b664ba5f --- /dev/null +++ b/CodingTracker/Controller/CodingSessionController.cs @@ -0,0 +1,80 @@ +using Microsoft.Data.Sqlite; + +namespace CodeReviews.Console.CodingTracker; + +public sealed class CodingSessionController : ICodingSessionController +{ + private readonly ICodingSessionService _codingService; + private readonly ICodingSessionView _codingView; + + public CodingSessionController(ICodingSessionService service, ICodingSessionView view) + { + _codingService = service; + _codingView = view; + } + public void AddSession() + { + var (startTime, endTime) = _codingView.GetSessionTimes(); + + try + { + _codingService.Add(startTime, endTime); + _codingView.DisplayMessage("Coding session added successfully."); + } + catch (ArgumentException ex) + { + _codingView.DisplayError(ex.Message); + } + } + + public void DeleteSession() + { + long id = _codingView.GetSessionId("Enter the ID of the session to delete:"); + bool deleted; + try + { + deleted = _codingService.Delete(id); + _codingView.DisplayMessage("Coding session deleted successfully."); + } + catch (SqliteException ex) + { + _codingView.DisplayError($"Unexpected SQLite error {ex.SqliteErrorCode}: {ex.Message}"); + } + } + + public void UpdateSession() + { + long id = _codingView.GetSessionId("Enter the ID of the session to update:"); + var (startTime, endTime) = _codingView.GetSessionTimes(); + + try + { + bool updated = _codingService.Update(id, startTime, endTime); + if (!updated) + { + _codingView.DisplayError($"Coding session {id} was not found."); + return; + } + _codingView.DisplayMessage("Coding session updated successfully."); + } + catch (ArgumentException ex) + { + _codingView.DisplayError(ex.Message); + } + } + + public void ViewSessions() + { + List sessions; + + try + { + sessions = _codingService.GetAll(); + _codingView.DisplaySessions(sessions); + } + catch (SqliteException ex) + { + _codingView.DisplayError($"Unexpected SQLite error {ex.SqliteErrorCode}: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/CodingTracker/Controller/ICodingSessionController.cs b/CodingTracker/Controller/ICodingSessionController.cs new file mode 100644 index 000000000..d7110c5c2 --- /dev/null +++ b/CodingTracker/Controller/ICodingSessionController.cs @@ -0,0 +1,10 @@ +public interface ICodingSessionController +{ + void ViewSessions(); + + void AddSession(); + + void UpdateSession(); + + void DeleteSession(); +} \ No newline at end of file diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs index 18705a9ec..4bafff5e0 100644 --- a/CodingTracker/Program.cs +++ b/CodingTracker/Program.cs @@ -18,8 +18,12 @@ static void Main(string[] args) var dbInitializer = new DatabaseInitializer(connectionFactory); dbInitializer.Initialize(); + ICodingSessionRepo repository = new CodingSessionRepo(connectionFactory); + ICodingSessionService service = new CodingSessionService(repository); + ICodingSessionView codingSessionView = new CodingSessionView(); + ICodingSessionController codingSessionController = new CodingSessionController(service, codingSessionView); IAppView appView = new AppView(); - var appController = new AppController(appView); + var appController = new AppController(appView, codingSessionController); appController.Run(); } diff --git a/CodingTracker/Repositories/CodingSessionRepo.cs b/CodingTracker/Repositories/CodingSessionRepo.cs index 1346b9c6f..ce599b8bf 100644 --- a/CodingTracker/Repositories/CodingSessionRepo.cs +++ b/CodingTracker/Repositories/CodingSessionRepo.cs @@ -30,11 +30,17 @@ FROM CodingSessions foreach (var row in rows) { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + sessions.Add(new CodingSession { Id = row.Id, - StartTime = ParseDateFromString(row.StartTime), - EndTime = ParseDateFromString(row.EndTime), + StartTime = startTime, + EndTime = endTime, }); } } @@ -58,12 +64,15 @@ FROM CodingSessions session = record == null ? null - : new CodingSession - { - Id = record.Id, - StartTime = ParseDateFromString(record.StartTime), - EndTime = ParseDateFromString(record.EndTime), - }; + : TryParseDateFromString(record.StartTime, out var startTime) && + TryParseDateFromString(record.EndTime, out var endTime) + ? new CodingSession + { + Id = record.Id, + StartTime = startTime, + EndTime = endTime, + } + : null; } return session; @@ -131,12 +140,22 @@ UPDATE CodingSessions return affectedRows == 1; } - private static DateTime ParseDateFromString(string value) - => DateTime.ParseExact( + private static bool TryParseDateFromString(string? value, out DateTime result) + { + result = default; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return DateTime.TryParseExact( value, _dateFormat, - CultureInfo.InvariantCulture); - + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out result); + } private static string FormatDateTimeToString(DateTime obj) => obj.ToString(_dateFormat, CultureInfo.InvariantCulture); diff --git a/CodingTracker/View/AppView.cs b/CodingTracker/View/AppView.cs index cda3af0db..38e759521 100644 --- a/CodingTracker/View/AppView.cs +++ b/CodingTracker/View/AppView.cs @@ -14,22 +14,14 @@ public MenuOption DisplayMainMenu() .AddChoices(Enum.GetValues()) .UseConverter(FormatMenuOption)); } - - public void DisplaySessions(List sessions) - { - throw new NotImplementedException(); - } - public void DisplayMessage(string message) { AnsiConsole.MarkupLine(Markup.Escape(message)); } - public void DisplayGoodbye() { AnsiConsole.MarkupLine("[green]Goodbye![/]"); } - private static string FormatMenuOption(MenuOption option) { return option switch diff --git a/CodingTracker/View/CodingSessionView.cs b/CodingTracker/View/CodingSessionView.cs new file mode 100644 index 000000000..6c5023fa5 --- /dev/null +++ b/CodingTracker/View/CodingSessionView.cs @@ -0,0 +1,99 @@ + +using System.Globalization; +using Spectre.Console; +namespace CodeReviews.Console.CodingTracker; + +public sealed class CodingSessionView : ICodingSessionView +{ + private const string _inputDateFormat = "dd-MM-yyyy HH:mm"; + public void DisplayError(string message) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + WaitForInput(); + } + + public void DisplayMessage(string message) + => AnsiConsole.MarkupLine($"[green]{Markup.Escape(message)}[/]"); + + public void DisplaySessions(List sessions) + { + AnsiConsole.Clear(); + + if (sessions.Count == 0) + { + DisplayMessage("No records found."); + WaitForInput(); + return; + } + + var table = new Table() + .AddColumn("Id") + .AddColumn("Start time") + .AddColumn("End time") + .AddColumn("Duration"); + + foreach (var row in sessions) + { + table.AddRow( + row.Id.ToString(), + row.StartTime.ToString(_inputDateFormat), + row.EndTime.ToString(_inputDateFormat), + FormatDuration(row.Duration)); + } + + AnsiConsole.Write(table); + WaitForInput(); + } + + public long GetSessionId(string prompt) + { + return AnsiConsole.Prompt( + new TextPrompt(prompt) + .ValidationErrorMessage( + "[red]Enter a valid numeric ID.[/]") + .Validate(id => + id > 0 + ? ValidationResult.Success() + : ValidationResult.Error( + "[red]The ID must be positive.[/]"))); + } + + public (DateTime StartTime, DateTime EndTime) GetSessionTimes() + { + DateTime startTime = GetDateTime("Enter the start time:"); + DateTime endTime = GetDateTime("Enter the end time:"); + + return (startTime, endTime); + } + + private static DateTime GetDateTime(string prompt) + { + while (true) + { + string input = + AnsiConsole.Ask( + $"{prompt} [grey]({_inputDateFormat})[/]"); + + bool isValid = DateTime.TryParseExact( + input, + _inputDateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTime result); + + if (isValid) return result; + + AnsiConsole.MarkupLine( + $"[red]Use the format {_inputDateFormat}.[/]"); + } + } + private static string FormatDuration(TimeSpan duration) + => $"{(int)duration.TotalHours:D2}" + $":{duration.Minutes:D2}"; + + + private static void WaitForInput() + { + AnsiConsole.MarkupLine("\n[grey]Press any key to continue.[/]"); + AnsiConsole.Console.Input.ReadKey(true); + } +} \ No newline at end of file diff --git a/CodingTracker/View/IAppView.cs b/CodingTracker/View/IAppView.cs index 84f8644ea..7eed58ace 100644 --- a/CodingTracker/View/IAppView.cs +++ b/CodingTracker/View/IAppView.cs @@ -3,7 +3,6 @@ namespace CodeReviews.Console.CodingTracker; public interface IAppView { MenuOption DisplayMainMenu(); - void DisplaySessions(List sessions); void DisplayMessage(string message); void DisplayGoodbye(); } \ No newline at end of file diff --git a/CodingTracker/View/ICodingSessionView.cs b/CodingTracker/View/ICodingSessionView.cs new file mode 100644 index 000000000..be68f02ce --- /dev/null +++ b/CodingTracker/View/ICodingSessionView.cs @@ -0,0 +1,10 @@ +namespace CodeReviews.Console.CodingTracker; + +public interface ICodingSessionView +{ + void DisplaySessions(List sessions); + (DateTime StartTime, DateTime EndTime) GetSessionTimes(); + long GetSessionId(string prompt); + void DisplayMessage(string message); + void DisplayError(string message); +} \ No newline at end of file diff --git a/CodingTracker/coding-tracker.db b/CodingTracker/coding-tracker.db index 36382d497fd58c64b6b29c27bdc588e671266416..cae92d2ed9435a55a2ef822badc3ccf8b2b4c096 100644 GIT binary patch delta 109 zcmZojXh@hK&B#1a#+i|MW5N=CHb(v{4E$F%3ksa#H`8KdVK6o}GB7gJH89sTG*&P) zwK6rZGB7|EGP5!U3Gp&8Ffj7JW#E4c)ccrUSdfuflhZjrB{MHQIJLMqGe55w0FyQv AnE(I) delta 43 qcmZojXh@hK&B!=W#+i|EW5N=CCI*4cf(m!|CpO5j@iG7boC^R0OAA#1 From a3417ba0b7d12139135b69460b8b20a0881290ca Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Mon, 13 Jul 2026 16:05:47 +0200 Subject: [PATCH 09/17] UI shows tables before edit/delete --- CodingTracker/Controller/CodingSessionController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CodingTracker/Controller/CodingSessionController.cs b/CodingTracker/Controller/CodingSessionController.cs index 1b664ba5f..65584a1d8 100644 --- a/CodingTracker/Controller/CodingSessionController.cs +++ b/CodingTracker/Controller/CodingSessionController.cs @@ -29,6 +29,7 @@ public void AddSession() public void DeleteSession() { + ViewSessions(); long id = _codingView.GetSessionId("Enter the ID of the session to delete:"); bool deleted; try @@ -44,6 +45,7 @@ public void DeleteSession() public void UpdateSession() { + ViewSessions(); long id = _codingView.GetSessionId("Enter the ID of the session to update:"); var (startTime, endTime) = _codingView.GetSessionTimes(); From 96127cd4107b5761fa4de6c03d07fd9555bc3fe2 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Tue, 14 Jul 2026 09:49:35 +0200 Subject: [PATCH 10/17] validation of ids for update and delete --- .../Controller/CodingSessionController.cs | 32 ++++++++++++++++--- .../Controller/ICodingSessionController.cs | 2 +- CodingTracker/Validators/Validators.cs | 3 ++ CodingTracker/View/AppView.cs | 2 -- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/CodingTracker/Controller/CodingSessionController.cs b/CodingTracker/Controller/CodingSessionController.cs index 65584a1d8..4bae2e07c 100644 --- a/CodingTracker/Controller/CodingSessionController.cs +++ b/CodingTracker/Controller/CodingSessionController.cs @@ -29,12 +29,20 @@ public void AddSession() public void DeleteSession() { - ViewSessions(); + if(!ViewSessions()) + { + _codingView.DisplayMessage("No coding sessions available to delete."); + return; + } long id = _codingView.GetSessionId("Enter the ID of the session to delete:"); - bool deleted; try { - deleted = _codingService.Delete(id); + bool deleted = _codingService.Delete(id); + if (!deleted) + { + _codingView.DisplayError($"Coding session {id} was not found."); + return; + } _codingView.DisplayMessage("Coding session deleted successfully."); } catch (SqliteException ex) @@ -45,8 +53,20 @@ public void DeleteSession() public void UpdateSession() { - ViewSessions(); + if (!ViewSessions()) + { + _codingView.DisplayMessage("No coding sessions available to delete."); + return; + } long id = _codingView.GetSessionId("Enter the ID of the session to update:"); + + CodingSession? session = _codingService.GetOne(id); + if (session == null) + { + _codingView.DisplayError($"Coding session {id} was not found."); + return; + } + var (startTime, endTime) = _codingView.GetSessionTimes(); try @@ -65,7 +85,7 @@ public void UpdateSession() } } - public void ViewSessions() + public bool ViewSessions() { List sessions; @@ -73,10 +93,12 @@ public void ViewSessions() { sessions = _codingService.GetAll(); _codingView.DisplaySessions(sessions); + return sessions.Count > 0; } catch (SqliteException ex) { _codingView.DisplayError($"Unexpected SQLite error {ex.SqliteErrorCode}: {ex.Message}"); } + return false; } } \ No newline at end of file diff --git a/CodingTracker/Controller/ICodingSessionController.cs b/CodingTracker/Controller/ICodingSessionController.cs index d7110c5c2..232b316aa 100644 --- a/CodingTracker/Controller/ICodingSessionController.cs +++ b/CodingTracker/Controller/ICodingSessionController.cs @@ -1,6 +1,6 @@ public interface ICodingSessionController { - void ViewSessions(); + bool ViewSessions(); void AddSession(); diff --git a/CodingTracker/Validators/Validators.cs b/CodingTracker/Validators/Validators.cs index ad0e6959c..6b5bdbb27 100644 --- a/CodingTracker/Validators/Validators.cs +++ b/CodingTracker/Validators/Validators.cs @@ -1,3 +1,5 @@ +using CodeReviews.Console.CodingTracker; + public static class Validators { public static void ValidateSessionTimes( @@ -10,4 +12,5 @@ public static void ValidateSessionTimes( "The end time must be later than the start time."); } } + } \ No newline at end of file diff --git a/CodingTracker/View/AppView.cs b/CodingTracker/View/AppView.cs index 38e759521..e7ad80d04 100644 --- a/CodingTracker/View/AppView.cs +++ b/CodingTracker/View/AppView.cs @@ -6,8 +6,6 @@ public sealed class AppView : IAppView { public MenuOption DisplayMainMenu() { - AnsiConsole.Clear(); - return AnsiConsole.Prompt( new SelectionPrompt() .Title("[green]Coding Tracker[/]") From 9f8db2bca114d55511bba59bbd0eab3076d08467 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Tue, 14 Jul 2026 15:10:55 +0200 Subject: [PATCH 11/17] filtering logic added (not perfect) --- .../Controller/CodingSessionController.cs | 74 +++++++-- .../Controller/ICodingSessionController.cs | 2 +- CodingTracker/Enums/FilterOption.cs | 9 + .../{MenuOptionClass.cs => MenuOption.cs} | 0 CodingTracker/Enums/Month.cs | 15 ++ .../Repositories/CodingSessionRepo.cs | 157 ++++++++++++++++++ .../Repositories/ICodingSessionRepo.cs | 7 + .../Services/CodingSessionService.cs | 27 +++ .../Services/ICodingSessionService.cs | 4 + CodingTracker/View/CodingSessionView.cs | 61 ++++--- CodingTracker/View/ICodingSessionView.cs | 4 + CodingTracker/coding-tracker.db | Bin 12288 -> 12288 bytes 12 files changed, 328 insertions(+), 32 deletions(-) create mode 100644 CodingTracker/Enums/FilterOption.cs rename CodingTracker/Enums/{MenuOptionClass.cs => MenuOption.cs} (100%) create mode 100644 CodingTracker/Enums/Month.cs diff --git a/CodingTracker/Controller/CodingSessionController.cs b/CodingTracker/Controller/CodingSessionController.cs index 4bae2e07c..0ff239da7 100644 --- a/CodingTracker/Controller/CodingSessionController.cs +++ b/CodingTracker/Controller/CodingSessionController.cs @@ -29,7 +29,8 @@ public void AddSession() public void DeleteSession() { - if(!ViewSessions()) + List? sessions = GetSessionsSafely(_codingService.GetAll); + if (sessions == null || sessions.Count == 0) { _codingView.DisplayMessage("No coding sessions available to delete."); return; @@ -53,9 +54,10 @@ public void DeleteSession() public void UpdateSession() { - if (!ViewSessions()) + List? sessions = GetSessionsSafely(_codingService.GetAll); + if (sessions == null || sessions.Count == 0) { - _codingView.DisplayMessage("No coding sessions available to delete."); + _codingView.DisplayMessage("No coding sessions available to update."); return; } long id = _codingView.GetSessionId("Enter the ID of the session to update:"); @@ -85,20 +87,70 @@ public void UpdateSession() } } - public bool ViewSessions() + public void ViewSessions() { - List sessions; + List? sessions = GetSessionsSafely(_codingService.GetAll); + if (sessions is null) return; + _codingView.DisplaySessions(sessions); + if (sessions.Count == 0) return; + + while (true) + { + FilterOption opt = _codingView.DisplayFilterMenu(); + if (opt == FilterOption.Back) return; + + List? filtered = GetFilteredSessions(opt); + + if (filtered == null) return; + + sessions = filtered; + } + + } + private List? GetSessionsSafely(Func> retrieval) + { try { - sessions = _codingService.GetAll(); - _codingView.DisplaySessions(sessions); - return sessions.Count > 0; + return retrieval(); } - catch (SqliteException ex) + catch (Exception ex) { - _codingView.DisplayError($"Unexpected SQLite error {ex.SqliteErrorCode}: {ex.Message}"); + _codingView.DisplayError($"Unexpected error: {ex.Message}"); + return null; + } + } + private List? GetFilteredSessions(FilterOption option) + { + switch (option) + { + case FilterOption.ShowAll: + return GetSessionsSafely(_codingService.GetAll); + case FilterOption.Day: + { + DateTime date = _codingView.GetFilterDate("Enter the date:"); + return GetSessionsSafely(() => _codingService.GetByDay(date)); + } + case FilterOption.Week: + { + DateTime date = _codingView.GetFilterDate("Enter any date within the desired week:"); + return GetSessionsSafely(() => _codingService.GetByWeek(date)); + } + case FilterOption.Month: + { + Month month = _codingView.GetMonth(); + int year = _codingView.GetYear(); + return GetSessionsSafely(() => _codingService.GetByMonthOfYear(year, (int)month)); + } + + case FilterOption.Year: + { + int year = _codingView.GetYear(); + return GetSessionsSafely(() => _codingService.GetByYear(year)); + } + + default: + throw new ArgumentOutOfRangeException(nameof(option), option, "Unknown filter option."); } - return false; } } \ No newline at end of file diff --git a/CodingTracker/Controller/ICodingSessionController.cs b/CodingTracker/Controller/ICodingSessionController.cs index 232b316aa..d7110c5c2 100644 --- a/CodingTracker/Controller/ICodingSessionController.cs +++ b/CodingTracker/Controller/ICodingSessionController.cs @@ -1,6 +1,6 @@ public interface ICodingSessionController { - bool ViewSessions(); + void ViewSessions(); void AddSession(); diff --git a/CodingTracker/Enums/FilterOption.cs b/CodingTracker/Enums/FilterOption.cs new file mode 100644 index 000000000..57c62ae3c --- /dev/null +++ b/CodingTracker/Enums/FilterOption.cs @@ -0,0 +1,9 @@ +public enum FilterOption +{ + ShowAll, + Day, + Week, + Month, + Year, + Back +} \ No newline at end of file diff --git a/CodingTracker/Enums/MenuOptionClass.cs b/CodingTracker/Enums/MenuOption.cs similarity index 100% rename from CodingTracker/Enums/MenuOptionClass.cs rename to CodingTracker/Enums/MenuOption.cs diff --git a/CodingTracker/Enums/Month.cs b/CodingTracker/Enums/Month.cs new file mode 100644 index 000000000..20d6d8bed --- /dev/null +++ b/CodingTracker/Enums/Month.cs @@ -0,0 +1,15 @@ +public enum Month +{ + January = 1, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December +} \ No newline at end of file diff --git a/CodingTracker/Repositories/CodingSessionRepo.cs b/CodingTracker/Repositories/CodingSessionRepo.cs index ce599b8bf..1631ab3e3 100644 --- a/CodingTracker/Repositories/CodingSessionRepo.cs +++ b/CodingTracker/Repositories/CodingSessionRepo.cs @@ -139,6 +139,163 @@ UPDATE CodingSessions } return affectedRows == 1; } + public List GetSessionsByDay(DateTime day) + { + List sessions = new(); + + var start = day.Date; + var end = day.Date.AddDays(1); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE StartTime >= @Start AND StartTime < @End + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query, new { Start = start, End = end }); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } + public List GetSessionsInBetweenDates(DateTime start, DateTime end) + { + List sessions = new(); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE StartTime >= @Start AND StartTime < @End + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query, new { Start = start, End = end }); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } + public List GetSessionsByMonthOfYear(int year, int monthIndicator) + { + List sessions = new(); + + var start = new DateTime(year, monthIndicator, 1); + var end = start.AddMonths(1); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE StartTime >= @Start AND StartTime < @End + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query, new { Start = start, End = end }); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } + public List GetSessionsByYear(int year) + { + List sessions = new(); + + var start = new DateTime(year, 1, 1); + var end = start.AddYears(1); + + const string query = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE StartTime >= @Start AND StartTime < @End + ORDER BY StartTime DESC; + "; + + using (var connection = _connectionFactory.CreateConnection()) + { + connection.Open(); + + IEnumerable rows = + connection.Query(query, new { Start = start, End = end }); + + foreach (var row in rows) + { + if (!TryParseDateFromString(row.StartTime, out var startTime) || + !TryParseDateFromString(row.EndTime, out var endTime)) + { + continue; + } + + sessions.Add(new CodingSession + { + Id = row.Id, + StartTime = startTime, + EndTime = endTime, + }); + } + } + + return sessions; + } private static bool TryParseDateFromString(string? value, out DateTime result) { diff --git a/CodingTracker/Repositories/ICodingSessionRepo.cs b/CodingTracker/Repositories/ICodingSessionRepo.cs index a366ad0f8..90cd17084 100644 --- a/CodingTracker/Repositories/ICodingSessionRepo.cs +++ b/CodingTracker/Repositories/ICodingSessionRepo.cs @@ -2,9 +2,16 @@ namespace CodeReviews.Console.CodingTracker; public interface ICodingSessionRepo { + // CRUD methods List GetAll(); CodingSession? GetOne(long id); void Add(CodingSession session); bool Update(CodingSession session); bool Delete(long id); + + // Filtering methods + List GetSessionsByDay(DateTime day); + List GetSessionsInBetweenDates(DateTime start, DateTime end); + List GetSessionsByMonthOfYear(int year, int monthIndicator); + List GetSessionsByYear(int year); } \ No newline at end of file diff --git a/CodingTracker/Services/CodingSessionService.cs b/CodingTracker/Services/CodingSessionService.cs index bfaaa6746..d97429953 100644 --- a/CodingTracker/Services/CodingSessionService.cs +++ b/CodingTracker/Services/CodingSessionService.cs @@ -37,4 +37,31 @@ public bool Update(long id, DateTime startTime, DateTime endTime) return _repository.Update(session); } + public List GetByDay(DateTime date) => _repository.GetSessionsByDay(date); + public List GetByWeek(DateTime date) + { + int daysSinceMonday = ((int)date.DayOfWeek + 6) % 7; + DateTime startOfWeek = date.Date.AddDays(-daysSinceMonday); + DateTime endOfWeek = startOfWeek.AddDays(7); + + return _repository.GetSessionsInBetweenDates(startOfWeek, endOfWeek); + } + + public List GetByMonthOfYear(int year, int month) + { + if (year < 1 || year > 9999) + throw new ArgumentOutOfRangeException(nameof(year)); + + if (month < 1 || month > 12) + throw new ArgumentOutOfRangeException(nameof(month)); + + return _repository.GetSessionsByMonthOfYear(year, month); + } + public List GetByYear(int year) + { + if (year < 1 || year > 9999) + throw new ArgumentOutOfRangeException(nameof(year)); + + return _repository.GetSessionsByYear(year); + } } \ No newline at end of file diff --git a/CodingTracker/Services/ICodingSessionService.cs b/CodingTracker/Services/ICodingSessionService.cs index 4b633b75c..ff8840ded 100644 --- a/CodingTracker/Services/ICodingSessionService.cs +++ b/CodingTracker/Services/ICodingSessionService.cs @@ -7,4 +7,8 @@ public interface ICodingSessionService void Add(DateTime startTime, DateTime endTime); bool Update(long id, DateTime startTime, DateTime endTime); bool Delete(long id); + List GetByDay(DateTime date); + List GetByWeek(DateTime date); + List GetByMonthOfYear(int year, int month); + List GetByYear(int year); } \ No newline at end of file diff --git a/CodingTracker/View/CodingSessionView.cs b/CodingTracker/View/CodingSessionView.cs index 6c5023fa5..8917442e0 100644 --- a/CodingTracker/View/CodingSessionView.cs +++ b/CodingTracker/View/CodingSessionView.cs @@ -6,6 +6,7 @@ namespace CodeReviews.Console.CodingTracker; public sealed class CodingSessionView : ICodingSessionView { private const string _inputDateFormat = "dd-MM-yyyy HH:mm"; + private const string _filterDateFormat = "dd-MM-yyyy"; public void DisplayError(string message) { AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); @@ -22,7 +23,6 @@ public void DisplaySessions(List sessions) if (sessions.Count == 0) { DisplayMessage("No records found."); - WaitForInput(); return; } @@ -42,7 +42,6 @@ public void DisplaySessions(List sessions) } AnsiConsole.Write(table); - WaitForInput(); } public long GetSessionId(string prompt) @@ -60,31 +59,30 @@ public long GetSessionId(string prompt) public (DateTime StartTime, DateTime EndTime) GetSessionTimes() { - DateTime startTime = GetDateTime("Enter the start time:"); - DateTime endTime = GetDateTime("Enter the end time:"); + DateTime startTime = GetDateTime("Enter the start time:", _inputDateFormat); + DateTime endTime = GetDateTime("Enter the end time:", _inputDateFormat); return (startTime, endTime); } - private static DateTime GetDateTime(string prompt) + private static DateTime GetDateTime(string prompt, string format) { while (true) { - string input = - AnsiConsole.Ask( - $"{prompt} [grey]({_inputDateFormat})[/]"); - - bool isValid = DateTime.TryParseExact( - input, - _inputDateFormat, - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out DateTime result); - - if (isValid) return result; - - AnsiConsole.MarkupLine( - $"[red]Use the format {_inputDateFormat}.[/]"); + string input = AnsiConsole.Ask( + $"{prompt} [grey]({format})[/]"); + + if (DateTime.TryParseExact( + input, + format, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTime result)) + { + return result; + } + + AnsiConsole.MarkupLine($"[red]Use the format {format}.[/]"); } } private static string FormatDuration(TimeSpan duration) @@ -96,4 +94,27 @@ private static void WaitForInput() AnsiConsole.MarkupLine("\n[grey]Press any key to continue.[/]"); AnsiConsole.Console.Input.ReadKey(true); } + + public Month GetMonth() + => AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a month:") + .AddChoices(Enum.GetValues())); + + public int GetYear() + => AnsiConsole.Prompt( + new TextPrompt("Enter the year:") + .Validate(year => + year is >= 1 and <= 9999 + ? ValidationResult.Success() + : ValidationResult.Error( + "[red]Enter a valid year.[/]"))); + + public FilterOption DisplayFilterMenu() + => AnsiConsole.Prompt( + new SelectionPrompt() + .Title("\nHow would you like to filter the sessions?") + .AddChoices(Enum.GetValues())); + + public DateTime GetFilterDate(string prompt) => GetDateTime(prompt, _filterDateFormat); } \ No newline at end of file diff --git a/CodingTracker/View/ICodingSessionView.cs b/CodingTracker/View/ICodingSessionView.cs index be68f02ce..f9e829e30 100644 --- a/CodingTracker/View/ICodingSessionView.cs +++ b/CodingTracker/View/ICodingSessionView.cs @@ -7,4 +7,8 @@ public interface ICodingSessionView long GetSessionId(string prompt); void DisplayMessage(string message); void DisplayError(string message); + Month GetMonth(); + int GetYear(); + FilterOption DisplayFilterMenu(); + DateTime GetFilterDate(string prompt); } \ No newline at end of file diff --git a/CodingTracker/coding-tracker.db b/CodingTracker/coding-tracker.db index cae92d2ed9435a55a2ef822badc3ccf8b2b4c096..14f351cce9066c956c2f4766ff360a99b191bed1 100644 GIT binary patch delta 114 zcmZojXh@hK&B!`Y#+i|IW5N=C4krE;4E$I4S8Ns(n8UB5#l*s3Y;0s;WTtCiu4`zb zU}$V*Xli9(fGh;!1JUFw@@Cb9eMx& delta 66 zcmZojXh@hK&B#1a#+i|MW5N=CHb(v{4E$F%3o4xApL|8$h~+H<|63sMHvhx`4q-t? VW=&4#{FKbR^x)Lu;>`TKVgRrr6%zme From b5aee90eca3746bb453711223af3c4e2c9e81567 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Tue, 14 Jul 2026 15:47:40 +0200 Subject: [PATCH 12/17] minor fixes --- CodingTracker/Controller/CodingSessionController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodingTracker/Controller/CodingSessionController.cs b/CodingTracker/Controller/CodingSessionController.cs index 0ff239da7..99796bf6a 100644 --- a/CodingTracker/Controller/CodingSessionController.cs +++ b/CodingTracker/Controller/CodingSessionController.cs @@ -91,7 +91,6 @@ public void ViewSessions() { List? sessions = GetSessionsSafely(_codingService.GetAll); if (sessions is null) return; - _codingView.DisplaySessions(sessions); if (sessions.Count == 0) return; while (true) @@ -104,6 +103,7 @@ public void ViewSessions() if (filtered == null) return; sessions = filtered; + _codingView.DisplaySessions(sessions); } } From 6b346046512e0d2834103b5dd7cfdf39c7cb2001 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Tue, 14 Jul 2026 16:10:00 +0200 Subject: [PATCH 13/17] stopwatch ability --- .../Controller/CodingSessionController.cs | 60 +++++++++++++++--- CodingTracker/Enums/AddSessionOption.cs | 6 ++ CodingTracker/View/CodingSessionView.cs | 57 ++++++++++------- CodingTracker/View/ICodingSessionView.cs | 2 + CodingTracker/coding-tracker.db | Bin 12288 -> 12288 bytes 5 files changed, 94 insertions(+), 31 deletions(-) create mode 100644 CodingTracker/Enums/AddSessionOption.cs diff --git a/CodingTracker/Controller/CodingSessionController.cs b/CodingTracker/Controller/CodingSessionController.cs index 99796bf6a..e14674324 100644 --- a/CodingTracker/Controller/CodingSessionController.cs +++ b/CodingTracker/Controller/CodingSessionController.cs @@ -14,16 +14,26 @@ public CodingSessionController(ICodingSessionService service, ICodingSessionView } public void AddSession() { - var (startTime, endTime) = _codingView.GetSessionTimes(); + AddSessionOption opt = _codingView.DisplayAddSessionMenu(); - try + switch (opt) { - _codingService.Add(startTime, endTime); - _codingView.DisplayMessage("Coding session added successfully."); - } - catch (ArgumentException ex) - { - _codingView.DisplayError(ex.Message); + case AddSessionOption.Manual: + AddManualSession(); + break; + + case AddSessionOption.Stopwatch: + AddTimedSession(); + break; + + case AddSessionOption.Back: + return; + + default: + throw new ArgumentOutOfRangeException( + nameof(opt), + opt, + "Unknown add-session option."); } } @@ -153,4 +163,38 @@ public void ViewSessions() throw new ArgumentOutOfRangeException(nameof(option), option, "Unknown filter option."); } } + + private void AddManualSession() + { + var (startTime, endTime) = _codingView.GetSessionTimes(); + + try + { + _codingService.Add(startTime, endTime); + _codingView.DisplayMessage("Coding session added successfully."); + } + catch (ArgumentException ex) + { + _codingView.DisplayError(ex.Message); + } + } + + private void AddTimedSession() + { + DateTime startTime = DateTime.Now; + + _codingView.WaitStopwatch(); + + DateTime endTime = DateTime.Now; + + try + { + _codingService.Add(startTime, endTime); + _codingView.DisplayMessage($"Session recorded. Duration: " + $"{endTime - startTime:hh\\:mm\\:ss}"); + } + catch (ArgumentException ex) + { + _codingView.DisplayError(ex.Message); + } + } } \ No newline at end of file diff --git a/CodingTracker/Enums/AddSessionOption.cs b/CodingTracker/Enums/AddSessionOption.cs new file mode 100644 index 000000000..5c7546c35 --- /dev/null +++ b/CodingTracker/Enums/AddSessionOption.cs @@ -0,0 +1,6 @@ +public enum AddSessionOption +{ + Manual, + Stopwatch, + Back, +} diff --git a/CodingTracker/View/CodingSessionView.cs b/CodingTracker/View/CodingSessionView.cs index 8917442e0..7ea5feabc 100644 --- a/CodingTracker/View/CodingSessionView.cs +++ b/CodingTracker/View/CodingSessionView.cs @@ -65,6 +65,40 @@ public long GetSessionId(string prompt) return (startTime, endTime); } + public Month GetMonth() + => AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a month:") + .AddChoices(Enum.GetValues())); + + public int GetYear() + => AnsiConsole.Prompt( + new TextPrompt("Enter the year:") + .Validate(year => + year is >= 1 and <= 9999 + ? ValidationResult.Success() + : ValidationResult.Error( + "[red]Enter a valid year.[/]"))); + + public FilterOption DisplayFilterMenu() + => AnsiConsole.Prompt( + new SelectionPrompt() + .Title("\nHow would you like to filter the sessions?") + .AddChoices(Enum.GetValues())); + + public DateTime GetFilterDate(string prompt) => GetDateTime(prompt, _filterDateFormat); + + public AddSessionOption DisplayAddSessionMenu() + => AnsiConsole.Prompt( + new SelectionPrompt() + .Title("How would you like to add a session?") + .AddChoices(Enum.GetValues())); + public void WaitStopwatch() + { + AnsiConsole.MarkupLine("[green]Timer started.[/] Press [yellow]Enter[/] to stop."); + while (System.Console.ReadKey(true).Key != ConsoleKey.Enter) ; + } + private static DateTime GetDateTime(string prompt, string format) { while (true) @@ -94,27 +128,4 @@ private static void WaitForInput() AnsiConsole.MarkupLine("\n[grey]Press any key to continue.[/]"); AnsiConsole.Console.Input.ReadKey(true); } - - public Month GetMonth() - => AnsiConsole.Prompt( - new SelectionPrompt() - .Title("Select a month:") - .AddChoices(Enum.GetValues())); - - public int GetYear() - => AnsiConsole.Prompt( - new TextPrompt("Enter the year:") - .Validate(year => - year is >= 1 and <= 9999 - ? ValidationResult.Success() - : ValidationResult.Error( - "[red]Enter a valid year.[/]"))); - - public FilterOption DisplayFilterMenu() - => AnsiConsole.Prompt( - new SelectionPrompt() - .Title("\nHow would you like to filter the sessions?") - .AddChoices(Enum.GetValues())); - - public DateTime GetFilterDate(string prompt) => GetDateTime(prompt, _filterDateFormat); } \ No newline at end of file diff --git a/CodingTracker/View/ICodingSessionView.cs b/CodingTracker/View/ICodingSessionView.cs index f9e829e30..774f6e667 100644 --- a/CodingTracker/View/ICodingSessionView.cs +++ b/CodingTracker/View/ICodingSessionView.cs @@ -11,4 +11,6 @@ public interface ICodingSessionView int GetYear(); FilterOption DisplayFilterMenu(); DateTime GetFilterDate(string prompt); + AddSessionOption DisplayAddSessionMenu(); + void WaitStopwatch(); } \ No newline at end of file diff --git a/CodingTracker/coding-tracker.db b/CodingTracker/coding-tracker.db index 14f351cce9066c956c2f4766ff360a99b191bed1..32cb20417999dad670ed80646f8d3ae0e1a0fa7a 100644 GIT binary patch delta 85 zcmZojXh@hK&B!)U#+i|AW5N=CE@u832L3DjEBI?R3ku}%>u52vFc=#f85o)A8kp-E cnkX2WSs9pEnV6voSy~xdY+fNBF2KkP0C(3DS^xk5 delta 38 ucmZojXh@hK&B!`Y#+i|IW5N=C4krE;4E$I4S8Ntkn8Uw$g?zXGBNG7KtP1)7 From a45a8135062bb06f7d90215698916111e46ed797 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Tue, 14 Jul 2026 18:28:56 +0200 Subject: [PATCH 14/17] some unit tests --- .../CodingTracker.Tests.csproj | 27 ++ CodingTracker.Tests/CrudAndFilterTests.cs | 243 ++++++++++++++++++ CodingTracker.Tests/RepositoryTestBase.cs | 36 +++ CodingTracker/CodingTracker.sln | 8 +- CodingTracker/DB/SQLiteConnectionFactory.cs | 2 +- 5 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 CodingTracker.Tests/CodingTracker.Tests.csproj create mode 100644 CodingTracker.Tests/CrudAndFilterTests.cs create mode 100644 CodingTracker.Tests/RepositoryTestBase.cs diff --git a/CodingTracker.Tests/CodingTracker.Tests.csproj b/CodingTracker.Tests/CodingTracker.Tests.csproj new file mode 100644 index 000000000..8c0dbaefd --- /dev/null +++ b/CodingTracker.Tests/CodingTracker.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/CodingTracker.Tests/CrudAndFilterTests.cs b/CodingTracker.Tests/CrudAndFilterTests.cs new file mode 100644 index 000000000..53a64daa9 --- /dev/null +++ b/CodingTracker.Tests/CrudAndFilterTests.cs @@ -0,0 +1,243 @@ +using CodeReviews.Console.CodingTracker; +using Dapper; +namespace CodingTracker.Tests; + +[TestFixture] +[NonParallelizable] +public sealed class CrudAndFilterTests : RepositoryTestBase +{ + private static readonly List sessionTestCases = [ + new TestCaseData( + new DateTime(2026, 7, 14, 9, 0, 0), + new DateTime(2026, 7, 14, 10, 30, 0)) + .SetName("Session_OnSameDay"), + + new TestCaseData( + new DateTime(2026, 7, 14, 23, 30, 0), + new DateTime(2026, 7, 15, 0, 45, 0)) + .SetName("Session_AcrossMidnight"), + + new TestCaseData( + new DateTime(2026, 1, 31, 22, 0, 0), + new DateTime(2026, 2, 1, 1, 0, 0)) + .SetName("Session_AcrossMonthBoundary"), + + new TestCaseData( + new DateTime(2025, 12, 31, 23, 0, 0), + new DateTime(2026, 1, 1, 2, 0, 0)) + .SetName("Session_AcrossYearBoundary"), + + new TestCaseData( + new DateTime(2026, 7, 14, 12, 0, 15), + new DateTime(2026, 7, 14, 12, 0, 45)) + .SetName("Session_WithSeconds") + ]; + + [Test] + public void GetAll_NewDatabase_ReturnsEmptyList() + { + List sessions = repository.GetAll(); + Assert.That(sessions, Is.Empty); + } + + [TestCaseSource(nameof(sessionTestCases))] + public void Add_ValidSession_SavesSession(DateTime start, DateTime end) + { + var session = new CodingSession + { + StartTime = start, + EndTime = end + }; + + repository.Add(session); + + List result = repository.GetAll(); + + Assert.That(result, Has.Count.EqualTo(1)); + + CodingSession saved = result.Single(); + + Assert.Multiple(() => + { + Assert.That(saved.Id, Is.GreaterThan(0)); + Assert.That(saved.StartTime, Is.EqualTo(session.StartTime)); + Assert.That(saved.EndTime, Is.EqualTo(session.EndTime)); + Assert.That(saved.Duration, Is.EqualTo(session.EndTime - session.StartTime)); + }); + } + + [TestCaseSource(nameof(sessionTestCases))] + public void GetOne_ExistingId_ReturnsSession(DateTime start, DateTime end) + { + var session = new CodingSession + { + StartTime = start, + EndTime = end + }; + + repository.Add(session); + + long savedId = repository.GetAll().Single().Id; + + CodingSession? result = repository.GetOne(savedId); + + Assert.That(result, Is.Not.Null); + + Assert.Multiple(() => + { + Assert.That(result!.Id, Is.EqualTo(savedId)); + Assert.That(result.StartTime, Is.EqualTo(session.StartTime)); + Assert.That(result.EndTime, Is.EqualTo(session.EndTime)); + }); + } + + [Test] + public void GetOne_NonexistentId_ReturnsNull() + { + CodingSession? result = repository.GetOne(999999L); + Assert.That(result, Is.Null); + } + + [Test] + public void GetAll_MultipleSessions_ReturnsNewestFirst() + { + var oldest = new CodingSession + { + StartTime = new DateTime(2026, 7, 12, 10, 0, 0), + EndTime = new DateTime(2026, 7, 12, 11, 0, 0) + }; + + var newest = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 10, 0, 0), + EndTime = new DateTime(2026, 7, 14, 11, 0, 0) + }; + + var middle = new CodingSession + { + StartTime = new DateTime(2026, 7, 13, 10, 0, 0), + EndTime = new DateTime(2026, 7, 13, 11, 0, 0) + }; + + repository.Add(oldest); + repository.Add(newest); + repository.Add(middle); + + List result = repository.GetAll(); + + DateTime[] actualOrder = result.Select(x => x.StartTime).ToArray(); + + DateTime[] expectedOrder = + [ + newest.StartTime, + middle.StartTime, + oldest.StartTime + ]; + + Assert.That(actualOrder, Is.EqualTo(expectedOrder)); + } + + [Test] + public void Update_ExistingSession_ChangesStoredTimes() + { + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }); + + CodingSession stored = repository.GetAll().Single(); + + stored.StartTime = new DateTime(2026, 7, 14, 12, 30, 0); + stored.EndTime = new DateTime(2026, 7, 14, 14, 15, 0); + + bool updated = repository.Update(stored); + + CodingSession? reloaded = repository.GetOne(stored.Id); + + Assert.That(updated, Is.True); + Assert.That(reloaded, Is.Not.Null); + + Assert.Multiple(() => + { + Assert.That(reloaded!.StartTime, Is.EqualTo(stored.StartTime)); + Assert.That(reloaded.EndTime, Is.EqualTo(stored.EndTime)); + }); + } + + [Test] + public void Update_NonexistentSession_ReturnsFalse() + { + var session = new CodingSession + { + Id = 99999L, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + bool result = repository.Update(session); + + Assert.That(result, Is.False); + } + + [Test] + public void Delete_ExistingSession_RemovesSession() + { + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }); + + long id = repository.GetAll().Single().Id; + + bool deleted = repository.Delete(id); + + Assert.Multiple(() => + { + Assert.That(deleted, Is.True); + Assert.That(repository.GetOne(id), Is.Null); + Assert.That(repository.GetAll(), Is.Empty); + }); + } + + [Test] + public void Delete_NonexistentId_ReturnsFalse() + { + bool result = repository.Delete(999_999); + + Assert.That(result, Is.False); + } + + [Test] + public void GetSessionsByDay_ReturnsOnlySessionsStartingOnGivenDay() + { + var atStartOfDay = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 0, 0, 0), + EndTime = new DateTime(2026, 7, 14, 1, 0, 0) + }; + + var duringDay = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 15, 30, 0), + EndTime = new DateTime(2026, 7, 14, 16, 30, 0) + }; + + var nextDay = new CodingSession + { + StartTime = new DateTime(2026, 7, 15, 0, 0, 0), + EndTime = new DateTime(2026, 7, 15, 1, 0, 0) + }; + + repository.Add(atStartOfDay); + repository.Add(duringDay); + repository.Add(nextDay); + + List result = repository.GetSessionsByDay(new DateTime(2026, 7, 14)); + + Assert.That(result, Has.Count.EqualTo(2)); + + Assert.That(result.Select(x => x.StartTime), Is.EquivalentTo(new[] { atStartOfDay.StartTime, duringDay.StartTime })); + } +} diff --git a/CodingTracker.Tests/RepositoryTestBase.cs b/CodingTracker.Tests/RepositoryTestBase.cs new file mode 100644 index 000000000..3abf2e14b --- /dev/null +++ b/CodingTracker.Tests/RepositoryTestBase.cs @@ -0,0 +1,36 @@ +using CodeReviews.Console.CodingTracker; +using Microsoft.Data.Sqlite; +using Dapper; +namespace CodingTracker.Tests; + +[TestFixture] +[NonParallelizable] +public abstract class RepositoryTestBase +{ + protected string dbPath; + protected ICodingSessionRepo repository; + + [SetUp] + public void SetUp() + { + dbPath = Path.Combine(Path.GetTempPath(), $"coding-tracker-tests-{Guid.NewGuid():N}.db"); + + string connectionString = $"Data Source={dbPath};Pooling=False"; + + IDatabaseConnectionFactory connectionFactory = new SQLiteConnectionFactory(connectionString); + var initializer = new DatabaseInitializer(connectionFactory); + + initializer.Initialize(); + + repository = new CodingSessionRepo(connectionFactory); + } + + [TearDown] + public void DeleteDatabase() + { + if (File.Exists(dbPath)) + { + File.Delete(dbPath); + } + } +} diff --git a/CodingTracker/CodingTracker.sln b/CodingTracker/CodingTracker.sln index 5a48df943..2831a8b22 100644 --- a/CodingTracker/CodingTracker.sln +++ b/CodingTracker/CodingTracker.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36429.23 d17.14 +VisualStudioVersion = 17.14.36429.23 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker", "CodingTracker.csproj", "{594963C2-24C3-4E63-8C77-61866EE59BA0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker.Tests", "..\CodingTracker.Tests\CodingTracker.Tests.csproj", "{1E256DC0-2072-4D3D-9461-5D49C9FA7211}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {594963C2-24C3-4E63-8C77-61866EE59BA0}.Debug|Any CPU.Build.0 = Debug|Any CPU {594963C2-24C3-4E63-8C77-61866EE59BA0}.Release|Any CPU.ActiveCfg = Release|Any CPU {594963C2-24C3-4E63-8C77-61866EE59BA0}.Release|Any CPU.Build.0 = Release|Any CPU + {1E256DC0-2072-4D3D-9461-5D49C9FA7211}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E256DC0-2072-4D3D-9461-5D49C9FA7211}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E256DC0-2072-4D3D-9461-5D49C9FA7211}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E256DC0-2072-4D3D-9461-5D49C9FA7211}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CodingTracker/DB/SQLiteConnectionFactory.cs b/CodingTracker/DB/SQLiteConnectionFactory.cs index 72ee439a2..5c0d439bf 100644 --- a/CodingTracker/DB/SQLiteConnectionFactory.cs +++ b/CodingTracker/DB/SQLiteConnectionFactory.cs @@ -1,7 +1,7 @@ using System.Data.Common; using Microsoft.Data.Sqlite; -namespace CodeReviews.Console.CodingTracker.Data; +namespace CodeReviews.Console.CodingTracker; public sealed class SQLiteConnectionFactory : IDatabaseConnectionFactory { From d9b346a80f42225dee852949c793258029687de5 Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Wed, 15 Jul 2026 08:40:23 +0200 Subject: [PATCH 15/17] service tests and some fixes in crud test cases --- CodingTracker.Tests/CrudAndFilterTests.cs | 219 +++++++++- CodingTracker.Tests/ServiceTests.cs | 480 ++++++++++++++++++++++ CodingTracker/Program.cs | 3 +- 3 files changed, 690 insertions(+), 12 deletions(-) create mode 100644 CodingTracker.Tests/ServiceTests.cs diff --git a/CodingTracker.Tests/CrudAndFilterTests.cs b/CodingTracker.Tests/CrudAndFilterTests.cs index 53a64daa9..4279f80cf 100644 --- a/CodingTracker.Tests/CrudAndFilterTests.cs +++ b/CodingTracker.Tests/CrudAndFilterTests.cs @@ -9,28 +9,28 @@ public sealed class CrudAndFilterTests : RepositoryTestBase private static readonly List sessionTestCases = [ new TestCaseData( new DateTime(2026, 7, 14, 9, 0, 0), - new DateTime(2026, 7, 14, 10, 30, 0)) - .SetName("Session_OnSameDay"), + new DateTime(2026, 7, 14, 10, 30, 0) + ), new TestCaseData( new DateTime(2026, 7, 14, 23, 30, 0), - new DateTime(2026, 7, 15, 0, 45, 0)) - .SetName("Session_AcrossMidnight"), + new DateTime(2026, 7, 15, 0, 45, 0) + ), new TestCaseData( new DateTime(2026, 1, 31, 22, 0, 0), - new DateTime(2026, 2, 1, 1, 0, 0)) - .SetName("Session_AcrossMonthBoundary"), + new DateTime(2026, 2, 1, 1, 0, 0) + ), new TestCaseData( new DateTime(2025, 12, 31, 23, 0, 0), - new DateTime(2026, 1, 1, 2, 0, 0)) - .SetName("Session_AcrossYearBoundary"), + new DateTime(2026, 1, 1, 2, 0, 0) + ), new TestCaseData( new DateTime(2026, 7, 14, 12, 0, 15), - new DateTime(2026, 7, 14, 12, 0, 45)) - .SetName("Session_WithSeconds") + new DateTime(2026, 7, 14, 12, 0, 45) + ), ]; [Test] @@ -66,6 +66,12 @@ public void Add_ValidSession_SavesSession(DateTime start, DateTime end) }); } + [Test] + public void Add_NullSession_ThrowsArgumentNullException() + { + Assert.That(() => repository.Add(null!), Throws.TypeOf()); + } + [TestCaseSource(nameof(sessionTestCases))] public void GetOne_ExistingId_ReturnsSession(DateTime start, DateTime end) { @@ -209,6 +215,105 @@ public void Delete_NonexistentId_ReturnsFalse() Assert.That(result, Is.False); } + [Test] + public void Delete_ExistingSession_RemovesOnlySelectedSession() + { + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }); + + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 15, 8, 0, 0), + EndTime = new DateTime(2026, 7, 15, 9, 0, 0) + }); + + List stored = repository.GetAll(); + + long deletedId = stored[0].Id; + long remainingId = stored[1].Id; + + bool result = repository.Delete(deletedId); + + Assert.Multiple(() => + { + Assert.That(result, Is.True); + Assert.That(repository.GetOne(deletedId), Is.Null); + Assert.That(repository.GetOne(remainingId), Is.Not.Null); + Assert.That(repository.GetAll(), Has.Count.EqualTo(1)); + }); + } + + [Test] + public void Update_ExistingSession_ChangesOnlySelectedSession() + { + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }); + + repository.Add(new CodingSession + { + StartTime = new DateTime(2026, 7, 15, 8, 0, 0), + EndTime = new DateTime(2026, 7, 15, 9, 0, 0) + }); + + List stored = repository.GetAll(); + + CodingSession target = stored.Single(session => session.StartTime.Day == 14); + + CodingSession untouched = stored.Single(session => session.StartTime.Day == 15); + + DateTime originalUntouchedStart = untouched.StartTime; + DateTime originalUntouchedEnd = untouched.EndTime; + + target.StartTime = new DateTime(2026, 7, 14, 12, 0, 0); + target.EndTime = new DateTime(2026, 7, 14, 14, 0, 0); + + bool result = repository.Update(target); + + CodingSession? updated = repository.GetOne(target.Id); + + CodingSession? unchanged = repository.GetOne(untouched.Id); + + Assert.Multiple(() => + { + Assert.That(result, Is.True); + Assert.That(updated!.StartTime, Is.EqualTo(target.StartTime)); + Assert.That(updated.EndTime, Is.EqualTo(target.EndTime)); + Assert.That(unchanged!.StartTime, Is.EqualTo(originalUntouchedStart)); + Assert.That(unchanged.EndTime, Is.EqualTo(originalUntouchedEnd)); + }); + } + + [Test] + public void GetSessionsInBetweenDates_IncludesSessionAtStartBoundary() + { + DateTime start = new(2026, 7, 14, 9, 0, 0); + DateTime end = new(2026, 7, 14, 12, 0, 0); + + repository.Add(new CodingSession + { + StartTime = start, + EndTime = start.AddHours(1) + }); + + List result = + repository.GetSessionsInBetweenDates(start, end); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.Single().StartTime, Is.EqualTo(start)); + } + + [Test] + public void Update_NullSession_ThrowsArgumentNullException() + { + Assert.That(() => repository.Update(null!), Throws.TypeOf()); + } + [Test] public void GetSessionsByDay_ReturnsOnlySessionsStartingOnGivenDay() { @@ -240,4 +345,98 @@ public void GetSessionsByDay_ReturnsOnlySessionsStartingOnGivenDay() Assert.That(result.Select(x => x.StartTime), Is.EquivalentTo(new[] { atStartOfDay.StartTime, duringDay.StartTime })); } + [Test] + public void GetSessionsByMonthOfYear_ReturnsOnlySessionsStartingInGivenMonthAndYear() + { + var januaryFirst = new CodingSession + { + StartTime = new DateTime(2026, 1, 5, 10, 0, 0), + EndTime = new DateTime(2026, 1, 5, 11, 0, 0) + }; + + var januaryLast = new CodingSession + { + StartTime = new DateTime(2026, 1, 31, 22, 0, 0), + EndTime = new DateTime(2026, 2, 1, 1, 0, 0) + }; + + var february = new CodingSession + { + StartTime = new DateTime(2026, 2, 1, 8, 0, 0), + EndTime = new DateTime(2026, 2, 1, 9, 0, 0) + }; + + repository.Add(januaryFirst); + repository.Add(januaryLast); + repository.Add(february); + + List result = repository.GetSessionsByMonthOfYear(2026, 1); + + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result.Select(x => x.StartTime), Is.EqualTo(new[] { januaryLast.StartTime, januaryFirst.StartTime })); + } + + [Test] + public void GetSessionsInBetweenDates_ReturnsOnlySessionsStartingWithinGivenRange() + { + var beforeRange = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + var insideRange = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 10, 0, 0), + EndTime = new DateTime(2026, 7, 14, 11, 0, 0) + }; + + var atEndBoundary = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 12, 0, 0), + EndTime = new DateTime(2026, 7, 14, 13, 0, 0) + }; + + repository.Add(beforeRange); + repository.Add(insideRange); + repository.Add(atEndBoundary); + + List result = repository.GetSessionsInBetweenDates( + new DateTime(2026, 7, 14, 9, 0, 0), + new DateTime(2026, 7, 14, 12, 0, 0)); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.Single().StartTime, Is.EqualTo(insideRange.StartTime)); + } + + [Test] + public void GetSessionsByYear_ReturnsOnlySessionsStartingInGivenYear() + { + var previousYear = new CodingSession + { + StartTime = new DateTime(2025, 12, 31, 23, 0, 0), + EndTime = new DateTime(2026, 1, 1, 1, 0, 0) + }; + + var targetYear = new CodingSession + { + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + var nextYear = new CodingSession + { + StartTime = new DateTime(2027, 1, 1, 8, 0, 0), + EndTime = new DateTime(2027, 1, 1, 9, 0, 0) + }; + + repository.Add(previousYear); + repository.Add(targetYear); + repository.Add(nextYear); + + List result = repository.GetSessionsByYear(2026); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.Single().StartTime, Is.EqualTo(targetYear.StartTime)); + } } diff --git a/CodingTracker.Tests/ServiceTests.cs b/CodingTracker.Tests/ServiceTests.cs new file mode 100644 index 000000000..fef77a54f --- /dev/null +++ b/CodingTracker.Tests/ServiceTests.cs @@ -0,0 +1,480 @@ +using System.Linq; +using CodeReviews.Console.CodingTracker; + +namespace CodingTracker.Tests; + +[TestFixture] +[NonParallelizable] +public sealed class ServiceTests +{ + private CodingSessionService _service; + private CodingSessionRepoDouble _repository; + + private static readonly List WeekTestCases = + [ + new TestCaseData( + new DateTime(2026, 7, 13, 0, 0, 0), + new DateTime(2026, 7, 13, 0, 0, 0), + new DateTime(2026, 7, 20, 0, 0, 0)), + + new TestCaseData( + new DateTime(2026, 7, 15, 18, 30, 0), + new DateTime(2026, 7, 13, 0, 0, 0), + new DateTime(2026, 7, 20, 0, 0, 0)), + + new TestCaseData( + new DateTime(2026, 7, 19, 12, 0, 0), + new DateTime(2026, 7, 13, 0, 0, 0), + new DateTime(2026, 7, 20, 0, 0, 0)), + + new TestCaseData( + new DateTime(2025, 12, 31, 22, 0, 0), + new DateTime(2025, 12, 29, 0, 0, 0), + new DateTime(2026, 1, 5, 0, 0, 0)), + ]; + + private static readonly List InvalidIdTestCases = [ + new TestCaseData(0L), + new TestCaseData(-1L), + new TestCaseData(-999L), + new TestCaseData(long.MinValue), + ]; + + [SetUp] + public void SetUp() + { + _repository = new CodingSessionRepoDouble(); + _service = new CodingSessionService(_repository); + } + + // ADD + + [Test] + public void Add_ValidTimes_CallsRepositoryWithCreatedSession() + { + DateTime startTime = new DateTime(2026, 7, 14, 8, 0, 0); + DateTime endTime = new DateTime(2026, 7, 14, 10, 0, 0); + + _service.Add(startTime, endTime); + + Assert.That(_repository.AddedSessions, Has.Count.EqualTo(1)); + + CodingSession added = _repository.AddedSessions.Single(); + + Assert.Multiple(() => + { + Assert.That(added.StartTime, Is.EqualTo(startTime)); + Assert.That(added.EndTime, Is.EqualTo(endTime)); + Assert.That(added.Duration, Is.EqualTo(endTime - startTime)); + }); + } + + [TestCase(-1)] + [TestCase(0)] + public void Add_EndNotAfterStart_ThrowsArgumentException(int endOffsetMinutes) + { + DateTime startTime = new DateTime(2026, 7, 14, 8, 0, 0); + DateTime endTime = startTime.AddMinutes(endOffsetMinutes); + + Assert.That( + () => _service.Add(startTime, endTime), + Throws.TypeOf()); + + Assert.That(_repository.AddedSessions, Is.Empty); + } + + // DELETE + + [TestCaseSource(nameof(InvalidIdTestCases))] + public void Delete_InvalidId_ReturnsFalseWithoutCallingRepository(long id) + { + bool result = _service.Delete(id); + + Assert.Multiple(() => + { + Assert.That(result, Is.False); + Assert.That(_repository.DeletedId, Is.Null); + }); + } + + [TestCase(true)] + [TestCase(false)] + public void Delete_ValidId_ReturnsRepositoryResult(bool repositoryResult) + { + _repository.DeleteResult = repositoryResult; + + bool result = _service.Delete(3); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(repositoryResult)); + Assert.That(_repository.DeletedId, Is.EqualTo(3)); + }); + } + + // GET ALL + + [Test] + public void GetAll_ReturnsSessionsFromRepository() + { + var stored = new List + { + new() { Id = 1, StartTime = new DateTime(2026, 7, 14, 8, 0, 0), EndTime = new DateTime(2026, 7, 14, 9, 0, 0) }, + new() { Id = 2, StartTime = new DateTime(2026, 7, 14, 10, 0, 0), EndTime = new DateTime(2026, 7, 14, 11, 0, 0) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetAll(); + + Assert.That(result, Is.SameAs(stored)); + } + + // GET ONE + + [TestCaseSource(nameof(InvalidIdTestCases))] + public void GetOne_InvalidId_ReturnsNullWithoutCallingRepository(long id) + { + CodingSession? result = _service.GetOne(id); + + Assert.Multiple(() => + { + Assert.That(result, Is.Null); + Assert.That(_repository.GetOneId, Is.Null); + }); + } + + [Test] + public void GetOne_ValidIdWhenSessionExists_ReturnsSessionFromRepository() + { + var expected = new CodingSession + { + Id = 5, + StartTime = new DateTime(2026, 7, 14, 9, 0, 0), + EndTime = new DateTime(2026, 7, 14, 10, 0, 0) + }; + + _repository.SessionsToReturn = new List { expected }; + + CodingSession? result = _service.GetOne(expected.Id); + + Assert.Multiple(() => + { + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.SameAs(expected)); + Assert.That(_repository.GetOneId, Is.EqualTo(expected.Id)); + }); + } + + [Test] + public void GetOne_ValidIdWhenSessionDoesNotExist_ReturnsNull() + { + _repository.SessionsToReturn = new List(); + + CodingSession? result = _service.GetOne(10); + + Assert.Multiple(() => + { + Assert.That(result, Is.Null); + Assert.That(_repository.GetOneId, Is.EqualTo(10)); + }); + } + + // UPDATE + + [TestCaseSource(nameof(InvalidIdTestCases))] + public void Update_InvalidId_ReturnsFalseWithoutCallingRepository(long id) + { + bool result = _service.Update( + id, + new DateTime(2026, 7, 14, 8, 0, 0), + new DateTime(2026, 7, 14, 9, 0, 0)); + + Assert.Multiple(() => + { + Assert.That(result, Is.False); + Assert.That(_repository.GetOneId, Is.Null); + Assert.That(_repository.UpdatedId, Is.Null); + }); + } + + [TestCase(-1)] + [TestCase(0)] + public void Update_EndNotAfterStart_ThrowsArgumentException( + int endOffsetMinutes) + { + DateTime startTime = new DateTime(2026, 7, 14, 8, 0, 0); + DateTime endTime = startTime.AddMinutes(endOffsetMinutes); + + Assert.That( + () => _service.Update(1, startTime, endTime), + Throws.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(_repository.GetOneId, Is.Null); + Assert.That(_repository.UpdatedId, Is.Null); + Assert.That(_repository.UpdatedSession, Is.Null); + }); + } + + [Test] + public void Update_NonexistentSession_ReturnsFalseWithoutCallingUpdate() + { + _repository.SessionsToReturn = new List(); + + bool result = _service.Update( + 11, + new DateTime(2026, 7, 14, 8, 0, 0), + new DateTime(2026, 7, 14, 9, 0, 0)); + + Assert.Multiple(() => + { + Assert.That(result, Is.False); + Assert.That(_repository.GetOneId, Is.EqualTo(11)); + Assert.That(_repository.UpdatedId, Is.Null); + }); + } + + [Test] + public void Update_ExistingSession_ChangesTimesAndCallsRepositoryUpdate() + { + var existing = new CodingSession + { + Id = 7, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + _repository.SessionsToReturn = new List { existing }; + + bool result = _service.Update( + existing.Id, + new DateTime(2026, 7, 14, 10, 0, 0), + new DateTime(2026, 7, 14, 11, 30, 0)); + + Assert.Multiple(() => + { + Assert.That(result, Is.True); + Assert.That(_repository.UpdatedId, Is.EqualTo(existing.Id)); + Assert.That(_repository.UpdatedSession, Is.Not.Null); + Assert.That(_repository.UpdatedSession!.StartTime, Is.EqualTo(new DateTime(2026, 7, 14, 10, 0, 0))); + Assert.That(_repository.UpdatedSession.EndTime, Is.EqualTo(new DateTime(2026, 7, 14, 11, 30, 0))); + }); + } + + [TestCase(true)] + [TestCase(false)] + public void Update_ExistingSession_ReturnsRepositoryResult( + bool repositoryResult) + { + var existing = new CodingSession + { + Id = 8, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + _repository.SessionsToReturn = new List { existing }; + _repository.UpdateResult = repositoryResult; + + bool result = _service.Update( + existing.Id, + new DateTime(2026, 7, 14, 10, 0, 0), + new DateTime(2026, 7, 14, 11, 0, 0)); + + Assert.That(result, Is.EqualTo(repositoryResult)); + } + + // FILTER BY DAY + + [Test] + public void GetByDay_ValidDate_ReturnsSessionsFromRepository() + { + var stored = new List + { + new() { Id = 1, StartTime = new DateTime(2026, 7, 14, 8, 0, 0), EndTime = new DateTime(2026, 7, 14, 9, 0, 0) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetByDay(new DateTime(2026, 7, 14)); + + Assert.Multiple(() => + { + Assert.That(result, Is.SameAs(stored)); + Assert.That(_repository.DayForGetByDay, Is.EqualTo(new DateTime(2026, 7, 14))); + }); + } + + // FILTER BY WEEK + + [TestCaseSource(nameof(WeekTestCases))] + public void GetByWeek_ValidDate_UsesCorrectMondayToMondayRange( + DateTime selectedDate, + DateTime expectedStart, + DateTime expectedEnd) + { + var stored = new List + { + new() { Id = 1, StartTime = selectedDate, EndTime = selectedDate.AddHours(1) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetByWeek(selectedDate); + + Assert.Multiple(() => + { + Assert.That(result, Is.SameAs(stored)); + Assert.That(_repository.BetweenDatesArgs?.Start, Is.EqualTo(expectedStart)); + Assert.That(_repository.BetweenDatesArgs?.End, Is.EqualTo(expectedEnd)); + }); + } + + // FILTER BY MONTH AND YEAR + + [TestCase(0, 1)] + [TestCase(-1, 1)] + [TestCase(10000, 1)] + [TestCase(2026, 0)] + [TestCase(2026, -1)] + [TestCase(2026, 13)] + public void GetByMonthOfYear_InvalidArguments_ThrowsArgumentOutOfRangeException( + int year, + int month) + { + Assert.That( + () => _service.GetByMonthOfYear(year, month), + Throws.TypeOf()); + + Assert.That(_repository.MonthOfYearArgs, Is.Null); + } + + [TestCase(2026, 1)] + [TestCase(2026, 7)] + [TestCase(2025, 12)] + public void GetByMonthOfYear_ValidArguments_ReturnsSessionsFromRepository( + int year, + int month) + { + var stored = new List + { + new() { Id = 1, StartTime = new DateTime(year, month, 1, 8, 0, 0), EndTime = new DateTime(year, month, 1, 9, 0, 0) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetByMonthOfYear(year, month); + + Assert.Multiple(() => + { + Assert.That(result, Is.SameAs(stored)); + Assert.That(_repository.MonthOfYearArgs, Is.EqualTo((year, month))); + }); + } + + // FILTER BY YEAR + + [TestCase(0)] + [TestCase(-1)] + [TestCase(10000)] + public void GetByYear_InvalidYear_ThrowsArgumentOutOfRangeException( + int year) + { + Assert.That( + () => _service.GetByYear(year), + Throws.TypeOf()); + + Assert.That(_repository.YearArg, Is.Null); + } + + [TestCase(1)] + [TestCase(2026)] + [TestCase(9999)] + public void GetByYear_ValidYear_ReturnsSessionsFromRepository( + int year) + { + var stored = new List + { + new() { Id = 1, StartTime = new DateTime(year, 1, 1, 8, 0, 0), EndTime = new DateTime(year, 1, 1, 9, 0, 0) } + }; + + _repository.SessionsToReturn = stored; + + List result = _service.GetByYear(year); + + Assert.Multiple(() => + { + Assert.That(result, Is.SameAs(stored)); + Assert.That(_repository.YearArg, Is.EqualTo(year)); + }); + } + + private sealed class CodingSessionRepoDouble : ICodingSessionRepo + { + public List SessionsToReturn { get; set; } = new(); + public List AddedSessions { get; } = new(); + public long? DeletedId { get; private set; } + public bool DeleteResult { get; set; } = true; + public long? GetOneId { get; private set; } + public long? UpdatedId { get; private set; } + public CodingSession? UpdatedSession { get; private set; } + public bool UpdateResult { get; set; } = true; + public DateTime? DayForGetByDay { get; private set; } + public (DateTime Start, DateTime End)? BetweenDatesArgs { get; private set; } + public (int Year, int Month)? MonthOfYearArgs { get; private set; } + public int? YearArg { get; private set; } + + public List GetAll() => SessionsToReturn; + + public CodingSession? GetOne(long id) + { + GetOneId = id; + return SessionsToReturn.SingleOrDefault(x => x.Id == id); + } + + public void Add(CodingSession session) + { + AddedSessions.Add(session); + SessionsToReturn.Add(session); + } + + public bool Update(CodingSession session) + { + UpdatedId = session.Id; + UpdatedSession = session; + return UpdateResult; + } + + public bool Delete(long id) + { + DeletedId = id; + return DeleteResult; + } + + public List GetSessionsByDay(DateTime day) + { + DayForGetByDay = day; + return SessionsToReturn; + } + + public List GetSessionsInBetweenDates(DateTime start, DateTime end) + { + BetweenDatesArgs = (start, end); + return SessionsToReturn; + } + + public List GetSessionsByMonthOfYear(int year, int monthIndicator) + { + MonthOfYearArgs = (year, monthIndicator); + return SessionsToReturn; + } + + public List GetSessionsByYear(int year) + { + YearArg = year; + return SessionsToReturn; + } + } +} diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs index 4bafff5e0..88c04a51e 100644 --- a/CodingTracker/Program.cs +++ b/CodingTracker/Program.cs @@ -1,5 +1,4 @@ -using CodeReviews.Console.CodingTracker.Data; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; namespace CodeReviews.Console.CodingTracker { From 39d3c586f1a0b8c9374aa6e54f323baa8033e6ba Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Wed, 15 Jul 2026 10:31:26 +0200 Subject: [PATCH 16/17] controller tests + test doubles separated from the test files + some fixes --- CodingTracker.Tests/AppControllerTests.cs | 83 +++ .../CodingSessionControllerTests.cs | 592 ++++++++++++++++++ CodingTracker.Tests/ServiceTests.cs | 67 -- .../TestDoubles/CodingSessionRepoDouble.cs | 68 ++ .../TestDoubles/FakeAppView.cs | 14 + .../FakeCodingSessionController.cs | 13 + .../TestDoubles/FakeCodingSessionService.cs | 127 ++++ .../TestDoubles/FakeCodingSessionView.cs | 75 +++ CodingTracker.Tests/TestDoubles/ViewEvent.cs | 6 + .../Controller/CodingSessionController.cs | 8 + 10 files changed, 986 insertions(+), 67 deletions(-) create mode 100644 CodingTracker.Tests/AppControllerTests.cs create mode 100644 CodingTracker.Tests/CodingSessionControllerTests.cs create mode 100644 CodingTracker.Tests/TestDoubles/CodingSessionRepoDouble.cs create mode 100644 CodingTracker.Tests/TestDoubles/FakeAppView.cs create mode 100644 CodingTracker.Tests/TestDoubles/FakeCodingSessionController.cs create mode 100644 CodingTracker.Tests/TestDoubles/FakeCodingSessionService.cs create mode 100644 CodingTracker.Tests/TestDoubles/FakeCodingSessionView.cs create mode 100644 CodingTracker.Tests/TestDoubles/ViewEvent.cs diff --git a/CodingTracker.Tests/AppControllerTests.cs b/CodingTracker.Tests/AppControllerTests.cs new file mode 100644 index 000000000..4a56cfa0e --- /dev/null +++ b/CodingTracker.Tests/AppControllerTests.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using CodeReviews.Console.CodingTracker; + +[TestFixture] +public sealed class AppControllerTests +{ + private FakeAppView _appView = null!; + private FakeCodingSessionController _codingController = null!; + private AppController _controller = null!; + + [SetUp] + public void SetUp() + { + _appView = new FakeAppView(); + _codingController = new FakeCodingSessionController(); + _controller = new AppController(_appView, _codingController); + } + + [Test] + public void Run_ViewSelected_CallsViewSessionsAndDisplaysGoodbye() + { + _appView.OptionsToReturn.Enqueue(MenuOption.View); + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.Multiple(() => + { + Assert.That(_codingController.ViewSessionsCalls, Is.EqualTo(1)); + Assert.That(_appView.DisplayGoodbyeCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void Run_AddSelected_CallsAddSession() + { + _appView.OptionsToReturn.Enqueue(MenuOption.Add); + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.That(_codingController.AddSessionCalls, Is.EqualTo(1)); + } + + [Test] + public void Run_EditSelected_CallsUpdateSession() + { + _appView.OptionsToReturn.Enqueue(MenuOption.Edit); + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.That(_codingController.UpdateSessionCalls, Is.EqualTo(1)); + } + + [Test] + public void Run_DeleteSelected_CallsDeleteSession() + { + _appView.OptionsToReturn.Enqueue(MenuOption.Delete); + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.That(_codingController.DeleteSessionCalls, Is.EqualTo(1)); + } + + [Test] + public void Run_CloseSelected_DisplaysGoodbyeOnly() + { + _appView.OptionsToReturn.Enqueue(MenuOption.Close); + + _controller.Run(); + + Assert.Multiple(() => + { + Assert.That(_codingController.ViewSessionsCalls, Is.Zero); + Assert.That(_codingController.AddSessionCalls, Is.Zero); + Assert.That(_codingController.UpdateSessionCalls, Is.Zero); + Assert.That(_codingController.DeleteSessionCalls, Is.Zero); + Assert.That(_appView.DisplayGoodbyeCalls, Is.EqualTo(1)); + }); + } +} diff --git a/CodingTracker.Tests/CodingSessionControllerTests.cs b/CodingTracker.Tests/CodingSessionControllerTests.cs new file mode 100644 index 000000000..6aec4b208 --- /dev/null +++ b/CodingTracker.Tests/CodingSessionControllerTests.cs @@ -0,0 +1,592 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CodeReviews.Console.CodingTracker; + +[TestFixture] +public sealed class CodingSessionControllerTests +{ + private FakeCodingSessionView _view; + private FakeCodingSessionService _service; + private CodingSessionController _controller; + + [SetUp] + public void SetUp() + { + _view = new FakeCodingSessionView(); + _service = new FakeCodingSessionService(); + _controller = new CodingSessionController(_service, _view); + } + + [Test] + public void AddSession_ManualOption_GetsTimesAndCallsServiceAdd() + { + _view.AddSessionOptionToReturn = AddSessionOption.Manual; + _view.SessionTimesToReturn = ( + new DateTime(2026, 7, 14, 8, 0, 0), + new DateTime(2026, 7, 14, 9, 0, 0)); + + _controller.AddSession(); + + Assert.Multiple(() => + { + Assert.That(_service.AddCalled, Is.True); + Assert.That(_service.AddedStartTime, Is.EqualTo(_view.SessionTimesToReturn.StartTime)); + Assert.That(_service.AddedEndTime, Is.EqualTo(_view.SessionTimesToReturn.EndTime)); + Assert.That(_view.LastMessage, Is.EqualTo("Coding session added successfully.")); + }); + } + + [Test] + public void AddSession_TimerOption_RecordsStartAndEndAndCallsServiceAdd() + { + _view.AddSessionOptionToReturn = AddSessionOption.Stopwatch; + + _controller.AddSession(); + + Assert.Multiple(() => + { + Assert.That(_view.WaitStopwatchCalled, Is.True); + Assert.That(_service.AddCalled, Is.True); + Assert.That(_service.AddedStartTime, Is.Not.Null); + Assert.That(_service.AddedEndTime, Is.Not.Null); + Assert.That(_service.AddedStartTime, Is.LessThanOrEqualTo(_service.AddedEndTime!.Value)); + Assert.That(_view.LastMessage, Does.StartWith("Session recorded.")); + }); + } + + [Test] + public void AddSession_BackOption_DoesNotCallService() + { + _view.AddSessionOptionToReturn = AddSessionOption.Back; + + _controller.AddSession(); + + Assert.That(_service.AddCalled, Is.False); + } + + [Test] + public void AddSession_ServiceThrowsArgumentException_DisplaysError() + { + _view.AddSessionOptionToReturn = AddSessionOption.Manual; + _view.SessionTimesToReturn = ( + new DateTime(2026, 7, 14, 8, 0, 0), + new DateTime(2026, 7, 14, 7, 0, 0)); + _service.AddExceptionToThrow = new ArgumentException("Invalid times"); + + _controller.AddSession(); + + Assert.Multiple(() => + { + Assert.That(_service.AddCalled, Is.True); + Assert.That(_view.LastError, Is.EqualTo("Invalid times")); + Assert.That(_view.DisplayMessageCount, Is.EqualTo(0)); + }); + } + + [Test] + public void ViewSessions_NoSessions_DisplaysEmptyListAndDoesNotShowFilterMenu() + { + _service.SessionsToReturn = new List(); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayedSessions.Single().Count, Is.EqualTo(0)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void ViewSessions_SessionsExist_DisplaysSessionsAndShowsFilterMenu() + { + _service.SessionsToReturn = new List + { + new() { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayedSessions.Single().Count, Is.EqualTo(1)); + }); + } + + [Test] + public void ViewSessions_ShowAllSelected_CallsGetAllAgain() + { + _service.SessionsToReturn = new List + { + new() { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.FilterOptionsToReturn.Enqueue(FilterOption.ShowAll); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.GetAllCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + }); + } + + [Test] + public void ViewSessions_DaySelected_GetsDateAndCallsGetByDay() + { + DateTime requestedDate = new DateTime(2026, 7, 14); + _service.SessionsToReturn = [ + new CodingSession + { + Id = 99, + StartTime = new DateTime(2026, 1, 1, 8, 0, 0), + EndTime = new DateTime(2026, 1, 1, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByDay = [ + new CodingSession + { + Id = 1, + StartTime = requestedDate, + EndTime = requestedDate.AddHours(1) + } + ]; + + _view.FilterDateToReturn = requestedDate; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Day); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.DayArgument, Is.EqualTo(requestedDate)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + }); + } + + [Test] + public void ViewSessions_WeekSelected_GetsDateAndCallsGetByWeek() + { + DateTime requestedDate = new DateTime(2026, 7, 16); + _service.SessionsToReturn = [ + new CodingSession + { + Id = 99, + StartTime = new DateTime(2026, 7, 10, 8, 0, 0), + EndTime = new DateTime(2026, 7, 10, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByWeek = [ + new CodingSession + { + Id = 1, + StartTime = requestedDate, + EndTime = requestedDate.AddHours(1) + } + ]; + + _view.FilterDateToReturn = requestedDate; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Week); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.WeekArgument, Is.EqualTo(requestedDate)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayedSessions[1].Single().Id, Is.EqualTo(1)); + }); + } + + [Test] + public void ViewSessions_MonthSelected_GetsMonthAndYearAndCallsGetByMonthOfYear() + { + Month month = Month.July; + int year = 2026; + _service.SessionsToReturn = [ + new CodingSession + { + Id = 99, + StartTime = new DateTime(2026, 1, 10, 8, 0, 0), + EndTime = new DateTime(2026, 1, 10, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByMonth = [ + new CodingSession + { + Id = 1, + StartTime =new DateTime(year, (int)month, 1, 8, 0, 0), + EndTime = new DateTime(year, (int)month, 1, 9, 0, 0) + } + ]; + + _view.MonthToReturn = month; + _view.YearToReturn = year; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Month); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.MonthArguments, Is.EqualTo((year, (int)month))); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayedSessions[1].Single().Id, Is.EqualTo(1)); + }); + } + + [Test] + public void ViewSessions_YearSelected_GetsYearAndCallsGetByYear() + { + int year = 2026; + _service.SessionsToReturn = [ + new CodingSession + { + Id = 99, + StartTime = new DateTime(2025, 7, 10, 8, 0, 0), + EndTime = new DateTime(2025, 7, 10, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByYear = [ + new CodingSession + { + Id = 1, + StartTime = new DateTime(year, 1, 1, 8, 0, 0), + EndTime = new DateTime(year, 1, 1, 9, 0, 0) + } + ]; + + _view.YearToReturn = year; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Year); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.YearArgument, Is.EqualTo(year)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayedSessions[1].Single().Id, Is.EqualTo(1)); + }); + } + + [Test] + public void ViewSessions_BackSelected_ReturnsWithoutFurtherServiceCalls() + { + _service.SessionsToReturn = new List + { + new() { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_service.GetAllCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(1)); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void UpdateSession_NoSessions_DoesNotAskForId() + { + _service.SessionsToReturn = new List(); + + _controller.UpdateSession(); + + Assert.Multiple(() => + { + Assert.That(_view.GetSessionIdCalls, Is.EqualTo(0)); + Assert.That(_view.LastMessage, Is.EqualTo("No coding sessions available to update.")); + }); + } + + [Test] + public void UpdateSession_SessionsExist_DisplaysSessionsBeforeAskingForId() + { + var session = new CodingSession + { + Id = 1, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + }; + + _service.SessionsToReturn = [session]; + _view.SessionIdToReturn = 1; + + _view.SessionTimesToReturn = + ( + new DateTime(2026, 7, 14, 10, 0, 0), + new DateTime(2026, 7, 14, 11, 0, 0) + ); + + _controller.UpdateSession(); + + Assert.That(_view.Events, Is.EqualTo(new[]{ + ViewEvent.DisplaySessions, + ViewEvent.GetSessionId, + ViewEvent.GetSessionTimes + })); + } + + [Test] + public void UpdateSession_NonexistentId_DisplaysErrorAndDoesNotAskForTimes() + { + _service.SessionsToReturn = new List + { + new() { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.SessionIdToReturn = 99; + + _controller.UpdateSession(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Coding session 99 was not found.")); + Assert.That(_view.GetSessionTimesCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void UpdateSession_ValidId_GetsTimesAndCallsServiceUpdate() + { + var stored = new CodingSession { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) }; + _service.SessionsToReturn = new List { stored }; + _view.SessionIdToReturn = 1; + _view.SessionTimesToReturn = (DateTime.Now.AddHours(2), DateTime.Now.AddHours(3)); + + _controller.UpdateSession(); + + Assert.Multiple(() => + { + Assert.That(_service.UpdatedId, Is.EqualTo(1)); + Assert.That(_service.UpdatedStartTime, Is.EqualTo(_view.SessionTimesToReturn.StartTime)); + Assert.That(_service.UpdatedEndTime, Is.EqualTo(_view.SessionTimesToReturn.EndTime)); + Assert.That(_view.LastMessage, Is.EqualTo("Coding session updated successfully.")); + }); + } + + [Test] + public void UpdateSession_UpdateReturnsFalse_DisplaysError() + { + var stored = new CodingSession { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) }; + _service.SessionsToReturn = new List { stored }; + _view.SessionIdToReturn = 1; + _view.SessionTimesToReturn = (DateTime.Now.AddHours(2), DateTime.Now.AddHours(3)); + _service.UpdateResult = false; + + _controller.UpdateSession(); + + Assert.That(_view.LastError, Is.EqualTo("Coding session 1 was not found.")); + } + + [Test] + public void UpdateSession_InvalidTimes_DisplaysValidationError() + { + var stored = new CodingSession { Id = 1, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) }; + _service.SessionsToReturn = new List { stored }; + _view.SessionIdToReturn = 1; + _view.SessionTimesToReturn = (DateTime.Now.AddHours(2), DateTime.Now.AddHours(1)); + _service.UpdateExceptionToThrow = new ArgumentException("Invalid time range"); + + _controller.UpdateSession(); + + Assert.That(_view.LastError, Is.EqualTo("Invalid time range")); + } + + [Test] + public void DeleteSession_NoSessions_DoesNotAskForId() + { + _service.SessionsToReturn = new List(); + + _controller.DeleteSession(); + + Assert.Multiple(() => + { + Assert.That(_view.GetSessionIdCalls, Is.EqualTo(0)); + Assert.That(_view.LastMessage, Is.EqualTo("No coding sessions available to delete.")); + }); + } + + [Test] + public void DeleteSession_SessionsExist_DisplaysSessionsBeforeAskingForId() + { + _service.SessionsToReturn = [ + new CodingSession + { + Id = 2, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + } + ]; + + _view.SessionIdToReturn = 2; + + _controller.DeleteSession(); + + Assert.That(_view.Events, Is.EqualTo(new[] { ViewEvent.DisplaySessions, + ViewEvent.GetSessionId + })); + } + + [Test] + public void DeleteSession_ExistingId_CallsServiceDelete() + { + _service.SessionsToReturn = new List + { + new() { Id = 2, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.SessionIdToReturn = 2; + + _controller.DeleteSession(); + + Assert.That(_service.DeletedId, Is.EqualTo(2)); + } + + [Test] + public void DeleteSession_DeleteReturnsTrue_DisplaysSuccessMessage() + { + _service.SessionsToReturn = new List + { + new() { Id = 2, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.SessionIdToReturn = 2; + _service.DeleteResult = true; + + _controller.DeleteSession(); + + Assert.That(_view.LastMessage, Is.EqualTo("Coding session deleted successfully.")); + } + + [Test] + public void DeleteSession_DeleteReturnsFalse_DisplaysError() + { + _service.SessionsToReturn = new List + { + new() { Id = 2, StartTime = DateTime.Now, EndTime = DateTime.Now.AddHours(1) } + }; + _view.SessionIdToReturn = 2; + _service.DeleteResult = false; + + _controller.DeleteSession(); + + Assert.That(_view.LastError, Is.EqualTo("Coding session 2 was not found.")); + } + + [Test] + public void ViewSessions_DataRetrievalFails_DisplaysErrorAndReturns() + { + _service.GetAllExceptionToThrow = new InvalidOperationException("boom"); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Unexpected error: boom")); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(0)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void UpdateSession_DataRetrievalFails_DisplaysErrorAndReturns() + { + _service.GetAllExceptionToThrow = new InvalidOperationException("boom"); + + _controller.UpdateSession(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Unexpected error: boom")); + Assert.That(_view.GetSessionIdCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void DeleteSession_DataRetrievalFails_DisplaysErrorAndReturns() + { + _service.GetAllExceptionToThrow = new InvalidOperationException("boom"); + + _controller.DeleteSession(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Unexpected error: boom")); + Assert.That(_view.GetSessionIdCalls, Is.EqualTo(0)); + }); + } + + [Test] + public void ViewSessions_FilterReturnsEmptyList_StaysInFilteringLoop() + { + _service.SessionsToReturn = [ + new CodingSession + { + Id = 1, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + } + ]; + + _service.SessionsToReturnForGetByDay = []; + + _view.FilterDateToReturn = new DateTime(2020, 1, 1); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Day); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Back); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(2)); + Assert.That(_view.DisplayedSessions[1], Is.Empty); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(2)); + }); + } + + [Test] + public void ViewSessions_FilterRetrievalFails_DisplaysErrorAndReturns() + { + _service.SessionsToReturn = [ + new CodingSession + { + Id = 1, + StartTime = new DateTime(2026, 7, 14, 8, 0, 0), + EndTime = new DateTime(2026, 7, 14, 9, 0, 0) + } + ]; + + _view.FilterDateToReturn = new DateTime(2026, 7, 14); + _view.FilterOptionsToReturn.Enqueue(FilterOption.Day); + _service.FilterExceptionToThrow = new InvalidOperationException("filter failed"); + + _controller.ViewSessions(); + + Assert.Multiple(() => + { + Assert.That(_view.LastError, Is.EqualTo("Unexpected error: filter failed")); + Assert.That(_view.DisplaySessionsCalls, Is.EqualTo(1)); + Assert.That(_view.DisplayFilterMenuCalls, Is.EqualTo(1)); + Assert.That(_service.DayArgument, Is.Null); + }); + } +} diff --git a/CodingTracker.Tests/ServiceTests.cs b/CodingTracker.Tests/ServiceTests.cs index fef77a54f..a7991102a 100644 --- a/CodingTracker.Tests/ServiceTests.cs +++ b/CodingTracker.Tests/ServiceTests.cs @@ -410,71 +410,4 @@ public void GetByYear_ValidYear_ReturnsSessionsFromRepository( Assert.That(_repository.YearArg, Is.EqualTo(year)); }); } - - private sealed class CodingSessionRepoDouble : ICodingSessionRepo - { - public List SessionsToReturn { get; set; } = new(); - public List AddedSessions { get; } = new(); - public long? DeletedId { get; private set; } - public bool DeleteResult { get; set; } = true; - public long? GetOneId { get; private set; } - public long? UpdatedId { get; private set; } - public CodingSession? UpdatedSession { get; private set; } - public bool UpdateResult { get; set; } = true; - public DateTime? DayForGetByDay { get; private set; } - public (DateTime Start, DateTime End)? BetweenDatesArgs { get; private set; } - public (int Year, int Month)? MonthOfYearArgs { get; private set; } - public int? YearArg { get; private set; } - - public List GetAll() => SessionsToReturn; - - public CodingSession? GetOne(long id) - { - GetOneId = id; - return SessionsToReturn.SingleOrDefault(x => x.Id == id); - } - - public void Add(CodingSession session) - { - AddedSessions.Add(session); - SessionsToReturn.Add(session); - } - - public bool Update(CodingSession session) - { - UpdatedId = session.Id; - UpdatedSession = session; - return UpdateResult; - } - - public bool Delete(long id) - { - DeletedId = id; - return DeleteResult; - } - - public List GetSessionsByDay(DateTime day) - { - DayForGetByDay = day; - return SessionsToReturn; - } - - public List GetSessionsInBetweenDates(DateTime start, DateTime end) - { - BetweenDatesArgs = (start, end); - return SessionsToReturn; - } - - public List GetSessionsByMonthOfYear(int year, int monthIndicator) - { - MonthOfYearArgs = (year, monthIndicator); - return SessionsToReturn; - } - - public List GetSessionsByYear(int year) - { - YearArg = year; - return SessionsToReturn; - } - } } diff --git a/CodingTracker.Tests/TestDoubles/CodingSessionRepoDouble.cs b/CodingTracker.Tests/TestDoubles/CodingSessionRepoDouble.cs new file mode 100644 index 000000000..e6daaee18 --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/CodingSessionRepoDouble.cs @@ -0,0 +1,68 @@ +using CodeReviews.Console.CodingTracker; + +internal sealed class CodingSessionRepoDouble : ICodingSessionRepo +{ + public List SessionsToReturn { get; set; } = new(); + public List AddedSessions { get; } = new(); + public long? DeletedId { get; private set; } + public bool DeleteResult { get; set; } = true; + public long? GetOneId { get; private set; } + public long? UpdatedId { get; private set; } + public CodingSession? UpdatedSession { get; private set; } + public bool UpdateResult { get; set; } = true; + public DateTime? DayForGetByDay { get; private set; } + public (DateTime Start, DateTime End)? BetweenDatesArgs { get; private set; } + public (int Year, int Month)? MonthOfYearArgs { get; private set; } + public int? YearArg { get; private set; } + + public List GetAll() => SessionsToReturn; + + public CodingSession? GetOne(long id) + { + GetOneId = id; + return SessionsToReturn.SingleOrDefault(x => x.Id == id); + } + + public void Add(CodingSession session) + { + AddedSessions.Add(session); + SessionsToReturn.Add(session); + } + + public bool Update(CodingSession session) + { + UpdatedId = session.Id; + UpdatedSession = session; + return UpdateResult; + } + + public bool Delete(long id) + { + DeletedId = id; + return DeleteResult; + } + + public List GetSessionsByDay(DateTime day) + { + DayForGetByDay = day; + return SessionsToReturn; + } + + public List GetSessionsInBetweenDates(DateTime start, DateTime end) + { + BetweenDatesArgs = (start, end); + return SessionsToReturn; + } + + public List GetSessionsByMonthOfYear(int year, int monthIndicator) + { + MonthOfYearArgs = (year, monthIndicator); + return SessionsToReturn; + } + + public List GetSessionsByYear(int year) + { + YearArg = year; + return SessionsToReturn; + } +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/FakeAppView.cs b/CodingTracker.Tests/TestDoubles/FakeAppView.cs new file mode 100644 index 000000000..9bf5ab7cb --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/FakeAppView.cs @@ -0,0 +1,14 @@ +using CodeReviews.Console.CodingTracker; + +internal sealed class FakeAppView : IAppView +{ + public Queue OptionsToReturn { get; } = new(); + public int DisplayGoodbyeCalls { get; private set; } + + public MenuOption DisplayMainMenu() => OptionsToReturn.Count > 0 + ? OptionsToReturn.Dequeue() + : MenuOption.Close; + + public void DisplayMessage(string message) { } + public void DisplayGoodbye() => DisplayGoodbyeCalls++; +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/FakeCodingSessionController.cs b/CodingTracker.Tests/TestDoubles/FakeCodingSessionController.cs new file mode 100644 index 000000000..33a86fdaa --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/FakeCodingSessionController.cs @@ -0,0 +1,13 @@ +using CodeReviews.Console.CodingTracker; +internal sealed class FakeCodingSessionController : ICodingSessionController +{ + public int ViewSessionsCalls { get; private set; } + public int AddSessionCalls { get; private set; } + public int UpdateSessionCalls { get; private set; } + public int DeleteSessionCalls { get; private set; } + + public void ViewSessions() => ViewSessionsCalls++; + public void AddSession() => AddSessionCalls++; + public void UpdateSession() => UpdateSessionCalls++; + public void DeleteSession() => DeleteSessionCalls++; +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/FakeCodingSessionService.cs b/CodingTracker.Tests/TestDoubles/FakeCodingSessionService.cs new file mode 100644 index 000000000..68a2e30ad --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/FakeCodingSessionService.cs @@ -0,0 +1,127 @@ +using CodeReviews.Console.CodingTracker; + +internal sealed class FakeCodingSessionService : ICodingSessionService +{ + public List SessionsToReturn { get; set; } = new(); + public List? SessionsToReturnForGetOne { get; set; } + public List? SessionsToReturnForGetByDay { get; set; } + public List? SessionsToReturnForGetByWeek { get; set; } + public List? SessionsToReturnForGetByMonth { get; set; } + public List? SessionsToReturnForGetByYear { get; set; } + + public bool AddCalled { get; private set; } + public DateTime? AddedStartTime { get; private set; } + public DateTime? AddedEndTime { get; private set; } + public Exception? AddExceptionToThrow { get; set; } + public Exception? FilterExceptionToThrow { get; set; } + + public int GetAllCalls { get; private set; } + public Exception? GetAllExceptionToThrow { get; set; } + + public long? DeletedId { get; private set; } + public bool DeleteResult { get; set; } = true; + public Exception? DeleteExceptionToThrow { get; set; } + + public long? UpdatedId { get; private set; } + public DateTime? UpdatedStartTime { get; private set; } + public DateTime? UpdatedEndTime { get; private set; } + public bool UpdateResult { get; set; } = true; + public Exception? UpdateExceptionToThrow { get; set; } + + public DateTime? DayArgument { get; private set; } + public DateTime? WeekArgument { get; private set; } + public (int Year, int Month)? MonthArguments { get; private set; } + public int? YearArgument { get; private set; } + + public void Add(DateTime startTime, DateTime endTime) + { + AddCalled = true; + AddedStartTime = startTime; + AddedEndTime = endTime; + if (AddExceptionToThrow != null) + { + throw AddExceptionToThrow; + } + } + + public bool Delete(long id) + { + if (DeleteExceptionToThrow != null) + { + throw DeleteExceptionToThrow; + } + DeletedId = id; + return DeleteResult; + } + + public List GetAll() + { + if (GetAllExceptionToThrow != null) + { + throw GetAllExceptionToThrow; + } + GetAllCalls++; + return SessionsToReturn; + } + + public CodingSession? GetOne(long id) + { + if (SessionsToReturnForGetOne != null) + { + return SessionsToReturnForGetOne.SingleOrDefault(x => x.Id == id); + } + return SessionsToReturn.SingleOrDefault(x => x.Id == id); + } + + public bool Update(long id, DateTime startTime, DateTime endTime) + { + if (UpdateExceptionToThrow != null) + { + throw UpdateExceptionToThrow; + } + UpdatedId = id; + UpdatedStartTime = startTime; + UpdatedEndTime = endTime; + return UpdateResult; + } + + public List GetByDay(DateTime date) + { + if (FilterExceptionToThrow != null) + throw FilterExceptionToThrow; + + DayArgument = date; + + return SessionsToReturnForGetByDay ?? SessionsToReturn; + } + + public List GetByWeek(DateTime date) + { + if (FilterExceptionToThrow != null) + throw FilterExceptionToThrow; + + WeekArgument = date; + + return SessionsToReturnForGetByWeek ?? SessionsToReturn; + } + + public List GetByMonthOfYear(int year, int month) + { + if (FilterExceptionToThrow != null) + throw FilterExceptionToThrow; + + MonthArguments = (year, month); + + return SessionsToReturnForGetByMonth ?? SessionsToReturn; + } + + public List GetByYear(int year) + { + if (FilterExceptionToThrow != null) + throw FilterExceptionToThrow; + + YearArgument = year; + + return SessionsToReturnForGetByYear ?? SessionsToReturn; + } +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/FakeCodingSessionView.cs b/CodingTracker.Tests/TestDoubles/FakeCodingSessionView.cs new file mode 100644 index 000000000..812532818 --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/FakeCodingSessionView.cs @@ -0,0 +1,75 @@ +using CodeReviews.Console.CodingTracker; + +internal sealed class FakeCodingSessionView : ICodingSessionView +{ + public List Events { get; } = []; + public AddSessionOption AddSessionOptionToReturn { get; set; } + public Queue FilterOptionsToReturn { get; } = new(); + public DateTime FilterDateToReturn { get; set; } + public Month MonthToReturn { get; set; } + public int YearToReturn { get; set; } + public long SessionIdToReturn { get; set; } + public (DateTime StartTime, DateTime EndTime) SessionTimesToReturn { get; set; } + + public int DisplaySessionsCalls { get; private set; } + public List> DisplayedSessions { get; } = new(); + public int DisplayFilterMenuCalls { get; private set; } + public int GetSessionIdCalls { get; private set; } + public int GetSessionTimesCalls { get; private set; } + public int DisplayMessageCount { get; private set; } + public int DisplayErrorCount { get; private set; } + public bool WaitStopwatchCalled { get; private set; } + public string? LastMessage { get; private set; } + public string? LastError { get; private set; } + + public void DisplaySessions(List sessions) + { + Events.Add(ViewEvent.DisplaySessions); + DisplaySessionsCalls++; + DisplayedSessions.Add(new List(sessions)); + } + + public (DateTime StartTime, DateTime EndTime) GetSessionTimes() + { + Events.Add(ViewEvent.GetSessionTimes); + GetSessionTimesCalls++; + return SessionTimesToReturn; + } + + public long GetSessionId(string prompt) + { + Events.Add(ViewEvent.GetSessionId); + GetSessionIdCalls++; + return SessionIdToReturn; + } + + public void DisplayMessage(string message) + { + DisplayMessageCount++; + LastMessage = message; + } + + public void DisplayError(string message) + { + DisplayErrorCount++; + LastError = message; + } + + public Month GetMonth() => MonthToReturn; + public int GetYear() => YearToReturn; + + public FilterOption DisplayFilterMenu() + { + DisplayFilterMenuCalls++; + if (FilterOptionsToReturn.Count == 0) + throw new InvalidOperationException("No filter option was configured for this test."); + + return FilterOptionsToReturn.Dequeue(); + } + + public DateTime GetFilterDate(string prompt) => FilterDateToReturn; + + public AddSessionOption DisplayAddSessionMenu() => AddSessionOptionToReturn; + + public void WaitStopwatch() => WaitStopwatchCalled = true; +} \ No newline at end of file diff --git a/CodingTracker.Tests/TestDoubles/ViewEvent.cs b/CodingTracker.Tests/TestDoubles/ViewEvent.cs new file mode 100644 index 000000000..8910e465a --- /dev/null +++ b/CodingTracker.Tests/TestDoubles/ViewEvent.cs @@ -0,0 +1,6 @@ +internal enum ViewEvent +{ + DisplaySessions, + GetSessionId, + GetSessionTimes +} \ No newline at end of file diff --git a/CodingTracker/Controller/CodingSessionController.cs b/CodingTracker/Controller/CodingSessionController.cs index e14674324..1d3337fbe 100644 --- a/CodingTracker/Controller/CodingSessionController.cs +++ b/CodingTracker/Controller/CodingSessionController.cs @@ -45,6 +45,9 @@ public void DeleteSession() _codingView.DisplayMessage("No coding sessions available to delete."); return; } + + _codingView.DisplaySessions(sessions); + long id = _codingView.GetSessionId("Enter the ID of the session to delete:"); try { @@ -70,6 +73,9 @@ public void UpdateSession() _codingView.DisplayMessage("No coding sessions available to update."); return; } + + _codingView.DisplaySessions(sessions); + long id = _codingView.GetSessionId("Enter the ID of the session to update:"); CodingSession? session = _codingService.GetOne(id); @@ -101,6 +107,8 @@ public void ViewSessions() { List? sessions = GetSessionsSafely(_codingService.GetAll); if (sessions is null) return; + + _codingView.DisplaySessions(sessions); if (sessions.Count == 0) return; while (true) From daf3b39fdb4a2c2ec12de45a4e75092a707555bf Mon Sep 17 00:00:00 2001 From: felikshetalia Date: Wed, 15 Jul 2026 10:41:00 +0200 Subject: [PATCH 17/17] readme added --- README.md | 197 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/README.md b/README.md index e69de29bb..1f3842e15 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,197 @@ +# Coding Tracker App + +Console-based CRUD application for tracking coding sessions. Built with C#/.NET, SQLite, Dapper, and Spectre.Console. + +The project is based on [The C# Academy Coding Tracker challenge](https://www.thecsharpacademy.com/project/13/coding-tracker) and builds on the concepts I previously practised in my [Habit Tracker project](https://github.com/felikshetalia/CodeReviews.Console.HabitTracker). + +# Given Requirements + +- Users must be able to create, view, update, and delete coding sessions. +- Each coding session must contain an ID, start time, end time, and duration. +- Users enter the start and end times, while duration is calculated automatically. +- Date and time input must follow a clearly specified format and be validated. +- The application must store its data in a SQLite database. +- The database and the required table must be created automatically when the application starts. +- The database connection string must be stored in `appsettings.json`. +- Dapper must be used for database access. +- Spectre.Console must be used to display the user interface. +- The application must follow separation of concerns and keep classes in separate files. +- Repeated code should be reduced by following the DRY principle. +- Database records must be converted into `CodingSession` objects rather than being exposed to the rest of the application as anonymous data. +- The project should include a README explaining the implementation and thought process. + +# Features + +- **SQLite database initialization** + - The application creates the local database and `CodingSessions` table when they do not already exist. + - The database path is loaded from `Configuration/appsettings.json`. + - A connection factory centralizes the creation of SQLite connections. + +- **Coding-session CRUD operations** + - Add coding sessions. + - View all coding sessions. + - Update an existing session by ID. + - Delete an existing session by ID. + - Sessions are ordered from newest to oldest. + +- **Two ways to add a coding session** + - Enter a past session manually by providing its start and end times. + - Start a stopwatch and stop it when the coding session finishes. + +- **Automatic duration calculation** + - Users never enter duration manually. + - `Duration` is calculated from `EndTime - StartTime`, which prevents conflicting values from being stored. + +- **Filtering** + - Show all sessions. + - Filter by a specific day. + - Filter by week. + - Filter by month and year. + - Filter by year. + - Date-range queries use an inclusive start and exclusive end boundary. + +- **Input validation** + - Dates and times must match the expected format. + - The end time must be later than the start time. + - Session IDs must be positive. + - Update and delete workflows display the existing sessions before asking the user to choose an ID. + - Missing sessions and invalid operations are reported without terminating the application. + +- **Console interface** + - Spectre.Console selection menus are used instead of numeric menu commands. + - Coding sessions are presented in formatted tables. + - Error and success messages are displayed consistently. + +# Architecture + +The project uses an MVC-inspired layered structure with interfaces between the main dependencies: + +```text +Program +├── AppController +│ ├── IAppView +│ └── ICodingSessionController +│ +└── CodingSessionController + ├── ICodingSessionView + └── ICodingSessionService + └── CodingSessionService + └── ICodingSessionRepo + └── CodingSessionRepo + └── IDatabaseConnectionFactory + └── SQLiteConnectionFactory +``` + +The main responsibilities are: + +- **Views** handle Spectre.Console input and output. +- **Controllers** coordinate menu flows and connect views with services. +- **Services** contain validation, use-case logic, and date-range calculations. +- **Repositories** contain Dapper queries and database mapping. +- **Database classes** create connections and initialize the schema. +- **Models** represent coding sessions in the application. +- **Program.cs** acts as the composition root and connects concrete implementations through their interfaces. + +# Design Decisions + +- I kept `CodingSession.StartTime` and `CodingSession.EndTime` as `DateTime` values instead of changing the domain model to match SQLite's text representation. +- Conversion between `DateTime` and the database date format is handled at the persistence boundary. +- Duration is a calculated property rather than a separate database column. +- Filtering is placed inside the **View sessions** workflow instead of adding several extra options to the main menu. +- Week filtering accepts one date and calculates the Monday-to-Monday range in the service. +- I used a coding-session-specific repository interface instead of introducing a generic repository before another real repository exists. +- Interfaces are used at meaningful dependency boundaries rather than creating one interface for every class. +- Dependencies are constructed manually in `Program.cs` instead of introducing a dependency-injection framework for a small console application. + +# Technologies Used + +- C# +- .NET 9 +- SQLite +- Dapper +- Microsoft.Data.Sqlite +- Spectre.Console +- JSON configuration through `appsettings.json` +- NUnit 3 + +# Tests + +The solution contains both unit tests and database integration tests. + +- **Database initialization tests** + - Verify that the database and required table are created. + +- **Repository integration tests** + - Use isolated SQLite test databases. + - Cover create, read, update, and delete behavior. + - Verify that update and delete affect only the selected record. + - Test ordering and generated IDs. + - Test day, date-range, month, and year filters. + - Test inclusive and exclusive filter boundaries. + +- **Service unit tests** + - Use a test double implementing `ICodingSessionRepo`. + - Verify validation, invalid-ID short-circuiting, update behavior, repository delegation, and week-boundary calculations. + +- **Controller unit tests** + - Use fake services, views, and controllers. + - Verify menu routing, workflow branching, displayed messages, input order, filtering behavior, and error handling. + +Run the tests with: + +```bash +dotnet test CodingTracker/CodingTracker.sln +``` + +# Running the Application + +1. Clone the repository: + +```bash +git clone https://github.com/felikshetalia/CodeReviews.Console.CodingTracker.git +``` + +2. Move into the repository: + +```bash +cd CodeReviews.Console.CodingTracker +``` + +3. Restore the dependencies: + +```bash +dotnet restore CodingTracker/CodingTracker.sln +``` + +4. Run the application: + +```bash +dotnet run --project CodingTracker/CodingTracker.csproj +``` + +# What I've Learned from This Project + +- Using Dapper with SQLite for parameterized `SELECT`, `INSERT`, `UPDATE`, and `DELETE` operations. +- Creating a local SQLite database and schema automatically when the program starts. +- Loading connection settings from `appsettings.json`. +- Using a factory to isolate database-connection creation. +- Separating console presentation, application flow, business logic, and persistence. +- Applying interfaces and constructor injection to make classes replaceable and testable. +- Avoiding speculative base classes and generic abstractions until there is real duplication. +- Parsing and formatting dates consistently across console input and database storage. +- Calculating day, week, month, and year ranges using inclusive lower and exclusive upper boundaries. +- Building reusable Spectre.Console prompts and tables. +- Writing parameterized NUnit tests with `TestCase` and `TestCaseSource`. +- Distinguishing unit tests from repository integration tests. +- Creating handwritten test doubles that record calls and return configured results. +- Testing not only final results, but also whether dependencies were called with the correct arguments and in the correct order. + +# Areas to Improve + +- Inject a clock or `TimeProvider` so stopwatch tests do not depend on the system clock. +- Improve stopwatch recovery so an active timed session is not lost if the application closes unexpectedly. +- Make database error handling more consistent and avoid leaking SQLite-specific exceptions into higher layers. +- Reduce repeated database-row mapping and date-parsing code in the repository. +- Consider accepting seconds as optional manual input while preserving them for stopwatch sessions. +- Add automated test coverage reporting. +- Add screenshots or a short demonstration of the console interface.