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" + } +}