Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -478,3 +478,4 @@ $RECYCLE.BIN/
*.lnk
/MathGame2
/CodingTracker.TomDonegan/TextFile1.txt
.claude
30 changes: 30 additions & 0 deletions CodingTracker/CodingTracker.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.9" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="Spectre.Console" Version="0.57.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
</ItemGroup>

<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

</ItemGroup>

<ItemGroup>
<Folder Include="UI\" />
</ItemGroup>

</Project>
3 changes: 3 additions & 0 deletions CodingTracker/CodingTracker.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Solution>
<Project Path="CodingTracker.csproj" />
</Solution>
22 changes: 22 additions & 0 deletions CodingTracker/Config/AppConfig.cs
Original file line number Diff line number Diff line change
@@ -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";
}
}
176 changes: 176 additions & 0 deletions CodingTracker/DOCS.md
Original file line number Diff line number Diff line change
@@ -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<long>("SELECT COUNT(1) ...") > 0` |
| `All()` | Returns all sessions, newest first | `Query<CodingSession>("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<CodingSession>(...)` runs the SQL and materializes each row into a
`CodingSession` automatically by name. No `DataReader` loop, no manual `GetInt32`/`GetString`.
- **Scalars** — `ExecuteScalar<long>(...)` 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<DateTime>`; 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.
72 changes: 72 additions & 0 deletions CodingTracker/Database/DatabaseManager.cs
Original file line number Diff line number Diff line change
@@ -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<long>(
"SELECT COUNT(1) FROM coding_sessions WHERE Id = @id;", new { id }) > 0;
}

public static List<CodingSession> 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();
}
}
6 changes: 6 additions & 0 deletions CodingTracker/Models/CodingSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CodingTracker.Models;

record CodingSession(int Id, DateTime StartTime, DateTime EndTime)
{
public TimeSpan Duration => EndTime - StartTime;
}
5 changes: 5 additions & 0 deletions CodingTracker/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using CodingTracker.Database;
using CodingTracker.UI;

DatabaseManager.Init();
new UserInterface().Run();
Loading