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
523 changes: 51 additions & 472 deletions .gitignore

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions CodingTracker.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/CodingTracker/CodingTracker.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/CodingTracker.Tests/CodingTracker.Tests.csproj" />
</Folder>
</Solution>
1 change: 1 addition & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
wefhi
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
22 changes: 22 additions & 0 deletions src/CodingTracker/CodingTracker.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<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.79" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<PackageReference Include="Spectre.Console" Version="0.57.2" />
</ItemGroup>

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

</Project>
39 changes: 39 additions & 0 deletions src/CodingTracker/Configuration/AppSettings.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> 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<AppSettings>(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();
}
}
62 changes: 62 additions & 0 deletions src/CodingTracker/Controllers/CodingController.cs
Original file line number Diff line number Diff line change
@@ -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<CodingSession> GetSessions(SessionFilter filter)
{
IEnumerable<CodingSession> 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.");
}
}
85 changes: 85 additions & 0 deletions src/CodingTracker/Data/CodingSessionRepository.cs
Original file line number Diff line number Diff line change
@@ -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<int>("""
INSERT INTO CodingSessions (StartTime, EndTime)
VALUES (@StartTime, @EndTime);
SELECT last_insert_rowid();
""", ToParameters(session));
}

public List<CodingSession> GetAll()
{
using var connection = OpenConnection();
var rows = connection.Query<SessionRow>(
"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; } = "";
}
}
12 changes: 12 additions & 0 deletions src/CodingTracker/Data/ICodingSessionRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using CodingTracker.Models;

namespace CodingTracker.Data;

public interface ICodingSessionRepository
{
void InitializeDatabase();
int Create(CodingSession session);
List<CodingSession> GetAll();
bool Update(CodingSession session);
bool Delete(int id);
}
9 changes: 9 additions & 0 deletions src/CodingTracker/Models/CodingSession.cs
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions src/CodingTracker/Models/SessionFilter.cs
Original file line number Diff line number Diff line change
@@ -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);
21 changes: 21 additions & 0 deletions src/CodingTracker/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions src/CodingTracker/Services/IClock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace CodingTracker.Services;

public interface IClock { DateTime Now { get; } }
public sealed class SystemClock : IClock { public DateTime Now => DateTime.Now; }
17 changes: 17 additions & 0 deletions src/CodingTracker/Services/Validation.cs
Original file line number Diff line number Diff line change
@@ -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;
}
29 changes: 29 additions & 0 deletions src/CodingTracker/UI/SessionTable.cs
Original file line number Diff line number Diff line change
@@ -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<CodingSession> 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}";
}
Loading