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)));
+ }
+}