diff --git a/.gitignore b/.gitignore index 49976e115..8adf8e6bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,480 +1,59 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory +# Prerequisites +bin/ +obj/ .vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET -project.lock.json -project.fragment.lock.json -artifacts/ - -# Tye -.tye/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt +*.db +*.d -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta +# Object files +*.o +*.ko *.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml -.idea/ - -## -## Visual studio for Mac -## +*.elf +# Linker output +*.ilk +*.map +*.exp -# globs -Makefile.in -*.userprefs -*.usertasks -config.make -config.status -aclocal.m4 -install-sh -autom4te.cache/ -*.tar.gz -tarballs/ -test-results/ +# Precompiled Headers +*.gch +*.pch -# Mac bundle stuff -*.dmg +# Libraries +*.lib +*.a +*.la +*.lo + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out *.app +*.i*86 +*.x86_64 +*.hex + +# Debug files +*.dSYM/ +*.su +*.idb +*.pdb -# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk -/MathGame2 -/CodingTracker.TomDonegan/TextFile1.txt +# Kernel Module Compile Results +*.mod* +*.cmd +.tmp_versions/ +modules.order +Module.symvers +Mkfile.old +dkms.conf + +# debug information files +*.dwo diff --git a/CodingTracker.slnx b/CodingTracker.slnx new file mode 100644 index 000000000..2a475e317 --- /dev/null +++ b/CodingTracker.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..9c532fa83 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1 @@ +wefhi diff --git a/README.md b/README.md new file mode 100644 index 000000000..6eb566d40 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Coding Tracker + +A .NET 10 LTS console application for recording daily coding time. It supports manual entries, a live stopwatch, full CRUD operations, calendar-period filtering, and ascending or descending ordering. + +## Features + +- Add sessions using the exact `dd-MM-yyyy HH:mm` format +- Calculate duration automatically from start and end times +- Track a session as it happens with the stopwatch option +- View, update, and delete sessions +- Filter by day, Monday-to-Sunday week, month, or year +- Sort sessions by start time and see total duration +- Persist data in SQLite through Dapper +- Render menus and tables with Spectre.Console + +## Run it + +Install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0), then run: + +```powershell +dotnet restore +dotnet run --project src/CodingTracker +``` + +Run the tests with `dotnet test`. + +The database is created automatically on first launch. Its path and SQLite connection string are controlled by `src/CodingTracker/appsettings.json`; a relative data source is resolved from the executable directory. + +## Design and thought process + +The application is split by responsibility: `Models` contains data, `Data` owns SQL and Dapper mapping, `Controllers` holds CRUD coordination and filtering, `Services` contains reusable validation and clock abstractions, and `UI` owns prompts and Spectre.Console tables. + +SQLite has no dedicated date/time type, so timestamps are stored as invariant `yyyy-MM-dd HH:mm:ss.fffffff` text. That format sorts chronologically, preserves stopwatch precision, and is converted back into strongly typed `CodingSession` instances. The UI uses a separate, explicit human-facing format. `Duration` is calculated, making start and end times the single source of truth. + +Filtering is deterministic, database-independent controller logic, so it can be unit tested without a real database. Repository access is hidden behind an interface for the same reason. The stopwatch uses an injected clock, keeping time acquisition separate from session creation. diff --git a/global.json b/global.json new file mode 100644 index 000000000..512142d2b --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/src/CodingTracker/CodingTracker.csproj b/src/CodingTracker/CodingTracker.csproj new file mode 100644 index 000000000..8ce33e2be --- /dev/null +++ b/src/CodingTracker/CodingTracker.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/CodingTracker/Configuration/AppSettings.cs b/src/CodingTracker/Configuration/AppSettings.cs new file mode 100644 index 000000000..6c46b4114 --- /dev/null +++ b/src/CodingTracker/Configuration/AppSettings.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; + +namespace CodingTracker.Configuration; + +public sealed class AppSettings +{ + public string DatabasePath { get; init; } = "Data/coding-tracker.db"; + public Dictionary ConnectionStrings { get; init; } = []; + + public static AppSettings Load(string? settingsPath = null) + { + settingsPath ??= Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!File.Exists(settingsPath)) + throw new FileNotFoundException("The configuration file was not found.", settingsPath); + + var settings = JsonSerializer.Deserialize(File.ReadAllText(settingsPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (string.IsNullOrWhiteSpace(settings?.DatabasePath)) + throw new InvalidDataException("DatabasePath must be configured in appsettings.json."); + + return settings; + } + + public string GetConnectionString() + { + var configured = ConnectionStrings.GetValueOrDefault("CodingTracker"); + var builder = string.IsNullOrWhiteSpace(configured) + ? new SqliteConnectionStringBuilder { DataSource = DatabasePath } + : new SqliteConnectionStringBuilder(configured); + var path = Path.IsPathRooted(builder.DataSource) + ? builder.DataSource + : Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, builder.DataSource)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + builder.DataSource = path; + return builder.ToString(); + } +} diff --git a/src/CodingTracker/Controllers/CodingController.cs b/src/CodingTracker/Controllers/CodingController.cs new file mode 100644 index 000000000..19c2a6375 --- /dev/null +++ b/src/CodingTracker/Controllers/CodingController.cs @@ -0,0 +1,62 @@ +using CodingTracker.Data; +using CodingTracker.Models; +using CodingTracker.Services; + +namespace CodingTracker.Controllers; + +public sealed class CodingController(ICodingSessionRepository repository) +{ + public CodingSession Add(DateTime start, DateTime end) + { + EnsureRange(start, end); + var session = new CodingSession { StartTime = start, EndTime = end }; + session.Id = repository.Create(session); + return session; + } + + public List GetSessions(SessionFilter filter) + { + IEnumerable sessions = repository.GetAll(); + if (filter.Period != Period.All) + { + var reference = (filter.ReferenceDate ?? DateTime.Today).Date; + var (from, until) = GetBounds(filter.Period, reference); + sessions = sessions.Where(s => s.StartTime >= from && s.StartTime < until); + } + + sessions = filter.SortDirection == SortDirection.Ascending + ? sessions.OrderBy(s => s.StartTime) + : sessions.OrderByDescending(s => s.StartTime); + return sessions.ToList(); + } + + public bool Update(int id, DateTime start, DateTime end) + { + EnsureRange(start, end); + return repository.Update(new CodingSession { Id = id, StartTime = start, EndTime = end }); + } + + public bool Delete(int id) => repository.Delete(id); + + public static (DateTime From, DateTime Until) GetBounds(Period period, DateTime date) => period switch + { + Period.Day => (date.Date, date.Date.AddDays(1)), + Period.Week => WeekBounds(date), + Period.Month => (new DateTime(date.Year, date.Month, 1), new DateTime(date.Year, date.Month, 1).AddMonths(1)), + Period.Year => (new DateTime(date.Year, 1, 1), new DateTime(date.Year + 1, 1, 1)), + _ => (DateTime.MinValue, DateTime.MaxValue) + }; + + private static (DateTime, DateTime) WeekBounds(DateTime date) + { + var daysSinceMonday = ((int)date.DayOfWeek + 6) % 7; + var monday = date.Date.AddDays(-daysSinceMonday); + return (monday, monday.AddDays(7)); + } + + private static void EnsureRange(DateTime start, DateTime end) + { + if (!Validation.IsValidRange(start, end)) + throw new ArgumentException("End time must be later than start time."); + } +} diff --git a/src/CodingTracker/Data/CodingSessionRepository.cs b/src/CodingTracker/Data/CodingSessionRepository.cs new file mode 100644 index 000000000..aedbef895 --- /dev/null +++ b/src/CodingTracker/Data/CodingSessionRepository.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using CodingTracker.Models; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace CodingTracker.Data; + +public sealed class CodingSessionRepository(string connectionString) : ICodingSessionRepository +{ + private const string StorageFormat = "yyyy-MM-dd HH:mm:ss.fffffff"; + + public void InitializeDatabase() + { + using var connection = OpenConnection(); + connection.Execute(""" + CREATE TABLE IF NOT EXISTS CodingSessions ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + StartTime TEXT NOT NULL, + EndTime TEXT NOT NULL, + CHECK (EndTime > StartTime) + ); + """); + } + + public int Create(CodingSession session) + { + using var connection = OpenConnection(); + return connection.QuerySingle(""" + INSERT INTO CodingSessions (StartTime, EndTime) + VALUES (@StartTime, @EndTime); + SELECT last_insert_rowid(); + """, ToParameters(session)); + } + + public List GetAll() + { + using var connection = OpenConnection(); + var rows = connection.Query( + "SELECT Id, StartTime, EndTime FROM CodingSessions;").ToList(); + return rows.Select(ToSession).ToList(); + } + + public bool Update(CodingSession session) + { + using var connection = OpenConnection(); + return connection.Execute(""" + UPDATE CodingSessions SET StartTime = @StartTime, EndTime = @EndTime + WHERE Id = @Id; + """, ToParameters(session)) == 1; + } + + public bool Delete(int id) + { + using var connection = OpenConnection(); + return connection.Execute("DELETE FROM CodingSessions WHERE Id = @id;", new { id }) == 1; + } + + private SqliteConnection OpenConnection() + { + var connection = new SqliteConnection(connectionString); + connection.Open(); + return connection; + } + + private static object ToParameters(CodingSession session) => new + { + session.Id, + StartTime = session.StartTime.ToString(StorageFormat, CultureInfo.InvariantCulture), + EndTime = session.EndTime.ToString(StorageFormat, CultureInfo.InvariantCulture) + }; + + private static CodingSession ToSession(SessionRow row) => new() + { + Id = row.Id, + StartTime = DateTime.ParseExact(row.StartTime, StorageFormat, CultureInfo.InvariantCulture), + EndTime = DateTime.ParseExact(row.EndTime, StorageFormat, CultureInfo.InvariantCulture) + }; + + private sealed class SessionRow + { + public int Id { get; init; } + public string StartTime { get; init; } = ""; + public string EndTime { get; init; } = ""; + } +} diff --git a/src/CodingTracker/Data/ICodingSessionRepository.cs b/src/CodingTracker/Data/ICodingSessionRepository.cs new file mode 100644 index 000000000..1dd43161a --- /dev/null +++ b/src/CodingTracker/Data/ICodingSessionRepository.cs @@ -0,0 +1,12 @@ +using CodingTracker.Models; + +namespace CodingTracker.Data; + +public interface ICodingSessionRepository +{ + void InitializeDatabase(); + int Create(CodingSession session); + List GetAll(); + bool Update(CodingSession session); + bool Delete(int id); +} diff --git a/src/CodingTracker/Models/CodingSession.cs b/src/CodingTracker/Models/CodingSession.cs new file mode 100644 index 000000000..2241437c2 --- /dev/null +++ b/src/CodingTracker/Models/CodingSession.cs @@ -0,0 +1,9 @@ +namespace CodingTracker.Models; + +public sealed class CodingSession +{ + public int Id { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public TimeSpan Duration => EndTime - StartTime; +} diff --git a/src/CodingTracker/Models/SessionFilter.cs b/src/CodingTracker/Models/SessionFilter.cs new file mode 100644 index 000000000..f1a682a28 --- /dev/null +++ b/src/CodingTracker/Models/SessionFilter.cs @@ -0,0 +1,9 @@ +namespace CodingTracker.Models; + +public enum Period { All, Day, Week, Month, Year } +public enum SortDirection { Ascending, Descending } + +public sealed record SessionFilter( + Period Period = Period.All, + DateTime? ReferenceDate = null, + SortDirection SortDirection = SortDirection.Descending); diff --git a/src/CodingTracker/Program.cs b/src/CodingTracker/Program.cs new file mode 100644 index 000000000..48d64e5d1 --- /dev/null +++ b/src/CodingTracker/Program.cs @@ -0,0 +1,21 @@ +using CodingTracker.Configuration; +using CodingTracker.Controllers; +using CodingTracker.Data; +using CodingTracker.Services; +using CodingTracker.UI; + +try +{ + var settings = AppSettings.Load(); + var repository = new CodingSessionRepository(settings.GetConnectionString()); + repository.InitializeDatabase(); + + var controller = new CodingController(repository); + var userInput = new UserInput(controller, new SystemClock()); + userInput.Run(); +} +catch (Exception exception) +{ + Spectre.Console.AnsiConsole.MarkupLine($"[red]Unable to start Coding Tracker:[/] {Spectre.Console.Markup.Escape(exception.Message)}"); + Environment.ExitCode = 1; +} diff --git a/src/CodingTracker/Services/IClock.cs b/src/CodingTracker/Services/IClock.cs new file mode 100644 index 000000000..25ddcf72e --- /dev/null +++ b/src/CodingTracker/Services/IClock.cs @@ -0,0 +1,4 @@ +namespace CodingTracker.Services; + +public interface IClock { DateTime Now { get; } } +public sealed class SystemClock : IClock { public DateTime Now => DateTime.Now; } diff --git a/src/CodingTracker/Services/Validation.cs b/src/CodingTracker/Services/Validation.cs new file mode 100644 index 000000000..d912ba910 --- /dev/null +++ b/src/CodingTracker/Services/Validation.cs @@ -0,0 +1,17 @@ +using System.Globalization; + +namespace CodingTracker.Services; + +public static class Validation +{ + public const string DateTimeFormat = "dd-MM-yyyy HH:mm"; + + public static bool TryParseDateTime(string? value, out DateTime result) => + DateTime.TryParseExact(value, DateTimeFormat, CultureInfo.InvariantCulture, + DateTimeStyles.None, out result); + + public static bool IsValidRange(DateTime start, DateTime end) => end > start; + + public static bool TryParsePositiveId(string? value, out int id) => + int.TryParse(value, out id) && id > 0; +} diff --git a/src/CodingTracker/UI/SessionTable.cs b/src/CodingTracker/UI/SessionTable.cs new file mode 100644 index 000000000..f961bf081 --- /dev/null +++ b/src/CodingTracker/UI/SessionTable.cs @@ -0,0 +1,29 @@ +using CodingTracker.Models; +using CodingTracker.Services; +using Spectre.Console; + +namespace CodingTracker.UI; + +public static class SessionTable +{ + public static void Show(IReadOnlyCollection sessions) + { + if (sessions.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No coding sessions found.[/]"); + return; + } + + var table = new Table().Border(TableBorder.Rounded) + .AddColumn("[aqua]ID[/]").AddColumn("[aqua]Start[/]") + .AddColumn("[aqua]End[/]").AddColumn("[aqua]Duration[/]"); + foreach (var session in sessions) + table.AddRow(session.Id.ToString(), session.StartTime.ToString(Validation.DateTimeFormat), + session.EndTime.ToString(Validation.DateTimeFormat), FormatDuration(session.Duration)); + table.Caption = new TableTitle($"[grey]{sessions.Count} session(s) · Total {FormatDuration(TimeSpan.FromTicks(sessions.Sum(s => s.Duration.Ticks)))}[/]"); + AnsiConsole.Write(table); + } + + private static string FormatDuration(TimeSpan duration) => + $"{(int)duration.TotalHours:00}:{duration.Minutes:00}:{duration.Seconds:00}"; +} diff --git a/src/CodingTracker/UI/UserInput.cs b/src/CodingTracker/UI/UserInput.cs new file mode 100644 index 000000000..e055cd726 --- /dev/null +++ b/src/CodingTracker/UI/UserInput.cs @@ -0,0 +1,131 @@ +using CodingTracker.Controllers; +using CodingTracker.Models; +using CodingTracker.Services; +using Spectre.Console; + +namespace CodingTracker.UI; + +public sealed class UserInput(CodingController controller, IClock clock) +{ + public void Run() + { + AnsiConsole.Write(new FigletText("Coding Tracker").Color(Color.Aqua)); + var running = true; + while (running) + { + var choice = AnsiConsole.Prompt(new SelectionPrompt() + .Title("[yellow]What would you like to do?[/]") + .AddChoices("View sessions", "Add session manually", "Start stopwatch", + "Update session", "Delete session", "Exit")); + try + { + switch (choice) + { + case "View sessions": ViewSessions(); break; + case "Add session manually": AddSession(); break; + case "Start stopwatch": TrackWithStopwatch(); break; + case "Update session": UpdateSession(); break; + case "Delete session": DeleteSession(); break; + case "Exit": running = false; break; + } + } + catch (Exception exception) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(exception.Message)}[/]"); + } + } + } + + private void ViewSessions() + { + var period = AnsiConsole.Prompt(new SelectionPrompt() + .Title("Filter by period:").AddChoices(Enum.GetValues())); + DateTime? reference = period == Period.All ? null : PromptDate("Reference date"); + var direction = AnsiConsole.Prompt(new SelectionPrompt() + .Title("Sort by start time:").AddChoices(Enum.GetValues())); + SessionTable.Show(controller.GetSessions(new SessionFilter(period, reference, direction))); + } + + private void AddSession() + { + var (start, end) = PromptRange(); + var session = controller.Add(start, end); + Success($"Session {session.Id} added ({FormatDuration(session.Duration)})."); + } + + private void UpdateSession() + { + SessionTable.Show(controller.GetSessions(new SessionFilter())); + var id = PromptId("Session ID to update"); + var (start, end) = PromptRange(); + ReportResult(controller.Update(id, start, end), "Session updated.", "Session not found."); + } + + private void DeleteSession() + { + SessionTable.Show(controller.GetSessions(new SessionFilter())); + var id = PromptId("Session ID to delete"); + if (!AnsiConsole.Confirm($"Delete session {id}?", false)) return; + ReportResult(controller.Delete(id), "Session deleted.", "Session not found."); + } + + private void TrackWithStopwatch() + { + AnsiConsole.MarkupLine("[yellow]Press Enter to start the timer.[/]"); + Console.ReadLine(); + var start = clock.Now; + AnsiConsole.MarkupLine($"[green]Started at {start:dd-MM-yyyy HH:mm:ss}. Press Enter to stop.[/]"); + Console.ReadLine(); + var session = controller.Add(start, clock.Now); + Success($"Session {session.Id} saved ({FormatDuration(session.Duration)})."); + } + + private static (DateTime Start, DateTime End) PromptRange() + { + while (true) + { + var start = PromptDateTime("Start time"); + var end = PromptDateTime("End time"); + if (Validation.IsValidRange(start, end)) return (start, end); + AnsiConsole.MarkupLine("[red]End time must be later than start time. Please try again.[/]"); + } + } + + private static DateTime PromptDateTime(string label) + { + while (true) + { + var value = AnsiConsole.Ask($"{label} [grey]({Validation.DateTimeFormat})[/]:"); + if (Validation.TryParseDateTime(value, out var result)) return result; + AnsiConsole.MarkupLine($"[red]Use exactly {Validation.DateTimeFormat}, for example 16-07-2026 14:30.[/]"); + } + } + + private static DateTime PromptDate(string label) + { + while (true) + { + var value = AnsiConsole.Ask($"{label} [grey](dd-MM-yyyy)[/]:"); + if (DateTime.TryParseExact(value, "dd-MM-yyyy", System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var result)) return result; + AnsiConsole.MarkupLine("[red]Use exactly dd-MM-yyyy, for example 16-07-2026.[/]"); + } + } + + private static int PromptId(string label) + { + while (true) + { + var value = AnsiConsole.Ask($"{label}:"); + if (Validation.TryParsePositiveId(value, out var id)) return id; + AnsiConsole.MarkupLine("[red]Enter a positive whole number.[/]"); + } + } + + private static string FormatDuration(TimeSpan duration) => + $"{(int)duration.TotalHours:00}:{duration.Minutes:00}:{duration.Seconds:00}"; + private static void Success(string message) => + AnsiConsole.MarkupLine($"[green]{Markup.Escape(message)}[/]"); + private static void ReportResult(bool result, string success, string failure) => + AnsiConsole.MarkupLine(result ? $"[green]{success}[/]" : $"[red]{failure}[/]"); +} diff --git a/src/CodingTracker/appsettings.json b/src/CodingTracker/appsettings.json new file mode 100644 index 000000000..e90d816ac --- /dev/null +++ b/src/CodingTracker/appsettings.json @@ -0,0 +1,6 @@ +{ + "DatabasePath": "Data/coding-tracker.db", + "ConnectionStrings": { + "CodingTracker": "Data Source=Data/coding-tracker.db" + } +} diff --git a/tests/CodingTracker.Tests/CodingControllerTests.cs b/tests/CodingTracker.Tests/CodingControllerTests.cs new file mode 100644 index 000000000..cd4c6983e --- /dev/null +++ b/tests/CodingTracker.Tests/CodingControllerTests.cs @@ -0,0 +1,41 @@ +using CodingTracker.Controllers; +using CodingTracker.Data; +using CodingTracker.Models; + +namespace CodingTracker.Tests; + +public sealed class CodingControllerTests +{ + [Fact] + public void GetSessions_FiltersCalendarWeekAndSortsAscending() + { + var repository = new FakeRepository( + Session(1, new DateTime(2026, 7, 12)), + Session(2, new DateTime(2026, 7, 19)), + Session(3, new DateTime(2026, 7, 13))); + var result = new CodingController(repository).GetSessions(new SessionFilter( + Period.Week, new DateTime(2026, 7, 16), SortDirection.Ascending)); + Assert.Equal([3, 2], result.Select(x => x.Id)); + } + + [Fact] + public void Add_CalculatesDurationFromTimes() + { + var controller = new CodingController(new FakeRepository()); + var start = new DateTime(2026, 7, 16, 9, 0, 0); + Assert.Equal(TimeSpan.FromMinutes(90), controller.Add(start, start.AddMinutes(90)).Duration); + } + + private static CodingSession Session(int id, DateTime start) => + new() { Id = id, StartTime = start, EndTime = start.AddHours(1) }; + + private sealed class FakeRepository(params CodingSession[] seed) : ICodingSessionRepository + { + private readonly List _sessions = [.. seed]; + public void InitializeDatabase() { } + public int Create(CodingSession session) { session.Id = _sessions.Count + 1; _sessions.Add(session); return session.Id; } + public List GetAll() => [.. _sessions]; + public bool Update(CodingSession session) => true; + public bool Delete(int id) => true; + } +} diff --git a/tests/CodingTracker.Tests/CodingTracker.Tests.csproj b/tests/CodingTracker.Tests/CodingTracker.Tests.csproj new file mode 100644 index 000000000..022f083fa --- /dev/null +++ b/tests/CodingTracker.Tests/CodingTracker.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/tests/CodingTracker.Tests/ValidationTests.cs b/tests/CodingTracker.Tests/ValidationTests.cs new file mode 100644 index 000000000..3a9cbfe0c --- /dev/null +++ b/tests/CodingTracker.Tests/ValidationTests.cs @@ -0,0 +1,24 @@ +using CodingTracker.Services; + +namespace CodingTracker.Tests; + +public sealed class ValidationTests +{ + [Theory] + [InlineData("16-07-2026 09:30", true)] + [InlineData("2026-07-16 09:30", false)] + [InlineData("16/07/2026 09:30", false)] + [InlineData("16-07-2026 9:30", false)] + [InlineData("31-02-2026 09:30", false)] + public void DateTimeInput_RequiresExactValidFormat(string input, bool expected) => + Assert.Equal(expected, Validation.TryParseDateTime(input, out _)); + + [Fact] + public void Range_RequiresEndAfterStart() + { + var start = new DateTime(2026, 7, 16, 10, 0, 0); + Assert.True(Validation.IsValidRange(start, start.AddMinutes(1))); + Assert.False(Validation.IsValidRange(start, start)); + Assert.False(Validation.IsValidRange(start, start.AddMinutes(-1))); + } +}