diff --git a/CodingTracker/CodingTracker.slnx b/CodingTracker/CodingTracker.slnx
new file mode 100644
index 00000000..c4e83d0e
--- /dev/null
+++ b/CodingTracker/CodingTracker.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/CodingTracker/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker/CodingTracker.csproj
new file mode 100644
index 00000000..12273964
--- /dev/null
+++ b/CodingTracker/CodingTracker/CodingTracker.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/CodingTracker/CodingTracker/Controllers/CodingController.cs b/CodingTracker/CodingTracker/Controllers/CodingController.cs
new file mode 100644
index 00000000..df4adc0a
--- /dev/null
+++ b/CodingTracker/CodingTracker/Controllers/CodingController.cs
@@ -0,0 +1,128 @@
+using CodingTracker.Model;
+using Dapper;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+
+namespace CodingTracker.Controllers
+{
+ internal class CodingController
+ {
+ private readonly string _connectionString;
+
+ public CodingController()
+ {
+ var config = new ConfigurationBuilder()
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
+ .Build();
+
+ _connectionString = config.GetConnectionString("DefaultConnection") ?? "Data Source=coding-tracker.db";
+
+ InitDatabase();
+ }
+
+ private void InitDatabase()
+ {
+ using var connection = new SqliteConnection(_connectionString);
+ string initSql = @"
+ CREATE TABLE IF NOT EXISTS CodingSessions(
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ StartTime TEXT NOT NULL,
+ EndTime TEXT NOT NULL
+ );";
+ connection.Execute(initSql);
+ }
+
+ public bool InsertSql(CodingSession session)
+ {
+ using var connection = new SqliteConnection(_connectionString);
+ try
+ {
+ string sql = @"
+ INSERT INTO CodingSessions (StartTime, EndTime)
+ VALUES (@StartTime, @EndTime);";
+
+ int rowsAffected = connection.Execute(sql, new
+ {
+ StartTime = session.StartTime.ToString("dd-MM-yyyy HH:mm:ss"),
+ EndTime = session.EndTime.ToString("dd-MM-yyyy HH:mm:ss")
+ });
+
+ return rowsAffected > 0;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ public List GetSessionHistory()
+ {
+ using var connection = new SqliteConnection(_connectionString);
+ try
+ {
+ string sql = @"
+ SELECT
+ Id,
+ StartTime AS StartTimeString,
+ EndTime AS EndTimeString
+ FROM CodingSessions;";
+
+ // Dapper makes List
+ List sessionList = connection.Query(sql).AsList();
+
+ return sessionList;
+ }
+ catch (Exception)
+ {
+ return new List();
+ }
+ }
+
+ public bool UpdateSessionHistory(CodingSession session)
+ {
+ using var connection = new SqliteConnection(_connectionString);
+ try
+ {
+ string sql = @"
+ UPDATE CodingSessions
+ SET StartTime = @StartTimeString,
+ EndTime = @EndTimeString
+ WHERE Id = @Id;";
+
+ int rowsAffected = connection.Execute(sql, session);
+
+ // true if row was affected
+ return (rowsAffected > 0);
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ public bool DeleteSession(int id)
+ {
+ using var connection = new SqliteConnection(_connectionString);
+ try
+ {
+ string sql = @"
+ DELETE FROM CodingSessions
+ WHERE Id = @Id;";
+
+ int rowsAffected = connection.Execute(sql,new { Id = id });
+
+ // true if row was affected
+ return (rowsAffected > 0);
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/CodingTracker/CodingTracker/Models/CodingSession.cs b/CodingTracker/CodingTracker/Models/CodingSession.cs
new file mode 100644
index 00000000..bec46880
--- /dev/null
+++ b/CodingTracker/CodingTracker/Models/CodingSession.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace CodingTracker.Model
+{
+ internal class CodingSession
+ {
+ public int? Id { get; set; } // NULL before Sql autoincrement
+ public DateTime StartTime { get; set; }
+ public DateTime EndTime { get; set; }
+ public TimeSpan Duration => EndTime - StartTime;
+
+ // String Properties
+ public string StartTimeString { get; set; } = string.Empty;
+ public string EndTimeString { get; set; } = string.Empty;
+ public string DurationString => Duration.TotalHours >= 24
+ ? $"{(int)Duration.TotalHours}:{Duration:mm\\:ss}"
+ : Duration.ToString(@"hh\:mm\:ss");
+
+ public CodingSession() { } //without parameter
+
+ public CodingSession(DateTime startDate, DateTime endDate, int? id = null)
+ {
+ Id = id;
+ StartTime = startDate;
+ EndTime = endDate;
+ StartTimeString = startDate.ToString("dd-MM-yyyy HH:mm:ss");
+ EndTimeString = endDate.ToString("dd-MM-yyyy HH:mm:ss");
+ }
+ public CodingSession(string startDate, string endDate, int? id = null)
+ {
+ Id = id;
+ StartTimeString = startDate;
+ EndTimeString = endDate;
+ StartTime = DateTime.ParseExact(Regex.Replace(StartTimeString?.Trim() ?? "", (@"\s+"), (" ")).Replace(".", "-"),
+ "dd-MM-yyyy HH:mm:ss",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None);
+ EndTime = DateTime.ParseExact(Regex.Replace(EndTimeString?.Trim() ?? "", (@"\s+"), (" ")).Replace(".", "-"),
+ "dd-MM-yyyy HH:mm:ss",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None);
+ }
+
+ }
+
+
+}
diff --git a/CodingTracker/CodingTracker/Models/TimeCalculator.cs b/CodingTracker/CodingTracker/Models/TimeCalculator.cs
new file mode 100644
index 00000000..e7bb6aca
--- /dev/null
+++ b/CodingTracker/CodingTracker/Models/TimeCalculator.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace CodingTracker.Model
+{
+ internal static class TimeCalculator
+ {
+ internal static TimeSpan GetTimeSinceStart(DateTime startDateTime, DateTime currentDateTime)
+ {
+ return currentDateTime - startDateTime;
+ }
+ }
+}
diff --git a/CodingTracker/CodingTracker/Program.cs b/CodingTracker/CodingTracker/Program.cs
new file mode 100644
index 00000000..b248ee4f
--- /dev/null
+++ b/CodingTracker/CodingTracker/Program.cs
@@ -0,0 +1,16 @@
+using CodingTracker.Controllers;
+using CodingTracker.View;
+
+namespace CodingTracker
+{
+ internal class Program
+ {
+ static void Main()
+ {
+ var codingController = new CodingController();
+ var userInterface = new UserInterface(codingController);
+
+ userInterface.Menu();
+ }
+ }
+}
diff --git a/CodingTracker/CodingTracker/UserInput.cs b/CodingTracker/CodingTracker/UserInput.cs
new file mode 100644
index 00000000..c2b926e6
--- /dev/null
+++ b/CodingTracker/CodingTracker/UserInput.cs
@@ -0,0 +1,74 @@
+using Spectre.Console;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace CodingTracker
+{
+ internal static class UserInput
+ {
+
+ public static void WaitForUser(string message = "Press any key to continue...")
+ {
+ AnsiConsole.MarkupLine($"\n[grey]{message}[/]");
+ Console.ReadKey(true);
+ Console.Clear();
+ }
+
+ public static string AnySelection(string msg, string[] choices)
+ {
+ string choice = " ";
+ choice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title($"[green]{msg}[/]")
+ .AddChoices(choices));
+
+ return choice;
+ }
+
+ public static int GetIntFromUser(List validIDs)
+ {
+ int value;
+ bool incorrectInt = true;
+ do
+ {
+ AnsiConsole.Markup("Enter valid positive Integer here: ");
+ incorrectInt = !Validation.CheckStringToInt(Console.ReadLine(), out value);
+ if (incorrectInt || !(validIDs.Contains(value)))
+ {
+ AnsiConsole.MarkupLine($"[bold red]INVALID[/] Input. Try again.");
+ incorrectInt = true;
+ }
+ } while (incorrectInt);
+ return value;
+ }
+
+ public static string GetString(string promptMessage = "Enter here: ")
+ {
+ while (true)
+ {
+ AnsiConsole.Markup(promptMessage);
+ string? input = Console.ReadLine()?.Trim();
+
+ if (!string.IsNullOrWhiteSpace(input))
+ {
+ return input;
+ }
+
+ AnsiConsole.MarkupLine("[bold red]Input cannot be empty. Please try again.[/]");
+ }
+ }
+ public static void StopSessionOnKeyPress()
+ {
+ Console.ReadKey(true);
+ }
+
+ public static void ClearInputBuffer()
+ {
+ while (Console.KeyAvailable)
+ {
+ Console.ReadKey(true);
+ }
+ }
+ }
+}
diff --git a/CodingTracker/CodingTracker/Validation.cs b/CodingTracker/CodingTracker/Validation.cs
new file mode 100644
index 00000000..3438f107
--- /dev/null
+++ b/CodingTracker/CodingTracker/Validation.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Globalization;
+
+using System.Text.RegularExpressions;
+
+namespace CodingTracker
+{
+ internal static class Validation
+ {
+ public static bool ValidateStringToDateTime(string? dateTime, out DateTime parsedDateTime)
+ {
+ return DateTime.TryParseExact(
+ Regex.Replace(dateTime?.Trim() ?? "", (@"\s+"), (" ")).Replace(".", "-"), // ?? "" crash safe - Regex.Replace(input, pattern, replacement)
+ "dd-MM-yyyy HH:mm:ss",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out parsedDateTime);
+ }
+
+ public static bool CheckStringToInt(string? input, out int value)
+ {
+ bool check = int.TryParse(input, out value);
+ return check;
+ }
+
+ public static bool IsEndTimeValid(DateTime start, DateTime end)
+ {
+ return end > start;
+ }
+ }
+}
diff --git a/CodingTracker/CodingTracker/View/UserInterface.cs b/CodingTracker/CodingTracker/View/UserInterface.cs
new file mode 100644
index 00000000..ba097c45
--- /dev/null
+++ b/CodingTracker/CodingTracker/View/UserInterface.cs
@@ -0,0 +1,321 @@
+using CodingTracker.Controllers;
+using CodingTracker.Model;
+using Spectre.Console;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using CodingTracker;
+
+namespace CodingTracker.View
+{
+ internal class UserInterface
+ {
+ private readonly CodingController _controller;
+ public UserInterface(CodingController controller)
+ {
+ _controller = controller;
+ }
+
+
+ public void Menu()
+ {
+ bool runMenu = true;
+ AnsiConsole.MarkupLine("[#FFC0CB]Welcome to CodingTracker![/]");
+ do
+ {
+ var codingMenu = UserInput.AnySelection("What do you want to do?", ["Start Coding Session", "Add Coding Session", "Coding History", "End Application"]);
+
+ switch (codingMenu)
+ {
+ case "Start Coding Session":
+ {
+ CodingSession? session = StartCodingSession();
+ if (session != null)
+ {
+ SaveSession(session);
+ }
+ break;
+ }
+ case "Add Coding Session":
+ {
+ CodingSession? session = ManualEnterCodingSession();
+ if (session != null)
+ {
+ SaveSession(session);
+ }
+ break;
+ }
+ case "Coding History":
+ {
+ List historyList = GetHistory();
+ if (historyList.Count == 0)
+ {
+ AnsiConsole.MarkupLine("\n[yellow]No sessions found yet.[/]");
+ UserInput.WaitForUser();
+ Console.Clear();
+ break;
+ }
+
+ DisplayHistory(historyList);
+ List validIDs = GetValidIDs(historyList);
+ CRUDoperation(validIDs);
+ break;
+ }
+ case "End Application":
+ {
+ AnsiConsole.MarkupLine("[blue]Good Job! See you soon![/]");
+ runMenu = false;
+ break;
+ }
+ default:
+ {
+ AnsiConsole.MarkupLine("[bold red]An unexpected Error has occurred.[/]");
+ break;
+ }
+ }
+ } while (runMenu);
+ }
+ private void SaveSession(CodingSession session)
+ {
+ bool inserted = _controller.InsertSql(session);
+ if (inserted)
+ {
+ AnsiConsole.MarkupLine("[green]Session successfully saved to database![/]");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine("[bold red]Failed to save session to database.[/]");
+ }
+ }
+ private List GetValidIDs(List list)
+ {
+ List idList = new List();
+ foreach (CodingSession session in list)
+ {
+ if(session.Id.HasValue) idList.Add(session.Id.Value);
+ }
+ return idList;
+ }
+
+ private void CRUDoperation(List validIDs)
+ {
+ bool runCRUD = true;
+ while (runCRUD)
+ {
+ if (validIDs.Count == 0) // check if any records left
+ {
+ AnsiConsole.MarkupLine("[yellow]No records remaining. Returning to menu...[/]");
+ break;
+ }
+
+ string doContinue = UserInput.AnySelection("Do you want to create, update or delete DATA ?", ["Create","Update","Delete","Back to Menu"]);
+ switch (doContinue)
+ {
+ case "Create":
+ CodingSession? session = ManualEnterCodingSession();
+ if (session == null)
+ {
+ break;
+ }
+
+ if (_controller.InsertSql(session))
+ {
+ AnsiConsole.MarkupLine("[green]Added Session![/]");
+ List historyList = GetHistory();
+ DisplayHistory(historyList);
+ validIDs = GetValidIDs(historyList);
+ }
+ else
+ {
+ AnsiConsole.MarkupLine("[bold red]Failed to add session.[/]");
+ }
+ break;
+ case "Update":
+ {
+ AnsiConsole.MarkupLine("Enter [red]Id[/] of Session you want to update.");
+ int inputID = UserInput.GetIntFromUser(validIDs);
+
+ (DateTime startDateTime, DateTime endDateTime) = PromptStartAndEndTime();
+
+ // NEW SESSION
+ CodingSession newSession = new CodingSession(startDateTime, endDateTime, inputID);
+ bool updated = _controller.UpdateSessionHistory(newSession);
+ if (updated)
+ {
+ AnsiConsole.MarkupLine("[green]Updated successfully![/]");
+ List historyList = GetHistory();
+ DisplayHistory(historyList);
+ validIDs = GetValidIDs(historyList);
+ }
+ else
+ {
+ AnsiConsole.MarkupLine("[bold red]Did not Update. Unexpected Error.[/]");
+ }
+ break;
+ }
+
+ case "Delete":
+ {
+ AnsiConsole.MarkupLine("Enter [red]Id[/] of Session you want to delete.");
+ int inputID = UserInput.GetIntFromUser(validIDs);
+ bool deleted = _controller.DeleteSession(inputID);
+ if (deleted)
+ {
+ AnsiConsole.MarkupLine("[green]Deleted successfully![/]");
+ List historyList = GetHistory();
+ DisplayHistory(historyList);
+ validIDs.Remove(inputID);
+ }
+ else
+ {
+ AnsiConsole.MarkupLine("[bold red]Did not Delete. Unexpected Error.[/]");
+ }
+ break;
+ }
+ case "Back to Menu":
+ Console.Clear();
+ runCRUD = false;
+ break;
+ }
+ }
+ }
+
+ private void DisplayHistory(List historyList)
+ {
+ var table = new Table();
+
+ // Add columns
+ table.AddColumn("Id");
+ table.AddColumn("Start Time");
+ table.AddColumn("End Time");
+ table.AddColumn("Duration");
+
+ // Add rows
+ foreach (CodingSession rawSession in historyList)
+ {
+ CodingSession completedSession = new CodingSession(rawSession.StartTimeString, rawSession.EndTimeString, rawSession.Id);
+ table.AddRow($"{completedSession.Id.ToString()}", $"{completedSession.StartTimeString}", $"{completedSession.EndTimeString}", $"{completedSession.DurationString}");
+ }
+ AnsiConsole.Write(table);
+ }
+
+ private List GetHistory()
+ {
+ List list = _controller.GetSessionHistory();
+ return list;
+ }
+
+ private CodingSession? ManualEnterCodingSession()
+ {
+ do
+ {
+ (DateTime startDateTime, DateTime endDateTime) =PromptStartAndEndTime();
+ // TIMESPAN
+ TimeSpan manualTimeSpan = TimeCalculator.GetTimeSinceStart(startDateTime, endDateTime);
+
+ var doContinue = UserInput.AnySelection($"Is {manualTimeSpan.ToString(@"hh\:mm\:ss")} the correct timespan?", ["Correct!", "Try again!", "Abort, back to menu."]);
+
+ switch (doContinue)
+ {
+ case "Correct!":
+ return new CodingSession(startDateTime, endDateTime);
+
+ case "Try again!":
+ break;
+
+ case "Abort, back to menu.":
+ return null;
+ }
+ } while (true);
+
+ }
+
+ private DateTime GetDateTimeFromUser()
+ {
+ do
+ {
+ AnsiConsole.Markup("Enter [bold red]valid[/] Date and Time(dd-MM-yyyy HH:mm:ss): ");
+ string dateTime = UserInput.GetString();
+ bool checkDateTimeValid = Validation.ValidateStringToDateTime(dateTime, out DateTime parsedDateTime);
+ if (checkDateTimeValid)
+ {
+ AnsiConsole.MarkupLine($"[green]Valid:[/] {parsedDateTime}");
+ return parsedDateTime;
+ }
+ else
+ {
+ AnsiConsole.MarkupLine("[red]Invalid Format![/]");
+ }
+
+ } while (true);
+
+ }
+
+ private CodingSession? StartCodingSession()
+ {
+ DateTime startDateTime = DateTime.Now;
+ AnsiConsole.MarkupLine($"[#FFA500]Current Date(dd-MM-yyyy): {startDateTime.ToString("dd-MM-yyyy")}[/]");
+ AnsiConsole.MarkupLine($"[blue]Current Time(HH:mm:ss): {startDateTime.ToString("HH:mm:ss")}[/]");
+
+ var doContinue = UserInput.AnySelection("Do you want to start the timer?", ["Start Coding", "Go back to Menu"]);
+
+ if (doContinue != "Start Coding")
+ {
+ return null;
+ }
+
+ DateTime currentDateTime = DateTime.Now;
+ startDateTime = DateTime.Now;
+
+ var table = new Table().AddColumn("DateTime").Width(80).AddColumn("Trait");
+ table.AddRow($"{startDateTime.ToString("dd-MM-yyyy HH:mm:ss")}", "Start");
+ table.AddRow($"", "");
+ table.AddRow($"", "");
+
+ AnsiConsole.MarkupLine($"[#FFA500]Started at:[/] {startDateTime:dd-MM-yyyy HH:mm:ss}");
+ AnsiConsole.MarkupLine("[grey]Press any key to end session...[/]\n");
+
+ UserInput.ClearInputBuffer();
+ AnsiConsole.Live(table)
+ .Start(ctx =>
+ {
+ while (!Console.KeyAvailable)
+ {
+ currentDateTime = DateTime.Now;
+ table.RemoveRow(1);
+ table.InsertRow(1, $"{currentDateTime.ToString("dd-MM-yyyy HH:mm:ss")}", "Current");
+ table.RemoveRow(2);
+ TimeSpan timePassed = TimeCalculator.GetTimeSinceStart(startDateTime, currentDateTime);
+ table.InsertRow(2, $"[#FFA500]{timePassed.ToString(@"hh\:mm\:ss")}[/]", "[#FFA500]Time passed since start[/]");
+ ctx.Refresh();
+ Thread.Sleep(200);
+ }
+ });
+
+ UserInput.StopSessionOnKeyPress();
+ AnsiConsole.MarkupLine("Congrats! You have finished your Coding Session.");
+ return new CodingSession(startDateTime, currentDateTime);
+ }
+ private (DateTime start, DateTime end) PromptStartAndEndTime()
+ {
+ AnsiConsole.MarkupLine("Enter start time.");
+ DateTime start = GetDateTimeFromUser();
+
+ DateTime end;
+ bool invalid;
+ do
+ {
+ AnsiConsole.MarkupLine("Enter end time.");
+ end = GetDateTimeFromUser();
+ invalid = !Validation.IsEndTimeValid(start, end);
+
+ if (invalid)
+ {
+ AnsiConsole.MarkupLine("[bold red]End time must be after start time.[/]");
+ }
+ } while (invalid);
+
+ return (start, end);
+ }
+ }
+}
diff --git a/CodingTracker/CodingTracker/appsettings.json b/CodingTracker/CodingTracker/appsettings.json
new file mode 100644
index 00000000..0908081b
--- /dev/null
+++ b/CodingTracker/CodingTracker/appsettings.json
@@ -0,0 +1,5 @@
+{
+ "ConnectionStrings": {
+ "DefaultConnection": "Data Source=coding-tracker.db"
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..16f9d18d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,45 @@
+# Coding Tracker
+
+A console-based productivity application to log and manage coding sessions, developed with C#, SQLite, Dapper, and Spectre.Console.
+
+## Features
+
+- **Live Session Timer**: Start an interactive coding session with a live-updating Spectre console display showing elapsed time in real-time.
+- **Manual Logging**: Add past coding sessions manually with strict input parsing.
+- **Session History & Management**: View recorded sessions formatted in clean CLI tables.
+- **Full CRUD Operations**: Create, Read, Update, and Delete sessions with immediate ID validation.
+
+## Architecture & Code Structure
+
+The project strictly follows the Single Responsibility Principle and separation of concerns:
+
+- `CodingSession`: Data model representing a coding session, storing timestamps and calculating durations.
+- `CodingController`: Handles database persistence and executes parameterized SQL queries using **Dapper**.
+- `UserInterface`: Manages menu flows, screen outputs, and table renderings via **Spectre.Console**.
+- `UserInput`: Centralizes console input collection, menu prompts, and buffer management.
+- `Validation`: Enforces constraints, positive integer parsing, and date-time validation (`dd-MM-yyyy HH:mm:ss`).
+- `TimeCalculator`: Helper logic for calculating differences between session timestamps.
+
+## Technologies Used
+
+- **C# / .NET**
+- **SQLite** (`Microsoft.Data.Sqlite`)
+- **Dapper** (Micro-ORM for parameterized queries)
+- **Spectre.Console** (CLI rendering and interactive prompts)
+- **Microsoft.Extensions.Configuration** (Database connection string via `appsettings.json`)
+
+## Thought Process
+- **Architecture First**: Before writing business logic, I mapped out dedicated responsibilities (`CodingController` for persistence, `UserInput` for prompts, `Validation` for parsing) to ensure maintainability and clean data flow.
+- **User Experience**: Console apps often suffer from clunky navigation. By leveraging `Spectre.Console`, I focused on clear table layouts, real-time visual feedback for active sessions, and defensive input parsing to prevent runtime crashes.
+- **Data Integrity**: Enforcing strict timestamp formats and parameterized queries via Dapper was prioritized early to avoid malformed session records in SQLite.
+
+## Configuration
+
+Ensure `appsettings.json` contains a valid SQLite connection string:
+
+```json
+{
+ "ConnectionStrings": {
+ "DefaultConnection": "Data Source=codingTracker.db"
+ }
+}