diff --git a/.gitignore b/.gitignore
index 49976e115..4e6442a75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -478,3 +478,4 @@ $RECYCLE.BIN/
*.lnk
/MathGame2
/CodingTracker.TomDonegan/TextFile1.txt
+.claude
\ No newline at end of file
diff --git a/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker.csproj
new file mode 100644
index 000000000..cac31c824
--- /dev/null
+++ b/CodingTracker/CodingTracker.csproj
@@ -0,0 +1,30 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
diff --git a/CodingTracker/CodingTracker.slnx b/CodingTracker/CodingTracker.slnx
new file mode 100644
index 000000000..ba103e54f
--- /dev/null
+++ b/CodingTracker/CodingTracker.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/CodingTracker/Config/AppConfig.cs b/CodingTracker/Config/AppConfig.cs
new file mode 100644
index 000000000..cb210829d
--- /dev/null
+++ b/CodingTracker/Config/AppConfig.cs
@@ -0,0 +1,22 @@
+using Microsoft.Extensions.Configuration;
+
+namespace CodingTracker.Config;
+
+
+internal static class AppConfig
+{
+ public static string ConnectionString { get; }
+ public static string DateFormat { get; }
+
+ static AppConfig()
+ {
+ var config = new ConfigurationBuilder()
+ .SetBasePath(AppContext.BaseDirectory)
+ .AddJsonFile("appsettings.json")
+ .Build();
+
+ ConnectionString = config.GetConnectionString("Default")
+ ?? throw new InvalidOperationException("Connection string 'Default' not found in appsettings.json.");
+ DateFormat = config["DateFormat"] ?? "yyyy-MM-dd HH:mm";
+ }
+}
\ No newline at end of file
diff --git a/CodingTracker/DOCS.md b/CodingTracker/DOCS.md
new file mode 100644
index 000000000..366ec3b1c
--- /dev/null
+++ b/CodingTracker/DOCS.md
@@ -0,0 +1,176 @@
+# Coding Tracker — How It Works
+
+This document explains each component and how data flows through the app. The app is a
+small console CRUD tool for coding sessions, layered as: **Config → Model → Data access → UI**.
+
+## Startup flow
+
+`Program.cs` is the entry point. It runs two lines:
+
+```csharp
+DatabaseManager.Init(); // create the table if it doesn't exist
+new UserInterface().Run(); // enter the interactive menu loop
+```
+
+`Init()` runs first so the `coding_sessions` table always exists before the UI touches it.
+`Run()` then loops until the user picks **Exit**.
+
+## Configuration — `Config/AppConfig.cs`
+
+`AppConfig` is a static class with a static constructor, so `appsettings.json` is read
+**once**, the first time any property is accessed.
+
+```csharp
+var config = new ConfigurationBuilder()
+ .SetBasePath(AppContext.BaseDirectory) // look next to the .exe, not the CWD
+ .AddJsonFile("appsettings.json")
+ .Build();
+
+ConnectionString = config.GetConnectionString("Default")!; // ConnectionStrings:Default
+DateFormat = config["DateFormat"] ?? "yyyy-MM-dd HH:mm";
+```
+
+- **`SetBasePath(AppContext.BaseDirectory)`** is important: the `.csproj` copies
+ `appsettings.json` to the output folder (`CopyToOutputDirectory=PreserveNewest`), so the
+ app finds it regardless of which directory you launch from.
+- If `ConnectionStrings:Default` is missing, the constructor throws — failing fast and
+ loudly rather than silently using a wrong database.
+- `DateFormat` falls back to a sensible default if not present.
+
+Two public properties are exposed for the rest of the app:
+`AppConfig.ConnectionString` and `AppConfig.DateFormat`.
+
+## Model — `Models/CodingSession.cs`
+
+```csharp
+record CodingSession(int Id, DateTime StartTime, DateTime EndTime)
+{
+ public TimeSpan Duration => EndTime - StartTime;
+}
+```
+
+- A positional `record`: immutable, value-based equality, concise.
+- **`Duration` is a computed property** — it is derived from `StartTime`/`EndTime` every
+ time it's read. It is **never stored** in the database (there is no `Duration` column).
+ This keeps the data normalized: you can't have a duration that disagrees with the times.
+- Dapper maps a query result row directly onto this record by matching column names
+ (`Id`, `StartTime`, `EndTime`) to the constructor parameters.
+
+## Data access — `Database/DatabaseManager.cs`
+
+A static class wrapping Dapper. Every method opens a short-lived connection through one
+helper and disposes it with `using`:
+
+```csharp
+static SqliteConnection Connection()
+{
+ var connection = new SqliteConnection(AppConfig.ConnectionString);
+ connection.Open();
+ return connection;
+}
+```
+
+This is the key difference from a hand-rolled ADO.NET layer: instead of creating commands,
+adding parameters, and reading a `DataReader` by hand, each operation is a single Dapper call.
+
+| Method | What it does | Dapper call |
+|--------|--------------|-------------|
+| `Init()` | Creates the `coding_sessions` table if missing | `connection.Execute(createTableSql)` |
+| `Add(start, end)` | Inserts a new session | `Execute("INSERT ...", new { startTime, endTime })` |
+| `Update(id, start, end)` | Updates a session by Id | `Execute("UPDATE ... WHERE Id=@id", new { id, startTime, endTime })` |
+| `Delete(id)` | Deletes a session by Id | `Execute("DELETE ... WHERE Id=@id", new { id })` |
+| `Exists(id)` | Returns whether an Id exists | `ExecuteScalar("SELECT COUNT(1) ...") > 0` |
+| `All()` | Returns all sessions, newest first | `Query("SELECT ... ORDER BY StartTime DESC").ToList()` |
+
+How Dapper helps here:
+
+- **Parameters** — passing an anonymous object like `new { id }` binds `@id` safely
+ (parameterized query, no SQL injection). No manual `AddWithValue` calls.
+- **Reads** — `Query(...)` runs the SQL and materializes each row into a
+ `CodingSession` automatically by name. No `DataReader` loop, no manual `GetInt32`/`GetString`.
+- **Scalars** — `ExecuteScalar(...)` returns the single count value already typed.
+
+### Schema
+
+```sql
+CREATE TABLE IF NOT EXISTS coding_sessions (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ StartTime TEXT NOT NULL,
+ EndTime TEXT NOT NULL);
+```
+
+`StartTime`/`EndTime` are stored as `TEXT`. Microsoft.Data.Sqlite + Dapper round-trip
+`DateTime` to/from an ISO-8601 string automatically, so the C# code works with real
+`DateTime` values on both sides.
+
+## User interface — `UI/UserInterface.cs`
+
+`Run()` is the main loop, built on Spectre.Console:
+
+1. Clear the screen and show a `SelectionPrompt` menu.
+2. Dispatch the chosen option to a handler method.
+3. After the handler returns, wait for a keypress, then loop.
+4. **Exit** returns out of the loop, ending the program.
+
+### Menu actions
+
+- **View Records** — calls `All()` and renders a Spectre `Table` of
+ Id / Start / End / Duration. Empty database shows a friendly message instead.
+- **Add Record** — prompts for start and end times (parsed using `AppConfig.DateFormat`),
+ validates that end is after start, asks for confirmation, then calls `Add(...)`.
+- **Start Live Session** — the stopwatch:
+ ```csharp
+ var start = DateTime.Now;
+ while (!Console.KeyAvailable) // poll for a keypress
+ {
+ AnsiConsole.Markup($"\rElapsed: {FormatDuration(DateTime.Now - start)}");
+ Thread.Sleep(250); // redraw 4×/second
+ }
+ Console.ReadKey(true);
+ DatabaseManager.Add(start, DateTime.Now); // save when stopped
+ ```
+ It records the start, redraws the running elapsed time on one line until any key is
+ pressed, then saves the session and reports its duration.
+- **Update Record** — asks for an Id, validates it with `Exists(id)`, prompts for new
+ times (same validation as Add), confirms, then calls `Update(...)`.
+- **Delete Record** — asks for an Id, validates with `Exists(id)`, confirms, calls `Delete(...)`.
+- **Reports** — loads `All()` and computes, in C# with LINQ:
+ - total session count
+ - total time (`Aggregate` summing each `Duration`)
+ - average per session (`total / count`)
+ - this week (sessions since the most recent Sunday)
+ - this month (sessions since the 1st)
+
+ These are aggregated in code rather than SQL because the dataset is small; the logic
+ stays in one place and is easy to read.
+
+### Input & validation helpers
+
+- `PromptDate(label)` — a `TextPrompt`; Spectre re-prompts automatically until
+ the input parses, showing the expected `DateFormat`.
+- `PromptExistingId()` — reads an int Id and returns it only if `Exists` is true, otherwise
+ prints an error and returns `null` so the caller aborts.
+- `Confirm(message)` — a yes/no `ConfirmationPrompt` shown before every mutation.
+- `FormatDuration(TimeSpan)` — formats as `HH:MM:SS` (hours can exceed 24).
+
+## Data flow summary
+
+```
+appsettings.json
+ │ (read once at startup)
+ ▼
+ AppConfig ──ConnectionString──► DatabaseManager ──Dapper──► SQLite (coding-tracker.db)
+ │ ▲ │
+ DateFormat │ CodingSession records │
+ │ └──────────────────────────┘
+ ▼
+ UserInterface (Spectre.Console menu) ◄── user input / display
+```
+
+## Extending it
+
+- **New field on a session** — add it to the `CREATE TABLE`, the `CodingSession` record,
+ and the relevant `INSERT`/`UPDATE` SQL + UI prompts. Dapper picks it up by name.
+- **New report** — add a LINQ computation in `Reports()` and a row to the table.
+- **Different database location** — change `ConnectionStrings:Default` in `appsettings.json`;
+ no code change needed.
diff --git a/CodingTracker/Database/DatabaseManager.cs b/CodingTracker/Database/DatabaseManager.cs
new file mode 100644
index 000000000..b437e488f
--- /dev/null
+++ b/CodingTracker/Database/DatabaseManager.cs
@@ -0,0 +1,72 @@
+using System.Globalization;
+using Dapper;
+using Microsoft.Data.Sqlite;
+using CodingTracker.Config;
+using CodingTracker.Models;
+
+namespace CodingTracker.Database;
+
+internal static class DatabaseManager
+{
+ static SqliteConnection Connection()
+ {
+ var connection = new SqliteConnection(AppConfig.ConnectionString);
+ connection.Open();
+ return connection;
+ }
+
+ public static void Init()
+ {
+ using var connection = Connection();
+ connection.Execute(
+ """
+ CREATE TABLE IF NOT EXISTS coding_sessions (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ StartTime TEXT NOT NULL,
+ EndTime TEXT NOT NULL);
+ """);
+ }
+
+ public static void Add(DateTime startTime, DateTime endTime)
+ {
+ using var connection = Connection();
+ connection.Execute(
+ "INSERT INTO coding_sessions (StartTime, EndTime) VALUES (@startTime, @endTime);",
+ new { startTime, endTime });
+ }
+
+ public static void Update(int id, DateTime startTime, DateTime endTime)
+ {
+ using var connection = Connection();
+ connection.Execute(
+ "UPDATE coding_sessions SET StartTime = @startTime, EndTime = @endTime WHERE Id = @id;",
+ new { id, startTime, endTime });
+ }
+
+ public static void Delete(int id)
+ {
+ using var connection = Connection();
+ connection.Execute("DELETE FROM coding_sessions WHERE Id = @id;", new { id });
+ }
+
+ public static bool Exists(int id)
+ {
+ using var connection = Connection();
+ return connection.ExecuteScalar(
+ "SELECT COUNT(1) FROM coding_sessions WHERE Id = @id;", new { id }) > 0;
+ }
+
+ public static List All()
+ {
+ using var connection = Connection();
+ // SQLite stores these columns as TEXT/INTEGER; map explicitly so we don't rely on
+ // Dapper coercing a TEXT column into DateTime via the record constructor.
+ return connection.Query(
+ "SELECT Id, StartTime, EndTime FROM coding_sessions ORDER BY StartTime DESC;")
+ .Select(r => new CodingSession(
+ Convert.ToInt32(r.Id),
+ DateTime.Parse((string)r.StartTime, CultureInfo.InvariantCulture),
+ DateTime.Parse((string)r.EndTime, CultureInfo.InvariantCulture)))
+ .ToList();
+ }
+}
diff --git a/CodingTracker/Models/CodingSession.cs b/CodingTracker/Models/CodingSession.cs
new file mode 100644
index 000000000..f1c09d46b
--- /dev/null
+++ b/CodingTracker/Models/CodingSession.cs
@@ -0,0 +1,6 @@
+namespace CodingTracker.Models;
+
+record CodingSession(int Id, DateTime StartTime, DateTime EndTime)
+{
+ public TimeSpan Duration => EndTime - StartTime;
+}
diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs
new file mode 100644
index 000000000..db1b334c0
--- /dev/null
+++ b/CodingTracker/Program.cs
@@ -0,0 +1,5 @@
+using CodingTracker.Database;
+using CodingTracker.UI;
+
+DatabaseManager.Init();
+new UserInterface().Run();
\ No newline at end of file
diff --git a/CodingTracker/UI/UserInterface.cs b/CodingTracker/UI/UserInterface.cs
new file mode 100644
index 000000000..bc680f141
--- /dev/null
+++ b/CodingTracker/UI/UserInterface.cs
@@ -0,0 +1,208 @@
+using Spectre.Console;
+using CodingTracker.Config;
+using CodingTracker.Database;
+using CodingTracker.Models;
+
+namespace CodingTracker.UI;
+
+internal class UserInterface
+{
+ public void Run()
+ {
+ while (true)
+ {
+ AnsiConsole.Clear();
+ var choice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("[green]Coding Tracker[/]")
+ .AddChoices(
+ "View Records",
+ "Add Record",
+ "Start Live Session",
+ "Update Record",
+ "Delete Record",
+ "Reports",
+ "Exit"));
+
+ switch (choice)
+ {
+ case "View Records": ViewRecords(); break;
+ case "Add Record": AddRecord(); break;
+ case "Start Live Session": LiveSession(); break;
+ case "Update Record": UpdateRecord(); break;
+ case "Delete Record": DeleteRecord(); break;
+ case "Reports": Reports(); break;
+ case "Exit": return;
+ }
+
+ AnsiConsole.MarkupLine("[grey]Press any key to continue...[/]");
+ AnsiConsole.Console.Input.ReadKey(true);
+ }
+ }
+
+
+ static void ViewRecords()
+ {
+ var sessions = DatabaseManager.All();
+ if (sessions.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No records yet.[/]");
+ return;
+ }
+
+ var table = new Table()
+ .AddColumn("Id")
+ .AddColumn("Start")
+ .AddColumn("End")
+ .AddColumn("Duration");
+
+ foreach (var s in sessions)
+ table.AddRow(
+ s.Id.ToString(),
+ s.StartTime.ToString(AppConfig.DateFormat),
+ s.EndTime.ToString(AppConfig.DateFormat),
+ FormatDuration(s.Duration));
+
+ AnsiConsole.Write(table);
+ }
+
+ static void AddRecord()
+ {
+ var start = PromptDate("Start time");
+ var end = PromptDate("End time");
+
+ if (end <= start)
+ {
+ AnsiConsole.MarkupLine("[red]End time must be after start time.[/]");
+ return;
+ }
+
+ if (!Confirm($"Add session {start.ToString(AppConfig.DateFormat)} -> {end.ToString(AppConfig.DateFormat)}?"))
+ return;
+
+ DatabaseManager.Add(start, end);
+ AnsiConsole.MarkupLine("[green]Record added.[/]");
+ }
+
+ static void LiveSession()
+ {
+ var start = DateTime.Now;
+ AnsiConsole.MarkupLine($"[green]Session started at {start.ToString(AppConfig.DateFormat)}.[/] Press any key to stop...");
+
+ AnsiConsole.Live(new Markup(""))
+ .Start(ctx =>
+ {
+ while (!Console.KeyAvailable)
+ {
+ ctx.UpdateTarget(new Markup($"Time: [yellow]{FormatDuration(DateTime.Now - start)}[/]"));
+ ctx.Refresh();
+ Thread.Sleep(1000);
+ }
+ });
+ Console.ReadKey(true);
+
+ var end = DateTime.Now;
+ DatabaseManager.Add(start, end);
+ AnsiConsole.MarkupLine($"[green]Session saved.[/] Duration: {FormatDuration(end - start)}");
+ }
+
+ static void UpdateRecord()
+ {
+ var id = PromptExistingId();
+ if (id is null) return;
+
+ var start = PromptDate("New start time");
+ var end = PromptDate("New end time");
+
+ if (end <= start)
+ {
+ AnsiConsole.MarkupLine("[red]End time must be after start time.[/]");
+ return;
+ }
+
+ if (!Confirm($"Update record {id}?")) return;
+
+ DatabaseManager.Update(id.Value, start, end);
+ AnsiConsole.MarkupLine("[green]Record updated.[/]");
+ }
+
+ static void DeleteRecord()
+ {
+ var id = PromptExistingId();
+ if (id is null) return;
+
+ if (!Confirm($"Delete record {id}?")) return;
+
+ DatabaseManager.Delete(id.Value);
+ AnsiConsole.MarkupLine("[green]Record deleted.[/]");
+ }
+
+ static void Reports()
+ {
+ var sessions = DatabaseManager.All();
+ if (sessions.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No records to report on.[/]");
+ return;
+ }
+
+ var total = sessions.Aggregate(TimeSpan.Zero, (sum, s) => sum + s.Duration);
+ var average = total / sessions.Count;
+
+ var now = DateTime.Now;
+ var weekStart = now.Date.AddDays(-(int)now.DayOfWeek);
+ var monthStart = new DateTime(now.Year, now.Month, 1);
+
+ var thisWeek = sessions.Where(s => s.StartTime >= weekStart)
+ .Aggregate(TimeSpan.Zero, (sum, s) => sum + s.Duration);
+ var thisMonth = sessions.Where(s => s.StartTime >= monthStart)
+ .Aggregate(TimeSpan.Zero, (sum, s) => sum + s.Duration);
+
+ var table = new Table().AddColumn("Metric").AddColumn("Value");
+ table.AddRow("Total sessions", sessions.Count.ToString());
+ table.AddRow("Total time", FormatDuration(total));
+ table.AddRow("Average per session", FormatDuration(average));
+ table.AddRow("This week", FormatDuration(thisWeek));
+ table.AddRow("This month", FormatDuration(thisMonth));
+
+ AnsiConsole.Write(table);
+ }
+
+ // --- helpers ---
+
+ static DateTime PromptDate(string label)
+ {
+ var input = AnsiConsole.Prompt(
+ new TextPrompt($"{label} ([grey]{AppConfig.DateFormat}[/]):")
+ .Validate(value =>
+ DateTime.TryParseExact(
+ value,
+ AppConfig.DateFormat,
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None,
+ out _)
+ ? ValidationResult.Success()
+ : ValidationResult.Error($"[red]Use the format {AppConfig.DateFormat}[/]")));
+
+ return DateTime.ParseExact(
+ input,
+ AppConfig.DateFormat,
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None);
+ }
+
+ static int? PromptExistingId()
+ {
+ var id = AnsiConsole.Prompt(new TextPrompt("Record [green]Id[/]:"));
+ if (DatabaseManager.Exists(id)) return id;
+
+ AnsiConsole.MarkupLine($"[red]No record with Id {id}.[/]");
+ return null;
+ }
+
+ static bool Confirm(string message) =>
+ AnsiConsole.Prompt(new ConfirmationPrompt(message));
+
+ static string FormatDuration(TimeSpan d) =>
+ $"{(int)d.TotalHours:D2}:{d.Minutes:D2}:{d.Seconds:D2}";
+}
diff --git a/CodingTracker/appsettings.json b/CodingTracker/appsettings.json
new file mode 100644
index 000000000..fff526102
--- /dev/null
+++ b/CodingTracker/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "ConnectionStrings": {
+ "Default": "Data Source=coding-tracker.db"
+ },
+ "Database": {
+ "Path": "coding-tracker.db"
+ },
+ "DateFormat": "yyyy-MM-dd HH:mm"
+}
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..cec42d52b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,61 @@
+# Coding Tracker
+
+A .NET 10 console app for tracking coding sessions. Each record is a session with a
+start and end time; the **duration is computed, not stored**. Records can be entered
+manually or timed live with a built-in stopwatch, and a reports view summarizes totals.
+
+Built with **Dapper** over **SQLite**, a **Spectre.Console** UI, and configuration read
+from **`appsettings.json`** via `Microsoft.Extensions.Configuration`.
+
+## Features
+
+- View, add, update, and delete coding sessions (CRUD)
+- **Live stopwatch** — start a session and stop it with a keypress; it saves automatically
+- **Reports** — total sessions, total time, average session length, this week, this month
+
+## Requirements
+
+- .NET 10 SDK
+
+## Run
+
+From the project directory (the folder containing `CodingTracker.csproj`):
+
+```sh
+dotnet run
+```
+
+The SQLite database file (`coding-tracker.db` by default) is created next to the
+executable on first run, and `appsettings.json` is copied to the output folder.
+
+## Configuration
+
+Edit `appsettings.json`:
+
+```json
+{
+ "ConnectionStrings": {
+ "Default": "Data Source=coding-tracker.db"
+ },
+ "Database": {
+ "Path": "coding-tracker.db"
+ },
+ "DateFormat": "yyyy-MM-dd HH:mm"
+}
+```
+
+- `ConnectionStrings:Default` — the SQLite connection string Dapper uses.
+- `Database:Path` — the database file path, surfaced separately for reference.
+- `DateFormat` — how dates are parsed on input and shown on screen.
+
+## Project layout
+
+```
+CodingTracker/
+├── appsettings.json App configuration (copied to output)
+├── Program.cs Entry point: Init() then Run()
+├── Config/AppConfig.cs Reads appsettings.json once at startup
+├── Models/CodingSession.cs record(Id, StartTime, EndTime) with computed Duration
+├── Database/DatabaseManager.cs Dapper data access (CRUD)
+└── UI/UserInterface.cs Spectre.Console menu loop
+```