From 3f829116aead99b4bac283cd883a5c0af8957de0 Mon Sep 17 00:00:00 2001 From: madme Date: Wed, 24 Jun 2026 16:07:32 +0700 Subject: [PATCH 1/3] My project --- .gitignore | 1 + CodingTracker/CodingTracker.csproj | 30 ++++ CodingTracker/CodingTracker.slnx | 3 + CodingTracker/Config/AppConfig.cs | 22 +++ CodingTracker/DOCS.md | 176 ++++++++++++++++++++ CodingTracker/Database/DatabaseManager.cs | 72 ++++++++ CodingTracker/Models/CodingSession.cs | 6 + CodingTracker/Program.cs | 5 + CodingTracker/README.md | 61 +++++++ CodingTracker/UI/UserInterface.cs | 192 ++++++++++++++++++++++ CodingTracker/appsettings.json | 9 + 11 files changed, 577 insertions(+) create mode 100644 CodingTracker/CodingTracker.csproj create mode 100644 CodingTracker/CodingTracker.slnx create mode 100644 CodingTracker/Config/AppConfig.cs create mode 100644 CodingTracker/DOCS.md create mode 100644 CodingTracker/Database/DatabaseManager.cs create mode 100644 CodingTracker/Models/CodingSession.cs create mode 100644 CodingTracker/Program.cs create mode 100644 CodingTracker/README.md create mode 100644 CodingTracker/UI/UserInterface.cs create mode 100644 CodingTracker/appsettings.json 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/README.md b/CodingTracker/README.md new file mode 100644 index 000000000..cec42d52b --- /dev/null +++ b/CodingTracker/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 +``` diff --git a/CodingTracker/UI/UserInterface.cs b/CodingTracker/UI/UserInterface.cs new file mode 100644 index 000000000..303003acd --- /dev/null +++ b/CodingTracker/UI/UserInterface.cs @@ -0,0 +1,192 @@ +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) => + AnsiConsole.Prompt( + new TextPrompt($"{label} ([grey]{AppConfig.DateFormat}[/]):") + .ValidationErrorMessage($"[red]Use the format {AppConfig.DateFormat}[/]")); + + 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" +} From f511cb11f71b8d56f8cb1a204f2143a195054d43 Mon Sep 17 00:00:00 2001 From: madme <103824674+young-the-tiny@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:11:51 +0700 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CodingTracker/UI/UserInterface.cs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/CodingTracker/UI/UserInterface.cs b/CodingTracker/UI/UserInterface.cs index 303003acd..bc680f141 100644 --- a/CodingTracker/UI/UserInterface.cs +++ b/CodingTracker/UI/UserInterface.cs @@ -170,10 +170,26 @@ static void Reports() // --- helpers --- - static DateTime PromptDate(string label) => - AnsiConsole.Prompt( - new TextPrompt($"{label} ([grey]{AppConfig.DateFormat}[/]):") - .ValidationErrorMessage($"[red]Use the format {AppConfig.DateFormat}[/]")); + 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() { From 5d9ebeb36bb65d39735a656191c8149dab5dfc0c Mon Sep 17 00:00:00 2001 From: madme Date: Wed, 24 Jun 2026 16:14:01 +0700 Subject: [PATCH 3/3] README --- CodingTracker/README.md => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CodingTracker/README.md => README.md (100%) diff --git a/CodingTracker/README.md b/README.md similarity index 100% rename from CodingTracker/README.md rename to README.md