diff --git a/ActionExecuter.cs b/ActionExecuter.cs new file mode 100644 index 00000000..c8326238 --- /dev/null +++ b/ActionExecuter.cs @@ -0,0 +1,65 @@ +using Coding.Tracker.Commands; +using Spectre.Console; + +namespace Coding.Tracker +{ + + public static class ActionExecuter + { + private readonly static string startSession = "Start new session"; + private readonly static string addSession = "Add new session"; + private readonly static string viewSessions = "View sessions"; + private readonly static string updateSession = "Update session"; + private readonly static string deleteSession = "Delete session"; + private readonly static string exit = "Exit"; + + private readonly static Dictionary dictionary = new() + { + { startSession, UserAction.StartSession }, + { addSession, UserAction.CreateSession }, + { viewSessions, UserAction.ReadSessions }, + { updateSession, UserAction.UpdateSession }, + { deleteSession, UserAction.DeleteSession }, + { exit, UserAction.Exit }, + }; + + public static UserAction GetUserAction() + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("What [blue]action[/] would you like?") + .AddChoices( + startSession, + addSession, + viewSessions, + updateSession, + deleteSession, + exit) + ); + return dictionary[choice]; + } + + public static void Execute(UserAction action, CodeSessionService service) + { + Command command = default; + if (action == UserAction.CreateSession) + command = new CreateSessionCommand(service); + + else if (action == UserAction.StartSession) + command = new StartSessionCommand(service); + + else if (action == UserAction.ReadSessions) + command = new ViewDataCommand(service); + + else if (action == UserAction.UpdateSession) + command = new UpdateSessionCommand(service); + + else if (action == UserAction.DeleteSession) + command = new DeleteSessionCommand(service); + else if (action == UserAction.Exit) + return; + else throw new ArgumentException("Invalid command"); + command.Execute(); + } + } +} diff --git a/CodeSession.cs b/CodeSession.cs new file mode 100644 index 00000000..46ae772d --- /dev/null +++ b/CodeSession.cs @@ -0,0 +1,15 @@ +namespace Coding.Tracker +{ + public class CodeSession + { + public int Id { get; set; } + public string StartTime { get; set; } + public string EndTime { get; set; } + public string Duration { get; set; } + + public override string ToString() + { + return Id.ToString()+ ":" + StartTime + " " + EndTime + " " + Duration; + } + } +} diff --git a/CodeSessionService.cs b/CodeSessionService.cs new file mode 100644 index 00000000..0d5a5ffe --- /dev/null +++ b/CodeSessionService.cs @@ -0,0 +1,71 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using System.Data; + +namespace Coding.Tracker +{ + public class CodeSessionService : ICodeSessionService + { + private readonly string table = "sessions"; + private readonly string connectionString; + public CodeSessionService(string connectionString) + { + this.connectionString = connectionString; + } + + public bool CreateDatabase() + { + return Execute(db => + { + var query = $@"CREATE TABLE IF NOT EXISTS {table} + ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + StartTime TEXT, + EndTime TEXT, + Duration TEXT + )"; + return db.Execute(query) > 0; + }); + } + + public bool Create(CodeSession session) + { + return Execute(db => + { + var query = $"INSERT INTO {table}(StartTime, EndTime, Duration) VALUES(@StartTime, @EndTime, @Duration)"; + return db.Execute(query, session) > 0; + }); + } + + public List ReadAllData() + { + using IDbConnection db = new SqliteConnection(connectionString); + var query = $"SELECT * FROM {table}"; + return db.Query(query).ToList(); + } + + public bool Update(CodeSession session) + { + return Execute(db => + { + var query = $"UPDATE {table} SET StartTime=@StartTime, EndTime=@EndTime, Duration=@Duration WHERE Id=@Id"; + return db.Execute(query, session) > 0; + }); + } + + public bool Delete(int id) + { + return Execute(db => + { + var query = $"DELETE FROM {table} WHERE Id=@id"; + return db.Execute(query, new { id }) > 0; + }); + } + + private bool Execute(Func action) + { + using IDbConnection db = new SqliteConnection(connectionString); + return action(db); + } + } +} diff --git a/CodeTracker_should.cs b/CodeTracker_should.cs new file mode 100644 index 00000000..f0ea9dd4 --- /dev/null +++ b/CodeTracker_should.cs @@ -0,0 +1,152 @@ +using Microsoft.Data.Sqlite; +using NUnit.Framework; + +namespace Coding.Tracker +{ + [TestFixture] + public class CodeTracker_should + { + private const string DatabaseFile = "coding_tracker_tests.db"; + private CodeSessionService database = null!; + + [Test] + public void CreateDatabaseTest() + { + var session = new CodeSession + { + StartTime = "2026-09-21 10:00", + EndTime = "2026-09-21 11:00", + Duration = "01:00:00" + }; + + database.Create(session); + database.CreateDatabase(); + + Assert.That(database.ReadAllData().Count, Is.EqualTo(1)); + } + + [TestCase("2026-09-21 10:00", "2026-09-21 11:00", "01:00:00")] + public void CreateRecordTest(string startTime, string endTime, string duration) + { + var session = new CodeSession + { + StartTime = startTime, + EndTime = endTime, + Duration = duration + }; + + var result = database.Create(session); + var added = database.ReadAllData().Single(); + + Assert.That(result, Is.True); + Assert.That(added.Id, Is.GreaterThan(0)); + Assert.That(added.StartTime, Is.EqualTo(session.StartTime)); + Assert.That(added.EndTime, Is.EqualTo(session.EndTime)); + Assert.That(added.Duration, Is.EqualTo(session.Duration)); + } + + [Test] + public void ReadRecordsTest() + { + database.Create(new CodeSession + { + StartTime = "2026-09-21 10:00", + EndTime = "2026-09-21 11:00", + Duration = "01:00:00" + }); + + database.Create(new CodeSession + { + StartTime = "2026-09-22 10:00", + EndTime = "2026-09-22 12:00", + Duration = "02:00:00" + }); + + var records = database.ReadAllData(); + + Assert.That(records.Count, Is.EqualTo(2)); + Assert.That( + records.Select(r => r.Duration), + Is.EquivalentTo(new[] { "01:00:00", "02:00:00" })); + } + + [Test] + public void DeleteRecordTest() + { + database.Create(new CodeSession + { + StartTime = "2026-09-21 10:00", + EndTime = "2026-09-21 11:00", + Duration = "01:00:00" + }); + + database.Create(new CodeSession + { + StartTime = "2026-09-22 10:00", + EndTime = "2026-09-22 12:00", + Duration = "02:00:00" + }); + + var records = database.ReadAllData(); + var toDelete = records.Single(r => r.Duration == "01:00:00"); + var toKeep = records.Single(r => r.Duration == "02:00:00"); + + var result = database.Delete(toDelete.Id); + var remaining = database.ReadAllData().Single(); + + Assert.That(result, Is.True); + Assert.That(remaining.Id, Is.EqualTo(toKeep.Id)); + Assert.That(remaining.Duration, Is.EqualTo(toKeep.Duration)); + } + + [Test] + public void UpdateRecordTest() + { + database.Create(new CodeSession + { + StartTime = "2026-09-21 10:00", + EndTime = "2026-09-21 11:00", + Duration = "01:00:00" + }); + + var original = database.ReadAllData().Single(); + + var updatedSession = new CodeSession + { + Id = original.Id, + StartTime = "2026-09-22 12:00", + EndTime = "2026-09-22 14:30", + Duration = "02:30:00" + }; + + var result = database.Update(updatedSession); + var updated = database.ReadAllData().Single(); + + Assert.That(result, Is.True); + Assert.That(updated.Id, Is.EqualTo(original.Id)); + Assert.That(updated.StartTime, Is.EqualTo(updatedSession.StartTime)); + Assert.That(updated.EndTime, Is.EqualTo(updatedSession.EndTime)); + Assert.That(updated.Duration, Is.EqualTo(updatedSession.Duration)); + } + + [SetUp] + public void Setup() + { + DeleteDatabaseFile(); + database = new CodeSessionService($"Data Source={DatabaseFile}"); + database.CreateDatabase(); + } + + [TearDown] + public void TearDown() + { + DeleteDatabaseFile(); + } + + private static void DeleteDatabaseFile() + { + SqliteConnection.ClearAllPools(); + File.Delete(DatabaseFile); + } + } +} \ No newline at end of file diff --git a/Coding.Tracker.csproj b/Coding.Tracker.csproj new file mode 100644 index 00000000..224fa8ef --- /dev/null +++ b/Coding.Tracker.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + Coding.Tracker.Program + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/Commands/Command.cs b/Commands/Command.cs new file mode 100644 index 00000000..5d062f4c --- /dev/null +++ b/Commands/Command.cs @@ -0,0 +1,39 @@ +using System.Globalization; + +namespace Coding.Tracker.Commands +{ + public abstract class Command + { + protected CodeSessionService service; + protected Command(CodeSessionService service) + { + this.service = service; + } + public abstract void Execute(); + + protected static DateTime ValidateDateTime(string input) + { + var valid = DateTime.TryParseExact( + input, + "dd-MM-yy HH:mm:ss", + new CultureInfo("en-US"), + DateTimeStyles.None, out DateTime result); + + if (valid) + { + return result; + } + else throw new FormatException($"Wrong format of value: {input}! Should be dd-MM-yy HH:mm:ss"); + } + + protected static int ValidateNumeric(string input) + { + var valid = int.TryParse(input, out int result); + if (valid) + { + return result; + } + else throw new FormatException($"Wrong format of value: {input}! Should be numeric"); + } + } +} diff --git a/Commands/CreateSessionCommand.cs b/Commands/CreateSessionCommand.cs new file mode 100644 index 00000000..3cfe5b59 --- /dev/null +++ b/Commands/CreateSessionCommand.cs @@ -0,0 +1,36 @@ +using Spectre.Console; + +namespace Coding.Tracker.Commands +{ + public class CreateSessionCommand : Command + { + public CreateSessionCommand(CodeSessionService service) : base(service) + { + } + + public override void Execute() + { + var startTime = ValidateDateTime(AnsiConsole.Ask("Enter starttime of session in dd-MM-yy HH:mm:ss format")); + var endTime = ValidateDateTime(AnsiConsole.Ask("Enter endTime of session in dd-MM-yy HH:mm:ss format")); + + if (endTime <= startTime) + { + throw new ArgumentException($"StartTime {startTime} cannot be greater then EndTime {endTime}"); + } + + var session = new CodeSession + { + StartTime = startTime.ToString("dd-MM-yy HH:mm:ss"), + EndTime = endTime.ToString("dd-MM-yy HH:mm:ss"), + Duration = TimeOnly.FromTimeSpan(endTime - startTime).ToString("HH:mm:ss") + }; + + if (!service.Create(session)) + { + throw new ArgumentException("Error occures when adding to database"); + } + SpectreConsoleUI.PrintMessage("Session was succesfully created. Press any key to return to Menu", "green"); + Console.ReadKey(); + } + } +} diff --git a/Commands/DeleteSessionCommand.cs b/Commands/DeleteSessionCommand.cs new file mode 100644 index 00000000..66fee68f --- /dev/null +++ b/Commands/DeleteSessionCommand.cs @@ -0,0 +1,44 @@ +using Spectre.Console; + +namespace Coding.Tracker.Commands +{ + public class DeleteSessionCommand : Command + { + public DeleteSessionCommand(CodeSessionService service) : base(service) + { + } + + public override void Execute() + { + var data = service.ReadAllData(); + if (data.Count == 0) + { + SpectreConsoleUI.PrintMessage("No sessions to delete", "yellow"); + Console.ReadKey(true); + return; + } + var selected = SpectreConsoleUI.PrintChoices(data); + if (selected.Count == 0) + { + SpectreConsoleUI.PrintMessage("Deletion cancelled", "yellow"); + Console.ReadKey(true); + return; + } + var deletedCount = 0; + + foreach (var id in selected) + { + if (service.Delete(id)) + { + deletedCount++; + } + else + { + SpectreConsoleUI.PrintMessage($"Session with id {id} was not deleted", "yellow"); + } + } + SpectreConsoleUI.PrintMessage("Session was succesfully deleted. Press any key to return to Menu", "green"); + Console.ReadKey(true); + } + } +} diff --git a/Commands/StartSessionCommand.cs b/Commands/StartSessionCommand.cs new file mode 100644 index 00000000..78d462aa --- /dev/null +++ b/Commands/StartSessionCommand.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; + +namespace Coding.Tracker.Commands +{ + public class StartSessionCommand : Command + { + public StartSessionCommand(CodeSessionService service) : base(service) + { + } + + public override void Execute() + { + SpectreConsoleUI.PrintMessage("To start session press any key..."); + Console.ReadKey(); + + var startTime = DateTime.Now; + SpectreConsoleUI.Clear(); + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + var lastSecond = 0; + while (!Console.KeyAvailable) + { + var currentSecond = (int)stopwatch.Elapsed.TotalSeconds; + if (currentSecond != lastSecond) + { + SpectreConsoleUI.Clear(); + lastSecond = currentSecond; + var elapsed = stopwatch.Elapsed; + SpectreConsoleUI.PrintMessage($"Elapsed: {(int)elapsed.TotalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}. Press any key to stop"); + } + } + Console.ReadKey(true); + SpectreConsoleUI.PrintMessage(string.Empty); + stopwatch.Stop(); + + var endTime = DateTime.Now; + var codeSession = new CodeSession + { + StartTime = startTime.ToString("dd-MM-yy HH:mm:ss"), + EndTime = endTime.ToString("dd-MM-yy HH:mm:ss"), + Duration = TimeOnly.FromTimeSpan(endTime - startTime).ToString("HH:mm:ss") + }; + + if (!service.Create(codeSession)) + { + throw new ArgumentException("Error occures when adding to database"); + } + SpectreConsoleUI.PrintMessage("Session was succesfully created. Press any key to return to Menu", "green"); + Console.ReadKey(); + } + } +} diff --git a/Commands/UpdateSessionCommand.cs b/Commands/UpdateSessionCommand.cs new file mode 100644 index 00000000..f5441ee6 --- /dev/null +++ b/Commands/UpdateSessionCommand.cs @@ -0,0 +1,54 @@ +using Spectre.Console; + +namespace Coding.Tracker.Commands +{ + public class UpdateSessionCommand : Command + { + public UpdateSessionCommand(CodeSessionService service) : base(service) + { + } + + public override void Execute() + { + var data = service.ReadAllData(); + if (data.Count == 0) + { + SpectreConsoleUI.PrintMessage("No sessions to delete", "yellow"); + Console.ReadKey(true); + return; + } + var reader = new ViewDataCommand(service); + reader.ExecuteWithoutExit(); + var id = ValidateNumeric(AnsiConsole.Ask("Enter numeric [blue]id[/] of session for update:")); + + var session = service.ReadAllData().FirstOrDefault(s => s.Id == id); + if (session == null) + { + SpectreConsoleUI.PrintMessage($"Session with {id} not found", "yellow"); + Console.ReadKey(); + } + else + { + var startTime = ValidateDateTime(AnsiConsole.Ask("Enter start time of session in dd-MM-yy HH:mm:ss format:")); + var endTime = ValidateDateTime(AnsiConsole.Ask("Enter end time of session in dd-MM-yy HH:mm:ss format:")); + + if (endTime <= startTime) + { + throw new ArgumentException($"StartTime {startTime} cannot be greater then EndTime {endTime}"); + } + + session.StartTime = startTime.ToString("dd-MM-yy HH:mm:ss"); + session.EndTime = endTime.ToString("dd-MM-yy HH:mm:ss"); + session.Duration = TimeOnly.FromTimeSpan(endTime - startTime).ToString("HH:mm:ss"); + + if (!service.Update(session)) + { + throw new ArgumentException("Error occures when updating of database"); + } + + SpectreConsoleUI.PrintMessage("Session was succesfully updated. Press any key to return to Menu", "green"); + Console.ReadKey(); + } + } + } +} diff --git a/Commands/ViewDataCommand.cs b/Commands/ViewDataCommand.cs new file mode 100644 index 00000000..8d509976 --- /dev/null +++ b/Commands/ViewDataCommand.cs @@ -0,0 +1,62 @@ +using Spectre.Console; + +namespace Coding.Tracker.Commands +{ + public class ViewDataCommand : Command + { + public ViewDataCommand(CodeSessionService service) : base(service) + { + } + + public override void Execute() + { + var list = service.ReadAllData(); + if (list.Count == 0) + { + SpectreConsoleUI.PrintMessage("No sessions to delete", "yellow"); + Console.ReadKey(true); + return; + } + SpectreConsoleUI.PrintTable(list); + + + while (true) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("What [blue]action[/] would you like?") + .AddChoices( + "Exit", + "Order duration descending", + "Order duration ascending")); + + if (choice == "Exit") + break; + else if (choice == "Order duration descending") + OrderByDesc(); + else if (choice == "Order duration ascending") + OrderByAsc(); + } + } + + public void ExecuteWithoutExit() + { + var list = service.ReadAllData(); + SpectreConsoleUI.PrintTable(list); + } + + public void OrderByDesc() + { + SpectreConsoleUI.Clear(); + var ordered = service.ReadAllData().OrderByDescending(c => c.Duration).ToList(); + SpectreConsoleUI.PrintTable(ordered); + } + + public void OrderByAsc() + { + SpectreConsoleUI.Clear(); + var ordered = service.ReadAllData().OrderBy(c => c.Duration).ToList(); + SpectreConsoleUI.PrintTable(ordered); + } + } +} diff --git a/ICodeSessionService.cs b/ICodeSessionService.cs new file mode 100644 index 00000000..07192fa7 --- /dev/null +++ b/ICodeSessionService.cs @@ -0,0 +1,11 @@ +namespace Coding.Tracker +{ + public interface ICodeSessionService + { + bool CreateDatabase(); + bool Create(CodeSession session); + List ReadAllData(); + bool Update(CodeSession session); + bool Delete(int id); + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 00000000..9af76ffa --- /dev/null +++ b/Program.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Configuration; + +namespace Coding.Tracker +{ + public static class Program + { + private static CodeSessionService service; + public static void Main(string[] args) + { + if (!Configurate()) return; + service.CreateDatabase(); + while (true) + { + try + { + SpectreConsoleUI.Clear(); + var choice = ActionExecuter.GetUserAction(); + if (choice == UserAction.Exit) + break; + ActionExecuter.Execute(choice, service); + } + catch (Exception ex) + { + SpectreConsoleUI.PrintMessage($"{ex.Message}", "red"); + Console.ReadKey(); + } + } + } + + private static bool Configurate() + { + try + { + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + + var connectionString = configuration.GetConnectionString("DefaultConnection"); + service = new CodeSessionService(connectionString); + return true; + } + catch + { + SpectreConsoleUI.PrintMessage("Unable to load configuration"); + return false; + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..c305193a --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Coding Tracker + +Console based CRUD application to track coding sessions. +Developed using C#, SQLite, Dapper and Spectre.Console. + +# Features + +- Create, view, update and delete coding sessions. +- Enter start and end time manually or use a stopwatch. +- Calculate session duration. +- Sort sessions by duration. +- Select multiple sessions to delete. +- Store records in a SQLite database. + +# Challenges + +- Working with Dapper and SQL queries. +- Creating interactive menus with Spectre.Console. +- Separating commands, database logic and UI. + +# How to Use + +Run the application using `dotnet run` from the project folder. +Use arrow keys to navigate the menu and Enter to select an action. + +- **Start new session** — press any key to start the stopwatch, then press any key to stop and save the session. +- **Add new session** — enter start and end time in `dd-MM-yy HH:mm:ss` format, for example `25-09-26 14:30:00`. Duration is calculated automatically. +- **View sessions** — view all records and sort them by duration in ascending or descending order. +- **Update session** — enter the session Id, then enter new start and end time. +- **Delete session** — select sessions with Space and press Enter to delete them. Press Enter without selecting anything to cancel. +- **Exit** — close the application. + +Records are saved in SQLite and remain available after restarting the application. + +# Areas to Improve + +- Add filtering by day, week and year. +- Improve input validation. +- Add more tests. \ No newline at end of file diff --git a/SpectreConsoleUI.cs b/SpectreConsoleUI.cs new file mode 100644 index 00000000..d7a82e67 --- /dev/null +++ b/SpectreConsoleUI.cs @@ -0,0 +1,61 @@ +using Spectre.Console; + +namespace Coding.Tracker +{ + public static class SpectreConsoleUI + { + public static void PrintTable(List sessions) + { + var table = new Table() + .AddColumn("Id") + .AddColumn("Start time") + .AddColumn("End time") + .AddColumn("Duration"); + + foreach(var session in sessions) + { + table.AddRow( + $"{session.Id}", + $"{session.StartTime}", + $"{session.EndTime}", + $"{session.Duration}"); + } + + AnsiConsole.Write(table); + } + + public static void PrintMessage(string message, string color) + { + AnsiConsole.MarkupLine($"[{color}] {message}[/]"); + } + + public static void PrintMessage(string message) + { + PrintMessage(message, "white"); + } + + public static void Clear() + { + AnsiConsole.Clear(); + } + + public static List PrintChoices(List sessions) + { + var multiPrompt = new MultiSelectionPrompt() + .Title("Choose session") + .NotRequired() + .InstructionsText("[grey](Press [blue][/] to toggle, [green][/] to confirm)[/]"); + + foreach (var session in sessions) + { + multiPrompt.AddChoice(session.ToString()); + } + + var choices = AnsiConsole.Prompt(multiPrompt); + return sessions + .Where(s => choices.Contains(s.ToString())) + .Select(s => s.Id) + .ToList(); + } + } +} diff --git a/UserAction.cs b/UserAction.cs new file mode 100644 index 00000000..91c63f4f --- /dev/null +++ b/UserAction.cs @@ -0,0 +1,13 @@ +namespace Coding.Tracker +{ + public enum UserAction + { + StartSession, + CreateSession, + ReadSessions, + UpdateSession, + DeleteSession, + Exit, + + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 00000000..6df9b568 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=coding_tracker.db" + } +} \ No newline at end of file