diff --git a/.codacy.yml b/.codacy.yml deleted file mode 100644 index 44c0e92eb..000000000 --- a/.codacy.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -exclude_paths: - - "README.md" - - "**/README.md" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 49976e115..5c1f7dab3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ *.userosscache *.sln.docstates + +# Vs Code debug config files +launch.json +tasks.json + # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -399,7 +404,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -.idea/ ## ## Visual studio for Mac @@ -476,5 +480,3 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -/MathGame2 -/CodingTracker.TomDonegan/TextFile1.txt diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..24e29b1b1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/bin/Debug/net10.0/CodingTracker.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "console": "externalTerminal" + } + + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..28f9a1994 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "dotnet", + "task": "build", + "group": "build", + "problemMatcher": [], + "label": "build" + } + ] +} \ No newline at end of file diff --git a/CodingTracker/.vscode/launch.json b/CodingTracker/.vscode/launch.json new file mode 100644 index 000000000..14a0adc9a --- /dev/null +++ b/CodingTracker/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "dotnet: build", + "program": "${workspaceFolder}/bin/Debug/net10.0/CodingTracker.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "console": "externalTerminal" + } + + ] +} \ No newline at end of file diff --git a/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker.csproj new file mode 100644 index 000000000..5d8e1568a --- /dev/null +++ b/CodingTracker/CodingTracker.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/CodingTracker/Data/Database.cs b/CodingTracker/Data/Database.cs new file mode 100644 index 000000000..3e510621e --- /dev/null +++ b/CodingTracker/Data/Database.cs @@ -0,0 +1,106 @@ +using CodingSession; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace CodingTracker; + +internal class Database +{ + private static string ConnectionString; + + internal static void Configure(string connectionString) + { + ConnectionString = connectionString; + } + + internal static T Execute(Func action) + { + try + { + using var connection = new SqliteConnection(ConnectionString); + return action(connection); + } + catch (SqliteException ex) + { + throw new InvalidOperationException("Database access failed.", ex); + } + } + + internal static void CreateDB() + { + bool tableExisted; + + using (var connection = new SqliteConnection(ConnectionString)) + { + const string existsSql = + $@"SELECT EXISTS (SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = 'coding_tracker')"; + + tableExisted = connection.ExecuteScalar(existsSql); + + string createSql = + $@"CREATE TABLE IF NOT EXISTS coding_tracker( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity TEXT, + startTime TEXT, + endTime TEXT, + duration INTEGER)"; + + connection.Execute(createSql); + } + + if (!tableExisted) + DebugTool.SeedData(); + } + + internal static void Add(string activity, string startTime, string endTime, int duration) + { + string sql = @$"INSERT INTO coding_tracker + (activity, startTime, endTime, duration) + VALUES (@activity, @startTime, @endTime, @duration)"; + + Execute(connection => connection.Execute(sql, new + { + activity = activity, + startTime = startTime, + endTime = endTime, + duration = duration + })); + } + + internal static void ConvertAndAdd(string activity, DateTime startTime, DateTime endTime, TimeSpan duration) + { + string startTimeDB = TimeFormatter.ConvertDateForDb(startTime); + string endTimeDB = TimeFormatter.ConvertDateForDb(endTime); + int durationDB = TimeFormatter.MinutesToInt(duration); + + Add(activity, startTimeDB, endTimeDB, durationDB); + } + + internal static void Delete(CodingSession entry) + { + string sql = @$"DELETE FROM coding_tracker WHERE id = @id"; + Execute(connection => connection.Execute(sql, new { id = entry.Id })); + } + + internal static void UpdateRow(CodingSession entry) + { + string sql = @$"UPDATE coding_tracker + SET activity = @activity, + startTime = @startTime, + endTime = @endTime, + duration = @duration + WHERE id = @id"; + + Execute(connection => connection.Execute(sql, new + { + activity = entry.Activity, + startTime = entry.StartTime, + endTime = entry.EndTime, + duration = entry.Duration, + id = entry.Id + })); + } +} diff --git a/CodingTracker/Data/Query.cs b/CodingTracker/Data/Query.cs new file mode 100644 index 000000000..97f3436af --- /dev/null +++ b/CodingTracker/Data/Query.cs @@ -0,0 +1,48 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using static CodingTracker.Database; + +namespace CodingTracker; + +internal class Query +{ + internal static List ViewAll() + { + string sql = @$"SELECT * FROM coding_tracker ORDER BY startTime DESC"; + return Execute(connection => connection.Query(sql).ToList()); + } + + internal static int CountRows() + { + string sql = @$"SELECT COUNT(*) FROM coding_tracker"; + return Execute(connection => Convert.ToInt32(connection.ExecuteScalar(sql))); + } + + internal static int RowFromID(int id) + { + string sql = @$"SELECT * FROM coding_tracker WHERE id = @id"; + return Execute(connection => Convert.ToInt32(connection.ExecuteScalar(sql, new { id }))); + } + + internal static List FilterByActivity(string filter) + { + string sql = @$"SELECT * FROM coding_tracker WHERE activity LIKE @filter ORDER BY startTime DESC"; + return Execute(connection => connection.Query(sql, new { filter = $"%{filter}%" }).ToList()); + } + + internal static List FilterByDate(string startDate, string endDate) + { + string sql = @$"SELECT * FROM coding_tracker + WHERE startTime BETWEEN @startDate AND @endDate + ORDER BY startTime DESC"; + return Execute(connection => connection.Query(sql, new { startDate = startDate, endDate = endDate }).ToList()); + } + + internal static string LastActivity() + { + string sql = @$"SELECT activity FROM coding_tracker + ORDER BY id DESC + LIMIT 1"; + return Execute(connection => Convert.ToString(connection.ExecuteScalar(sql))); + } +} \ No newline at end of file diff --git a/CodingTracker/Enums/EnumExtensions.cs b/CodingTracker/Enums/EnumExtensions.cs new file mode 100644 index 000000000..1109b9306 --- /dev/null +++ b/CodingTracker/Enums/EnumExtensions.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using System.ComponentModel.DataAnnotations; +using static CodingTracker.Enums; + +namespace CodingTracker; + +public static class EnumExtensions +{ + public static string GetDisplayName(this Enum value) + { + var member = value.GetType().GetMember(value.ToString())[0]; + + return member + .GetCustomAttribute()? + .GetName() + ?? value.ToString(); + } + + internal static List GetEditableColumn() + { + List choices = new(); + + foreach (string columnName in Enum.GetNames()) + { + if (columnName != Column.id.ToString() && columnName != Column.duration.ToString()) + choices.Add(((Column)Enum.Parse(typeof(Column), columnName)).GetDisplayName()); + } + return choices; + } +} \ No newline at end of file diff --git a/CodingTracker/Enums/Enums.cs b/CodingTracker/Enums/Enums.cs new file mode 100644 index 000000000..f562dfb30 --- /dev/null +++ b/CodingTracker/Enums/Enums.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; + +namespace CodingTracker; + +internal class Enums +{ + internal enum Column + { + [Display(Name = "ID")] + id, + + [Display(Name = "Activity")] + activity, + + [Display(Name = "Start time")] + startTime, + + [Display(Name = "End time")] + endTime, + + [Display(Name = "Duration")] + duration + } + + internal enum MenuChoice + { + [Display(Name = "View entries")] + viewItems, + + [Display(Name = "[green]Start[/] a session [green]now[/]")] + addItemStartNow, + + [Display(Name = "[green]Add[/] a previous session")] + addItem, + + [Display(Name = "[blue]Edit[/] an entry")] + editItem, + + [Display(Name = "[red]Delete[/] an entry")] + deleteItem, + + [Display(Name = "Exit")] + exit, + } +} \ No newline at end of file diff --git a/CodingTracker/LiveSession.cs b/CodingTracker/LiveSession.cs new file mode 100644 index 000000000..a7fb0fb8e --- /dev/null +++ b/CodingTracker/LiveSession.cs @@ -0,0 +1,110 @@ +using CodingSession; +using Spectre.Console; +using System.Diagnostics; +using static CodingTracker.TimeFormatter; + +namespace CodingTracker; + +internal class LiveSession +{ + + internal static void Launch() + { + string action = "Add"; + string colorHighlight = "green"; + + DateTime startDate = DateTime.Now; + Stopwatch stopwatch = Stopwatch.StartNew(); + + ConsoleKeyInfo input = new(); + + while (input.Key != ConsoleKey.Enter) + { + SessionActiveStatus(startDate, stopwatch); + LiveTimer(stopwatch); + + if (Console.KeyAvailable) + { + input = Console.ReadKey(); + + if (stopwatch.IsRunning && input.Key == ConsoleKey.Spacebar) + stopwatch.Stop(); + + else if (!stopwatch.IsRunning && input.Key == ConsoleKey.Spacebar) + stopwatch.Start(); + } + } + + DateTime endDate = DateTime.Now; + SessionOverStatus(startDate, stopwatch); + + string activity = ChoicesPrompt.AskForActivity(action, colorHighlight); + + //add autoFill feature, query the DB for Last activity, catch empty DB + + Database.ConvertAndAdd(activity, startDate, endDate, stopwatch.Elapsed); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[{colorHighlight}]The session has been added to the DB.[/]"); + AnsiConsole.WriteLine(); + } + + private static void SessionActiveStatus(DateTime date, Stopwatch stopwatch) + { + string colorHighlight; + string state; + string action; + + if (stopwatch.IsRunning) + { + colorHighlight = "orange1"; + state = "active"; + action = "pause"; + } + + else + { + colorHighlight = "blue"; + state = "paused"; + action = "resume"; + } + + Console.Clear(); + Displayer.PrintTitle(); + + AnsiConsole.MarkupLine($"Coding session [{colorHighlight}]{state}.[/]"); + AnsiConsole.MarkupLine($"[Grey]Toggle 'Spacebar' to {action} session, 'Enter' to terminate.[/]"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(DisplayDate(date)); + } + + private static void SessionOverStatus(DateTime date, Stopwatch stopwatch) + { + Console.Clear(); + Displayer.PrintTitle(); + + AnsiConsole.MarkupLine($"Coding session [green]done.[/]"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(DisplayDate(date)); + AnsiConsole.MarkupLine(DisplayDuration(stopwatch)); + } + + private static void LiveTimer(Stopwatch stopwatch) + { + int clockRow = Console.CursorTop; + + bool cursorVisible = Console.CursorVisible; + Console.CursorVisible = false; + + while (!Console.KeyAvailable) + { + Console.SetCursorPosition(0, clockRow); + Console.Write(DisplayDuration(stopwatch)); + + Thread.Sleep(100); + } + AnsiConsole.WriteLine("\n"); + + Console.CursorVisible = cursorVisible; + } +} \ No newline at end of file diff --git a/CodingTracker/Model/CodingSession.cs b/CodingTracker/Model/CodingSession.cs new file mode 100644 index 000000000..1e98f98b3 --- /dev/null +++ b/CodingTracker/Model/CodingSession.cs @@ -0,0 +1,50 @@ +namespace CodingTracker; + +class CodingSession +{ + internal int Id { get; private set; } + internal string Activity { get; private set; } + internal string StartTime { get; private set; } + internal string EndTime { get; private set; } + internal int Duration { get; private set; } + + internal CodingSession(Int64 id, string activity, string startTime, string endTime, Int64 duration) + { + Id = (int)id; + Activity = activity; + StartTime = startTime; + EndTime = endTime; + Duration = (int)duration; + } + + internal void EditStartTime(string startTime) + { + StartTime = startTime; + } + + internal void EditEndTime(string endTime) + { + EndTime = endTime; + } + + internal void EditDuration(int duration) + { + Duration = duration; + } + + internal void EditActivity(string activity) + { + Activity = activity; + } + + internal string DisplayData() + { + string activity = Activity; + string startTime = TimeFormatter.ConvertDateOutDb(StartTime); + string endTime = TimeFormatter.ConvertHourOutDb(EndTime); + string duration = TimeFormatter.IntToHHmm(Duration); + + string result = activity.PadRight(20, ' ') + startTime.PadRight(25, ' ') + endTime.PadRight(10, ' ') + duration; + return Spectre.Console.Markup.Escape(result); + } +} \ No newline at end of file diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs new file mode 100644 index 000000000..00105c2c3 --- /dev/null +++ b/CodingTracker/Program.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Configuration; + +namespace CodingTracker; + +class Program +{ + static void Main(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + + var connectionString = configuration.GetConnectionString("CodingTracker") + ?? throw new InvalidOperationException( + "Connection string 'CodingTracker' is missing."); + + Database.Configure(connectionString); + Database.CreateDB(); + + Displayer.PrintTitle(); + UserInterface.MainMenu(); + } +} diff --git a/CodingTracker/Test/DebugTool.cs b/CodingTracker/Test/DebugTool.cs new file mode 100644 index 000000000..39b56dddb --- /dev/null +++ b/CodingTracker/Test/DebugTool.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; +using System.Reflection.Metadata; +using CodingTracker; + +namespace CodingSession; + +internal class DebugTool +{ + internal static void SeedData() + { + string[] activities = ["Coding Tracker", "Habit Logger", "Website"]; + DateTime startDate = RandomDateTime(); + Random rand = new(); + + for (int i = 0; i < 30; i++) + { + int randDay = rand.Next(0, 4); + int randMinutes = rand.Next(0, 580); + startDate = startDate.AddDays(randDay).AddMinutes(randMinutes); + + string activity = activities[rand.Next(0, activities.Length)]; + + TimeSpan duration = TimeSpan.FromMinutes(rand.Next(5, 440)); + DateTime endDate = startDate.AddMinutes((int)duration.TotalMinutes); + + Database.ConvertAndAdd(activity, startDate, endDate, duration); + } + } + + private static DateTime RandomDateTime() + { + Random rand = new(); + + DateTime startDay = new DateTime(2026, 8, 1); + int maxRange = (DateTime.Today - startDay).Days + 1; + DateTime date = startDay.AddDays(rand.Next(0, maxRange)); + + return date; + } +} \ No newline at end of file diff --git a/CodingTracker/UI/ChoicesPrompt.cs b/CodingTracker/UI/ChoicesPrompt.cs new file mode 100644 index 000000000..989a41585 --- /dev/null +++ b/CodingTracker/UI/ChoicesPrompt.cs @@ -0,0 +1,97 @@ +using System.Runtime.CompilerServices; +using Spectre.Console; +using static CodingTracker.TimeFormatter; + +namespace CodingTracker; + +internal class ChoicesPrompt +{ + internal static string AskForActivity(string action, string colorHighlight) + { + AnsiConsole.MarkupLine("[grey]Press 'Enter' to autofill with the last activity (highest id registered).[/]"); + var prompt = new TextPrompt($"[{colorHighlight}]{action}[/] activity:").AllowEmpty(); + string result = AnsiConsole.Prompt(prompt); + + if (result == "") + return Query.LastActivity(); + else + return result; + } + + public static List AskForFilter(string action, string colorHighlight) + { + List tableData = new(); + string[] choices = [ + $"{action} from all", + $"{action} from specific {Enums.Column.activity.GetDisplayName()}", + $"{action} from specific Time Range"]; + + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"What do you want to [bold {colorHighlight}]{action}[/]:") + .HighlightStyle(colorHighlight) + .PageSize(100) + .Mode(SelectionMode.Leaf) + .AddChoices(choices)); + + if (selection == choices[0]) + { + tableData = Query.ViewAll(); + } + else if (selection == choices[1]) + { + AnsiConsole.MarkupLine($"[{colorHighlight}]{action}[/] - search specific activity"); + string activity = AskForActivity(action, colorHighlight); + tableData = Query.FilterByActivity(activity); + } + else if (selection == choices[2]) + { + AnsiConsole.MarkupLine($"[{colorHighlight}]{action}[/] - search specific date"); + string startDate = ConvertDateForDb(GetTimeFromUser(DaTeStyleShort, "From", "00:00")); + string endDate = ConvertDateForDb(GetTimeFromUser(DaTeStyleShort, "To", "23:59")); + tableData = Query.FilterByDate(startDate, endDate); + } + + return tableData; + } + + public static CodingSession SelectEntry(List tableData, string action, string colorHighlight) + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[BOLD {colorHighlight}]Select[/] the entry you want to [BOLD {colorHighlight}]{action}[/]:") + .HighlightStyle(colorHighlight) + .UseConverter(entry => entry.DisplayData()) + .PageSize(100) + .AddChoices(tableData)); + + return selection; + } + + public static List SelectMultiString(string action, string colorHighlight, string[] choices) + { + var selection = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title($"What do you want to [BOLD {colorHighlight}]{action}[/]") + .HighlightStyle(colorHighlight) + .InstructionsText($"[grey](Press [{colorHighlight}][/] to toggle, [green][/] to confirm)[/]") + .PageSize(100) + .AddChoices(choices)); + + return selection; + } + + public static List SelectMultiSessions(string action, string colorHighlight, List tableData) + { + var selection = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title($"[BOLD {colorHighlight}]Select[/] the entry you want to [BOLD {colorHighlight}]{action}[/]:") + .HighlightStyle(colorHighlight) + .InstructionsText($"[grey](Press [{colorHighlight}][/] to toggle, [green][/] to confirm)[/]") + .UseConverter(entry => entry.DisplayData()) + .PageSize(100) + .AddChoices(tableData)); + + return selection; + } +} \ No newline at end of file diff --git a/CodingTracker/UI/Displayer.cs b/CodingTracker/UI/Displayer.cs new file mode 100644 index 000000000..f4b8c4a2a --- /dev/null +++ b/CodingTracker/UI/Displayer.cs @@ -0,0 +1,104 @@ +using Spectre.Console; +using static CodingTracker.Enums; +using static CodingTracker.TimeFormatter; +using System.Linq; + +namespace CodingTracker; + +internal class Displayer +{ + internal static void PreviewRow(string activity, string startTime, string endTime, int duration) + { + var table = new Table() + .RoundedBorder(); + + table.AddColumn(Markup.Escape(Enums.Column.activity.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.startTime.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.endTime.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.duration.GetDisplayName())); + + table.AddRow( + Markup.Escape(activity), + Markup.Escape(ConvertDateOutDb(startTime)), + Markup.Escape(ConvertHourOutDb(endTime)), + Markup.Escape(IntToHHmm(duration))); + + AnsiConsole.Write(table); + } + + internal static void PreviewRow(CodingSession session) + { + var table = new Table() + .RoundedBorder(); + + + table.AddColumn(Markup.Escape(Enums.Column.activity.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.startTime.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.endTime.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.duration.GetDisplayName())); + + table.AddRow( + Markup.Escape(session.Activity), + Markup.Escape(ConvertDateOutDb(session.StartTime)), + Markup.Escape(ConvertHourOutDb(session.EndTime)), + Markup.Escape(IntToHHmm(session.Duration))); + + AnsiConsole.Write(table); + } + + internal static void PreviewRows(List sessions) + { + var table = new Table() + .RoundedBorder(); + + table.AddColumn(Markup.Escape(Enums.Column.activity.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.startTime.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.endTime.GetDisplayName())); + table.AddColumn(Markup.Escape(Enums.Column.duration.GetDisplayName())); + + foreach (var session in sessions) + { + table.AddRow( + Markup.Escape(session.Activity), + Markup.Escape(ConvertDateOutDb(session.StartTime)), + Markup.Escape(ConvertHourOutDb(session.EndTime)), + Markup.Escape(IntToHHmm(session.Duration))); + } + + AnsiConsole.Write(table); + } + + internal static void PrintTable(List tableData, string title) + { + var table = new Table() + .RoundedBorder() + .Title(title); + + foreach (string column in Enum.GetNames()) + table.AddColumn(column); + + foreach (var entry in tableData) + table.AddRow( + Markup.Escape(entry.Id.ToString()), + Markup.Escape(entry.Activity), + Markup.Escape(ConvertDateOutDb(entry.StartTime)), + Markup.Escape(ConvertHourOutDb(entry.EndTime)), + Markup.Escape(IntToHHmm(entry.Duration))); + + AnsiConsole.Write(table); + } + + internal static void PrintTitle() + { + Console.Clear(); + AnsiConsole.Write(new Panel(" Coding Tracker ") + .AsciiBorder()); + AnsiConsole.WriteLine(); + } + + internal static void NoEntriesFound() + { + AnsiConsole.MarkupLine($"[red]No entries have been found.[/]"); + AnsiConsole.WriteLine(); + } +} \ No newline at end of file diff --git a/CodingTracker/UI/UserInterface.cs b/CodingTracker/UI/UserInterface.cs new file mode 100644 index 000000000..89a2b0ea5 --- /dev/null +++ b/CodingTracker/UI/UserInterface.cs @@ -0,0 +1,197 @@ +using Spectre.Console; +using static CodingTracker.Enums; +using static CodingTracker.TimeFormatter; + +namespace CodingTracker; + +internal class UserInterface +{ + internal static void MainMenu() + { + bool exitApp = false; + + while (!exitApp) + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("What do you want to do?") + .HighlightStyle(Color.Grey) + .UseConverter(choice => choice.GetDisplayName()) + .AddChoices(Enum.GetValues())); + + switch (selection) + { + case MenuChoice.viewItems: + ViewAll(); + break; + + case MenuChoice.addItemStartNow: + LiveSession.Launch(); + break; + + case MenuChoice.addItem: + AddItem(); + break; + + case MenuChoice.editItem: + EditItem(); + break; + + case MenuChoice.deleteItem: + DeleteItem(); + break; + + case MenuChoice.exit: + exitApp = true; + break; + } + } + } + + internal static void AddItem() + { + string colorHighlight = "green"; + + AnsiConsole.MarkupLine($"[{colorHighlight}]Add Session[/]"); + + string startDate = GetTimeFromUser(DaTeStyleShort, "Day").ToString(DaTeStyleShort); + string startHour = GetTimeFromUser(DateStyleHour, "start hour").ToString(DateStyleHour); + string endHour = GetTimeFromUser(DateStyleHour, "end hour").ToString(DateStyleHour); + string activity = ChoicesPrompt.AskForActivity("Add", colorHighlight); + + string[] timeInfos = FormatTimeInfo(startDate, startHour, endHour); + + Database.Add(activity, timeInfos[0], timeInfos[1], Convert.ToInt32(timeInfos[2])); + + AnsiConsole.WriteLine(); + Displayer.PreviewRow(activity, timeInfos[0], timeInfos[1], Convert.ToInt32(timeInfos[2])); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[{colorHighlight}]The session has been added to the DB.[/]"); + AnsiConsole.WriteLine(); + } + + internal static void ViewAll() + { + List tableData = Query.ViewAll(); + Displayer.PrintTable(tableData, "[bold] all Sessions[/]"); + } + + internal static void EditItem() + { + Displayer.PrintTitle(); + + if (Query.CountRows() == 0) + { + Displayer.NoEntriesFound(); + return; + } + + string action = "Edit"; + string colorHighlight = "Blue"; + string[] timeInfos; + + List tableData = ChoicesPrompt.AskForFilter(action, colorHighlight); + AnsiConsole.WriteLine(); + + if (tableData.Count == 0) + { + Displayer.NoEntriesFound(); + return; + } + + CodingSession selection = ChoicesPrompt.SelectEntry(tableData, action, colorHighlight); + + AnsiConsole.MarkupLine($"[{colorHighlight}]Selection (id {selection.Id})[/]"); + Displayer.PreviewRow(selection); + AnsiConsole.WriteLine(); + + string[] choices = EnumExtensions.GetEditableColumn().ToArray(); + List toEdit = ChoicesPrompt.SelectMultiString(action, colorHighlight, choices); + + if (toEdit.Contains($"{Column.activity.GetDisplayName()}")) + selection.EditActivity(ChoicesPrompt.AskForActivity(action, colorHighlight)); + + if (toEdit.Contains($"{Column.startTime.GetDisplayName()}") && toEdit.Contains($"{Column.endTime.GetDisplayName()}")) + { + string startDate = GetTimeFromUser(DaTeStyleShort, $"[{colorHighlight}]{action}[/] start date").ToString(DaTeStyleShort); + string startHour = GetTimeFromUser(DateStyleHour, $"[{colorHighlight}]{action}[/] start hour").ToString(DateStyleHour); + string endHour = GetTimeFromUser(DateStyleHour, $"[{colorHighlight}]{action}[/] end hour").ToString(DateStyleHour); + timeInfos = FormatTimeInfo(startDate, startHour, endHour); + + selection.EditStartTime(timeInfos[0]); + selection.EditEndTime(timeInfos[1]); + selection.EditDuration(Convert.ToInt32(timeInfos[2])); + } + + else if (toEdit.Contains($"{Column.startTime.GetDisplayName()}")) + { + string startDate = GetTimeFromUser(DaTeStyleShort, $"[{colorHighlight}]{action}[/] start date").ToString(DaTeStyleShort); + string startHour = GetTimeFromUser(DateStyleHour, $"[{colorHighlight}]{action}[/] start hour").ToString(DateStyleHour); + string endHour = selection.EndTime; + timeInfos = FormatTimeInfo(startDate, startHour, endHour); + + selection.EditStartTime(timeInfos[0]); + selection.EditDuration(Convert.ToInt32(timeInfos[2])); + } + + else if (toEdit.Contains($"{Column.endTime.GetDisplayName()}")) + { + string startDate = TrimHour(selection.StartTime); + string startHour = TrimDate(selection.StartTime); + string endHour = GetTimeFromUser(DateStyleHour, $"[{colorHighlight}]{action}[/] end hour").ToString(DateStyleHour); + timeInfos = FormatTimeInfo(startDate, startHour, endHour); + + selection.EditEndTime(timeInfos[1]); + selection.EditDuration(Convert.ToInt32(timeInfos[2])); + } + + Database.UpdateRow(selection); + + AnsiConsole.WriteLine(); + Displayer.PreviewRow(selection); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[green]Entry succesfully [{colorHighlight}]edited.[/][/]"); + AnsiConsole.WriteLine(); + } + + internal static void DeleteItem() + { + Displayer.PrintTitle(); + + if (Query.CountRows() == 0) + { + Displayer.NoEntriesFound(); + return; + } + + string action = "Delete"; + string colorHighlight = "Red"; + + List tableData = ChoicesPrompt.AskForFilter(action, colorHighlight); + AnsiConsole.WriteLine(); + + if (tableData.Count == 0) + { + Displayer.NoEntriesFound(); + return; + } + + List selection = ChoicesPrompt.SelectMultiSessions(action, colorHighlight, tableData); + Displayer.PreviewRows(selection); + + if (selection.Count == 0) + AnsiConsole.MarkupLine($"[yellow]No entry has been deleted[/]"); + else + { + foreach (var entry in selection) + Database.Delete(entry); + + if (selection.Count == 1) + AnsiConsole.MarkupLine($"[green]{selection.Count} entry has been [red]deleted[/].[/]"); + + else + AnsiConsole.MarkupLine($"[green]{selection.Count} entries have been [red]deleted[/].[/]"); + } + AnsiConsole.WriteLine(); + } +} \ No newline at end of file diff --git a/CodingTracker/Utility/TimeFormater.cs b/CodingTracker/Utility/TimeFormater.cs new file mode 100644 index 000000000..da1fecf3e --- /dev/null +++ b/CodingTracker/Utility/TimeFormater.cs @@ -0,0 +1,136 @@ +using System.Diagnostics; +using Spectre.Console; +using System.Globalization; + +namespace CodingTracker; + +internal class TimeFormatter +{ + public const string DaTeStyleShort = @"yyyy/MM/dd"; + public const string DaTeStyleDB = @"yyyy/MM/dd HH:mm"; + public const string HourStyleShort = @"HH.mm"; + public const string DateStyleFull = "ddd dd MMM yy - HH:mm"; + public const string DateStyleHour = "HH:mm"; + public const string StopWatchStyle = @"hh\:mm\:ss"; + + internal static string DisplayDate(DateTime date) + { + return date.ToString(DateStyleFull); + } + + internal static string DisplayDuration(Stopwatch stopwatch) + { + return stopwatch.Elapsed.ToString(StopWatchStyle); + } + + internal static string ConvertDateForDb(DateTime date) + { + return date.ToString(DaTeStyleDB, CultureInfo.InvariantCulture); + } + + internal static string ConvertDateOutDb(string dateString) + { + return DateTime.Parse(dateString).ToString(DateStyleFull); + } + + internal static string ConvertHourOutDb(string dateString) + { + return DateTime.Parse(dateString).ToString(DateStyleHour); + } + + internal static string IntToHHmm(int duration) + { + int hours = duration / 60; + int minutes = duration % 60; + + return $"{hours:D2}:{minutes:D2}"; + } + + internal static int MinutesToInt(TimeSpan t) + { + return Convert.ToInt32(t.TotalMinutes); + } + + /// return [startDate, endDate, durationString] + internal static string[] FormatTimeInfo(string startDate, string startHour, string endHour) + { + string endDate = MergeDateHour(startDate, endHour); + startDate = MergeDateHour(startDate, startHour); + + DateTime eh = DateTime.Parse(endHour); + DateTime sh = DateTime.Parse(startHour); + TimeSpan duration; + + if (eh < sh) // (add 1 day to the date) + { + duration = TimeSpan.FromHours(24) + (eh - sh); + endDate = (DateTime.Parse(endDate) + TimeSpan.FromDays(1)).ToString(DaTeStyleDB); + } + else + duration = eh - sh; + + string durationString = Convert.ToString(MinutesToInt(duration)); + + return [startDate, endDate, durationString]; + } + + internal static string TrimHour(string fullDate) + { + return fullDate.Substring(0, DaTeStyleShort.Length + 1); + } + + internal static string TrimDate(string fullDate) + { + return fullDate.Substring(DaTeStyleShort.Length + 1); + } + + internal static string MergeDateHour(string startDate, string hour) + { + return startDate + " " + hour; + } + + internal static DateTime GetTimeFromUser(string expectedFormat, string topic) + { + string errorMessage = $"[red]Format mismatch. Use {expectedFormat}[/]"; + + string input = AnsiConsole.Prompt( + new TextPrompt($"{topic} ({expectedFormat}): ") + .ValidationErrorMessage(errorMessage) + .Validate(value => + { + return DateTime.TryParseExact( + value, + expectedFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _) + ? ValidationResult.Success() + : ValidationResult.Error(errorMessage); + })); + + return DateTime.ParseExact(input, expectedFormat, CultureInfo.InvariantCulture); + } + + internal static DateTime GetTimeFromUser(string expectedFormat, string topic, string hour) + { + string errorMessage = $"[red]Format mismatch. Use {expectedFormat}[/]"; + + string input = AnsiConsole.Prompt( + new TextPrompt($"{topic} ({expectedFormat}): ") + .ValidationErrorMessage(errorMessage) + .Validate(value => + { + return DateTime.TryParseExact( + value, + expectedFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _) + ? ValidationResult.Success() + : ValidationResult.Error(errorMessage); + })); + + string day = $"{input} {hour}"; + return DateTime.ParseExact(day, DaTeStyleDB, CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/CodingTracker/appsettings.json b/CodingTracker/appsettings.json new file mode 100644 index 000000000..c4798fec2 --- /dev/null +++ b/CodingTracker/appsettings.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "CodingTracker": "Data Source=CodingTracker.db" + }, + "Application": { + "Name": "Coding Tracker" + } +} \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 000000000..715970f6b --- /dev/null +++ b/Readme.md @@ -0,0 +1,44 @@ +# Console Coding Tracker +Console-based CRUD application to track time spent coding. Developed using C#, SQLite, Dapper, and Spectre.Console. + +## Given Requirements +- Show the data in the console using the Spectre.Console library. +- Have separate classes in different files (i.e., UserInput.cs, Validation.cs, CodingController.cs). +- Tell the user the specific date and time format to use and not allow any other format. +- Create a configuration file called appsettings.json, which will contain the database path, connection strings, and any other configurations you might need. +- Create a CodingSession class in a separate file with the following properties: Id, StartTime, EndTime, and Duration. +- The user shouldn't input the duration of the session. It should be calculated based on the start and end times. +- The user should be able to input the start and end times manually. +- Use the Dapper ORM for data access instead of ADO.NET. + +## Features +- Users can create a real-time coding session and track its duration. +- Users can add, edit, or remove a previous session. +- Users can view sessions using a basic search (time period of session activity). +- Users can autofill their activity using the latest entry in the database. +- Date and time inputs are checked to ensure they are in the correct and realistic format. + +## Challenges +I struggled a lot with the Elapsed.Time and DateTime paradigms. As I wanted to let the user input dates in a user-friendly way, I had to do a lot more conversions than I expected. +As I developed the application, I had to do a lot of helpers conversion methods. Way more that I expected. Sometimes I had the hour without the date, sometime the hour and the date etc. It felt complicated. This made naming and code readability challenging. +I made early the poor decision of letting the Database class be responsible for adding data in the correct format (Database.ConvertAndAdd()). I'm not redoing that again in the future. +Otherwise I found it hard to remember some really specific solution I used in previous projects. When facing the same need again, I tend to directly reread what I did. +It's hard to find the balance between learning by heart and being pragmatic and efficient. I don't think it makes a lot of sense to learn every coding scenario or library. + +## Lessons Learned +- I think I have a good basic understanding of DateTime and Elapsed.Time now. +- I should plan the project beforehand. I started with what was challenging (the stopwatch) and ended up working in a chaotic way. Knowing which features I want and writing user stories will help me stay focused. +- Having a side project to test features quickly would be helpful. +- Getting used to do micro-commit. Till here I only used Github to send my work once done. Now I am trying to get used a habbit of commiting new progress increment. + +## Areas to improve +- I really need to learn how delegates, generics and lambda work. I always struggle to understand code that uses them, and they seem really practical. +- the process of making an appsettings.json is still a bit mysterious to me +- Error handling is a vast topic that I will have to dig into. + +## Resources Used +- LearnDapper.com +- My previous habit tracker project +- Spectre documentation +- Various Stack Overflow and Medium posts +- Copilot to get some syntax quickly and explain how it works, as well as for review when I know I'm going the wrong way