diff --git a/Coding Tracker/Coding Tracker.slnx b/Coding Tracker/Coding Tracker.slnx new file mode 100644 index 00000000..f4dfc280 --- /dev/null +++ b/Coding Tracker/Coding Tracker.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/Coding Tracker/Coding Tracker/Coding Tracker.csproj b/Coding Tracker/Coding Tracker/Coding Tracker.csproj new file mode 100644 index 00000000..19b303eb --- /dev/null +++ b/Coding Tracker/Coding Tracker/Coding Tracker.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + Coding_Tracker + enable + enable + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/Coding Tracker/Coding Tracker/Controllers/AutoTimerSessionController.cs b/Coding Tracker/Coding Tracker/Controllers/AutoTimerSessionController.cs new file mode 100644 index 00000000..b0d2dabf --- /dev/null +++ b/Coding Tracker/Coding Tracker/Controllers/AutoTimerSessionController.cs @@ -0,0 +1,55 @@ +using Spectre.Console; +using Coding_Tracker.Models; +using Coding_Tracker.Repositories; + +namespace Coding_Tracker.Controllers +{ + internal class AutoTimerSessionController : ISessionController + { + private readonly ConsoleUI _ui; + private readonly CodingSessionRepository _repo; + public AutoTimerSessionController(CodingSessionRepository repo, ConsoleUI ui) + { + _repo = repo; + _ui = ui; + } + + public void StartSession() + { + var start = DateTime.Now; + DisplayStopWatch(); + var end = DateTime.Now; + + _repo.AddSession(CodingSession.Create(start, end)); + } + + public void DisplayStopWatch() + { + float timer = 0f; + var stopWatchPanel = new Panel(""); + stopWatchPanel.Border = BoxBorder.Rounded; + stopWatchPanel.Header = new PanelHeader("[green]Coding Session In Progress[/]"); + while (true) + { + stopWatchPanel = new Panel( + new Markup($"[yellow]{TimeSpan.FromSeconds(timer):hh\\:mm\\:ss}[/]") + ); + AnsiConsole.Write(stopWatchPanel); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[grey]Press [blue]S[/] to stop the session...[/]"); + if (Console.KeyAvailable) + { + var key = Console.ReadKey(true); + if (key.Key == ConsoleKey.S) + { + break; + } + } + timer += 1f; + Thread.Sleep(1000); + AnsiConsole.Clear(); + } + + } + } +} diff --git a/Coding Tracker/Coding Tracker/Controllers/ConsoleUI.cs b/Coding Tracker/Coding Tracker/Controllers/ConsoleUI.cs new file mode 100644 index 00000000..aa4512c1 --- /dev/null +++ b/Coding Tracker/Coding Tracker/Controllers/ConsoleUI.cs @@ -0,0 +1,142 @@ +using Spectre.Console; +using Spectre.Console.Rendering; +using System.Data; + +namespace Coding_Tracker.Controllers +{ + internal class ConsoleUI + { + + private const string DateFormat = "dd-MM-yyyy HH:mm"; + public void DisplayMessage(string message, bool isError, string colour = "yellow") + { + var panel = new Panel(message) + { + Border = BoxBorder.Rounded, + Padding = new Padding(1, 1), + Header = new PanelHeader(isError ? "[red]Error[/]" : "[green]Info[/]") + }; + AnsiConsole.Write(panel); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[grey]Press any key to continue...[/]"); + Console.ReadKey(true); + } + + public bool ConfirmDeletion(string itemName) + { + return AnsiConsole.Confirm($"[red]Are you sure you want to delete[/] [yellow]{itemName}[/]? This action cannot be undone."); + } + + public DateTime PromptForDateTime(string prompt) + { + return PromptUntilValid( + prompt, + DateFormat.ToLower(), + input => DateTimeValidator.ValidateDateResponse(input, DateFormat), + $"Invalid date format. Please use the format: {DateFormat.ToLower()}"); + } + public DateTime PromptForEndDateTime(string prompt, DateTime startTime) + { + return PromptUntilValid( + prompt, + DateFormat.ToLower(), + input => DateTimeValidator.ValidateEndDateResponse(startTime, input, DateFormat), + $"Invalid end time. It must be after the start time and use the format: {DateFormat.ToLower()}"); + } + private T PromptUntilValid(string prompt, string formatHint, Func parse, string errorMessage) + { + while (true) + { + try + { + var input = AnsiConsole.Ask($"{prompt} format: {formatHint}:"); + return parse(input); + } + catch (FormatException) + { + DisplayMessage(errorMessage, true); + } + } + } + + public void DisplayActivityHeatMap(List sessions) + { + var today = DateTime.Today; + bool isToday = sessions.Any(s => s.StartTime.Date == today); + var startDate = today.AddDays(-29); + + // Sum minutes per day for records with multiple entries + var minutesByDay = sessions + .Where(s => s.StartTime.Date >= startDate && s.StartTime.Date <= today) + .GroupBy(s => s.StartTime.Date) + .ToDictionary(g => g.Key, g => g.Sum(s => s.Duration.TotalMinutes)); + + // Snap grid start back to Monday + DateTime gridStart = startDate; + while (gridStart.DayOfWeek != DayOfWeek.Monday) + gridStart = gridStart.AddDays(-1); + + // Grid end is 'today'. Calculate number of weeks needed + int totalGridDays = (today - gridStart).Days + 1; + int weekColumns = (int)Math.Ceiling(totalGridDays / 7.0); + + var table = new Table() + .Border(TableBorder.None) + .HideHeaders(); + + table.AddColumn(new TableColumn("")); + for (int c = 0; c < weekColumns; c++) + table.AddColumn(new TableColumn("")); + + string[] labels = { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" }; + + for (int row = 0; row < 7; row++) + { + var cells = new List(); + + // Adding the day labels + cells.Add(new Markup($"[grey]{labels[row]}[/]")); + + for (int col = 0; col < weekColumns; col++) + { + var day = gridStart.AddDays(col * 7 + row); + + if (day < startDate || day > today) + { + cells.Add(new Text(" ", new Style(background: Color.Grey19))); + continue; + } + + var minutes = minutesByDay.TryGetValue(day, out var m) ? m : 0; + + var bg = + minutes > 0 && minutes <= 1 ? Color.LightGreen : + minutes > 1 && minutes <= 30 ? Color.Green : + minutes > 30 ? Color.DarkGreen : + Color.Grey; + + if (isToday && day == today) bg = Color.Blue; + + cells.Add(new Text(" ", new Style(background: bg))); + + } + + table.AddRow(cells.ToArray()); + } + + AnsiConsole.Write(new Spectre.Console.Rule("[green]Last 30 Days Coding Heatmap[/]").Centered()); + AnsiConsole.Write(table); + + //legend text + AnsiConsole.MarkupLine("\nLegend:\n" + + "[on grey] [/] No record\n" + + "[on lightgreen] [/] Up to 30 mins\n" + + "[on green] [/] Between 30 mins and up to 120 mins\n" + + "[on darkgreen] [/] Greater than 120 mins\n" + + "[on blue] [/] Today\n"); + + AnsiConsole.MarkupLine("\nPress any key to return to the main menu..."); + Console.ReadKey(true); + } + } +} diff --git a/Coding Tracker/Coding Tracker/Controllers/ISessionController.cs b/Coding Tracker/Coding Tracker/Controllers/ISessionController.cs new file mode 100644 index 00000000..5f686e14 --- /dev/null +++ b/Coding Tracker/Coding Tracker/Controllers/ISessionController.cs @@ -0,0 +1,9 @@ +using Spectre.Console; + +namespace Coding_Tracker.Controllers +{ + internal interface ISessionController + { + void StartSession(); + } +} diff --git a/Coding Tracker/Coding Tracker/Controllers/ManualTimerSessionController.cs b/Coding Tracker/Coding Tracker/Controllers/ManualTimerSessionController.cs new file mode 100644 index 00000000..8c5ad366 --- /dev/null +++ b/Coding Tracker/Coding Tracker/Controllers/ManualTimerSessionController.cs @@ -0,0 +1,26 @@ +using Coding_Tracker.Repositories; +using Spectre.Console; + +namespace Coding_Tracker.Controllers +{ + internal class ManualTimerSessionController : ISessionController + { + private readonly CodingSessionRepository _repo; + private readonly ConsoleUI _ui; + public ManualTimerSessionController(CodingSessionRepository repo, ConsoleUI ui) + { + _repo = repo; + _ui = ui; + } + + public void StartSession() + { + var start = _ui.PromptForDateTime("Enter the [green]start[/] date and time"); + var end = _ui.PromptForEndDateTime("Enter the [red]end[/] date and time", start); + + _repo.AddSession(CodingSession.Create(start, end)); + } + + + } +} diff --git a/Coding Tracker/Coding Tracker/DatabaseInitialiser.cs b/Coding Tracker/Coding Tracker/DatabaseInitialiser.cs new file mode 100644 index 00000000..94aacdc3 --- /dev/null +++ b/Coding Tracker/Coding Tracker/DatabaseInitialiser.cs @@ -0,0 +1,23 @@ +using Dapper; +using Microsoft.Data.Sqlite; + +namespace Coding_Tracker.Controllers +{ + internal class DatabaseInitialiser + { + public DatabaseInitialiser(string databasePath) + { + using var connection = new SqliteConnection($"Data Source={databasePath}"); + connection.Open(); + + const string createTableQuery = @" + CREATE TABLE IF NOT EXISTS CodingSessions ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + StartTime TEXT NOT NULL, + EndTime TEXT NOT NULL + );"; + + connection.Execute(createTableQuery); + } + } +} diff --git a/Coding Tracker/Coding Tracker/DateTimeValidator.cs b/Coding Tracker/Coding Tracker/DateTimeValidator.cs new file mode 100644 index 00000000..7e34b473 --- /dev/null +++ b/Coding Tracker/Coding Tracker/DateTimeValidator.cs @@ -0,0 +1,24 @@ +namespace Coding_Tracker +{ + public class DateTimeValidator + { + public static DateTime ValidateDateResponse(string input, string format = "dd-MM-yyyy HH:mm") + { + if (DateTime.TryParseExact(input, format, null, System.Globalization.DateTimeStyles.None, out var result)) + { + return result; + } + throw new FormatException($"Input '{input}' is not in the expected format '{format}'."); + } + + public static DateTime ValidateEndDateResponse(DateTime startDate, string input, string format = "dd-MM-yyyy HH:mm") + { + var endDate = ValidateDateResponse(input, format); + if (endDate <= startDate) + { + throw new FormatException($"End date '{endDate}' must be after start date '{startDate}'."); + } + return endDate; + } + } +} diff --git a/Coding Tracker/Coding Tracker/Models/CodingSession.cs b/Coding Tracker/Coding Tracker/Models/CodingSession.cs new file mode 100644 index 00000000..36468c0a --- /dev/null +++ b/Coding Tracker/Coding Tracker/Models/CodingSession.cs @@ -0,0 +1,25 @@ +using Coding_Tracker; + +internal class CodingSession +{ + public int Id { get; set; } + public DateTime StartTime { get; private set; } + public DateTime EndTime { get; private set; } + + public TimeSpan Duration => EndTime - StartTime; + + public static CodingSession Create(DateTime startTime, DateTime endTime) + { + if (endTime <= startTime) + { + throw new ArgumentException("End time must be after start time."); + } + + + return new CodingSession + { + StartTime = startTime, + EndTime = endTime + }; + } +} diff --git a/Coding Tracker/Coding Tracker/Models/Enums.cs b/Coding Tracker/Coding Tracker/Models/Enums.cs new file mode 100644 index 00000000..db37ae4d --- /dev/null +++ b/Coding Tracker/Coding Tracker/Models/Enums.cs @@ -0,0 +1,28 @@ +namespace Coding_Tracker.Models +{ + internal class Enums + { + + internal enum MenuAction + { + Start_Coding_Session = 1, + View_Sessions, + View_Previous_30_Day_Activity_Heatmap, + Exit + } + + internal enum SessionMenuAction + { + Start_Timer = 1, + Enter_Time_Manually, + Back_To_Main_Menu + } + + internal enum ViewSessionsAction + { + View_All_Sessions = 1, + View_Sessions_From_Set_Date_Selection, + Back_To_Main_Menu + } + } +} diff --git a/Coding Tracker/Coding Tracker/Program.cs b/Coding Tracker/Coding Tracker/Program.cs new file mode 100644 index 00000000..07ea1238 --- /dev/null +++ b/Coding Tracker/Coding Tracker/Program.cs @@ -0,0 +1,22 @@ +using Spectre.Console; +using Microsoft.Extensions.Configuration; +using Coding_Tracker.Repositories; +using Coding_Tracker; +using Coding_Tracker.Controllers; + +var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .Build(); + +var dbPath = configuration["Database:ConnectionString"]; + +var databaseInitialiser = new DatabaseInitialiser(dbPath); +var repo = new CodingSessionRepository(dbPath); + +var userInterface = new UserInterface(repo); + +userInterface.MainMenu(); + + + diff --git a/Coding Tracker/Coding Tracker/Repositories/CodingSessionRepository.cs b/Coding Tracker/Coding Tracker/Repositories/CodingSessionRepository.cs new file mode 100644 index 00000000..61e06240 --- /dev/null +++ b/Coding Tracker/Coding Tracker/Repositories/CodingSessionRepository.cs @@ -0,0 +1,53 @@ +using Dapper; +using Microsoft.Data.Sqlite; + +namespace Coding_Tracker.Repositories +{ + internal class CodingSessionRepository + { + private readonly string _connectionString; + + public CodingSessionRepository(string databasePath) + { + _connectionString = $"Data Source={databasePath}"; + } + + private SqliteConnection OpenConnection() => new SqliteConnection(_connectionString); + + public int AddSession(CodingSession session) + { + const string sql = @" + INSERT INTO CodingSessions (StartTime, EndTime) + VALUES (@StartTime, @EndTime); + SELECT last_insert_rowid();"; + + using var connection = OpenConnection(); + return connection.ExecuteScalar(sql, session); + } + + public List GetAllSessions() + { + const string sql = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + ORDER BY StartTime DESC;"; + + using var connection = OpenConnection(); + return connection.Query(sql).ToList(); + } + + public List GetFilteredSession(DateTime? fromDate, DateTime? toDate) + { + const string sql = @" + SELECT Id, StartTime, EndTime + FROM CodingSessions + WHERE (@FromDate IS NULL OR StartTime >= @FromDate) + AND (@ToDate IS NULL OR EndTime <= @ToDate) + ORDER BY StartTime DESC;"; + + using var connection = OpenConnection(); + return connection.Query(sql, new { FromDate = fromDate, ToDate = toDate }).ToList(); + } + + } +} diff --git a/Coding Tracker/Coding Tracker/UserInterface.cs b/Coding Tracker/Coding Tracker/UserInterface.cs new file mode 100644 index 00000000..7e5a051f --- /dev/null +++ b/Coding Tracker/Coding Tracker/UserInterface.cs @@ -0,0 +1,159 @@ +using Coding_Tracker.Controllers; +using Coding_Tracker.Repositories; +using Spectre.Console; +using static Coding_Tracker.Models.Enums; +namespace Coding_Tracker +{ + internal class UserInterface + { + private readonly ConsoleUI _ui = new ConsoleUI(); + private readonly AutoTimerSessionController _autoTimerSessionController; + private readonly ManualTimerSessionController _manualTimerSessionController; + private readonly CodingSessionRepository _repo; + + public UserInterface(CodingSessionRepository repo) + { + _repo = repo; + _autoTimerSessionController = new AutoTimerSessionController(_repo, _ui); + _manualTimerSessionController = new ManualTimerSessionController(_repo, _ui); + } + internal void MainMenu() + { + while (true) + { + Console.Clear(); + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select an option:") + .AddChoices(Enum.GetValues())); + + + + switch (choice) + { + + case MenuAction.Start_Coding_Session: + bool flowControl = StartSession(); + if (!flowControl) + { + continue; + } + break; + + case MenuAction.View_Sessions: + ShowSessions(); + + break; + + case MenuAction.View_Previous_30_Day_Activity_Heatmap: + _ui.DisplayActivityHeatMap(_repo.GetAllSessions()); + break; + case MenuAction.Exit: + Environment.Exit(0); + return; + } + + } + } + + private bool StartSession() + { + var sessionType = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select session type:") + .AddChoices(Enum.GetValues())); + switch (sessionType) + { + case SessionMenuAction.Start_Timer: + _autoTimerSessionController.StartSession(); + break; + case SessionMenuAction.Enter_Time_Manually: + _manualTimerSessionController.StartSession(); + break; + case SessionMenuAction.Back_To_Main_Menu: + return false; + } + + return true; + } + + private void ShowSessions() + { + switch (AnsiConsole.Prompt( + new SelectionPrompt() + .Title("View sessions by:") + .AddChoices(Enum.GetValues() + ))) + { + case ViewSessionsAction.View_All_Sessions: + var allSessions = _repo.GetAllSessions(); + DisplayFilteredSessions(allSessions); + break; + + case ViewSessionsAction.View_Sessions_From_Set_Date_Selection: + FilteredSessions(); + break; + + case ViewSessionsAction.Back_To_Main_Menu: + Environment.Exit(0); + break; + } + } + + private void FilteredSessions() + { + switch (AnsiConsole.Prompt(new SelectionPrompt() + .Title("Please select an option from the following:") + .AddChoices(new[] { + "Yesterday", + "Last 7 Days", + "Last 30 Days", + "Custom Date Range", + "Back to Main Menu" + }))){ + case "Yesterday": + var yesterday = DateTime.Today.AddDays(-1); + var sessionsYesterday = _repo.GetFilteredSession(yesterday, null); + DisplayFilteredSessions(sessionsYesterday); + break; + + case "Last 7 Days": + var last7Days = DateTime.Today.AddDays(-7); + var sessions7Days = _repo.GetFilteredSession(last7Days, null); + DisplayFilteredSessions(sessions7Days); + break; + + case "Last 30 Days": + var last30Days = DateTime.Today.AddDays(-30); + var sessions30Days = _repo.GetFilteredSession(last30Days, null); + DisplayFilteredSessions(sessions30Days); + break; + + case "Custom Date Range": + var fromDate = _ui.PromptForDateTime("Enter the [green]start[/] date"); + var toDate = _ui.PromptForDateTime("Enter the [red]end[/] date"); + var customSessions = _repo.GetFilteredSession(fromDate, toDate); + DisplayFilteredSessions(customSessions); + break; + } + } + + private static void DisplayFilteredSessions(List sessions) + { + if (sessions.Count == 0) + { + AnsiConsole.MarkupLine("[red]No coding sessions found in the specified date range.[/]"); + } + else + { + foreach (var s in sessions) + { + AnsiConsole.MarkupLine($"[green]Session ID:[/] {s.Id}, [green]Start Time:[/] {s.StartTime}, [green]End Time:[/] {s.EndTime}, [green]Duration:[/] {s.Duration:hh\\:mm\\:ss}"); + } + } + AnsiConsole.MarkupLine("\nPress any key to return to the main menu..."); + Console.ReadKey(); + } + } +} diff --git a/Coding Tracker/Coding Tracker/appsettings.json b/Coding Tracker/Coding Tracker/appsettings.json new file mode 100644 index 00000000..4f209301 --- /dev/null +++ b/Coding Tracker/Coding Tracker/appsettings.json @@ -0,0 +1,5 @@ +{ + "Database": { + "ConnectionString": "coding_Tracker.db" + } +} \ No newline at end of file diff --git a/Coding Tracker/Coding_Tracker.Tests/Coding_Tracker.Tests.csproj b/Coding Tracker/Coding_Tracker.Tests/Coding_Tracker.Tests.csproj new file mode 100644 index 00000000..d20ae352 --- /dev/null +++ b/Coding Tracker/Coding_Tracker.Tests/Coding_Tracker.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Coding Tracker/Coding_Tracker.Tests/DateTimeValidatorTests.cs b/Coding Tracker/Coding_Tracker.Tests/DateTimeValidatorTests.cs new file mode 100644 index 00000000..aa7d903d --- /dev/null +++ b/Coding Tracker/Coding_Tracker.Tests/DateTimeValidatorTests.cs @@ -0,0 +1,46 @@ +namespace Coding_Tracker.Tests +{ + public class DateTimeValidatorTests + { + [Fact] + public void ValidateDateResponse_ValidDate_ReturnsDateTime() + { + var validDate = "25-12-2023 14:30"; + var format = "dd-MM-yyyy HH:mm"; + + var result = DateTimeValidator.ValidateDateResponse(validDate, format); + + Assert.Equal(new DateTime(2023, 12, 25, 14, 30, 0), result); + } + + [Fact] + public void ValidateDateResponse_InvalidDate_ThrowsFormatException() + { + var invalidDate = "41/13/2025 14:30"; + var format = "dd-MM-yyyy HH:mm"; + + Assert.Throws(() => DateTimeValidator.ValidateDateResponse(invalidDate, format)); + } + + [Fact] + public void ValidateDateResponse_EmptyString_ThrowsFormatException() + { + var emptyDate = ""; + var format = "dd-MM-yyyy HH:mm"; + Assert.Throws(() => DateTimeValidator.ValidateDateResponse(emptyDate, format)); + } + + [Fact] + public void ValidateEndDateResponse_EndBeforeStart_ThrowsFormatException() + { + + var start = new DateTime(2026, 1, 2, 9, 0, 0); + var endInput = "01-01-2026 09:00"; + var format = "dd-MM-yyyy HH:mm"; + + + Assert.Throws(() => DateTimeValidator.ValidateEndDateResponse(start, endInput, format)); + } + + } +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..632b139c --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Coding Tracker + +A simple console app for tracking coding sessions. Start a timer, log sessions manually, review history by date ranges, and view a 30-day activity heatmap. + +## Features + +- **Track sessions in real time** using a built-in stopwatch. +- **Log sessions manually** with validated start/end times. +- **Review history** for all sessions or a filtered date range (yesterday, last 7 days, last 30 days, or custom dates). +- **Visual activity heatmap** for the previous 30 days. +- **Local SQLite storage** for sessions. + +## Tech Stack + +- **.NET 10** console application +- **SQLite** with **Dapper** for data access +- **Spectre.Console** for rich terminal UI + +## Getting Started + +### Prerequisites + +- .NET SDK 10.0 or later + +### Configure the database + +The SQLite database path is configured in `appsettings.json`: + +```json +{ + "Database": { + "ConnectionString": "coding_Tracker.db" + } +} +``` + +Update the connection string if you want the database stored elsewhere. + +### Run the app + +From the repo root: + +```bash +dotnet run --project "Coding Tracker/Coding Tracker" +``` + +## Usage Overview + +- **Start Coding Session** + - Choose **Start Timer** to track time automatically. + - Choose **Enter Time Manually** to log a past session. +- **View Sessions** + - View all sessions or filter by a specific date range. +- **View Previous 30 Day Activity Heatmap** + - See a quick visual summary of recent activity. + +## Running Tests + +```bash +dotnet test "Coding Tracker/Coding_Tracker.Tests" +``` + +## Project Structure + +``` +Coding Tracker/ +├── Coding Tracker/ # Application source +├── Coding_Tracker.Tests/ # Test project +└── Coding Tracker.slnx +``` + +## Notes + +- Sessions are stored in a local SQLite database file. +- The database schema is created automatically on first run.