diff --git a/CodeReviews.Console.CodingTracker.sln b/CodeReviews.Console.CodingTracker.sln new file mode 100644 index 00000000..7d32bc45 --- /dev/null +++ b/CodeReviews.Console.CodingTracker.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker", "CodingTracker\CodingTracker.csproj", "{514F39B8-9759-4A04-918A-C2B2C4E12D0B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTrackerTests", "CodingTrackerTests\CodingTrackerTests.csproj", "{5104A8EC-03BB-4927-9FFF-7B33E0132E44}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {514F39B8-9759-4A04-918A-C2B2C4E12D0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {514F39B8-9759-4A04-918A-C2B2C4E12D0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {514F39B8-9759-4A04-918A-C2B2C4E12D0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {514F39B8-9759-4A04-918A-C2B2C4E12D0B}.Release|Any CPU.Build.0 = Release|Any CPU + {5104A8EC-03BB-4927-9FFF-7B33E0132E44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5104A8EC-03BB-4927-9FFF-7B33E0132E44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5104A8EC-03BB-4927-9FFF-7B33E0132E44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5104A8EC-03BB-4927-9FFF-7B33E0132E44}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/CodingTracker/AppSettings.cs b/CodingTracker/AppSettings.cs new file mode 100644 index 00000000..663fdb1a --- /dev/null +++ b/CodingTracker/AppSettings.cs @@ -0,0 +1,26 @@ +namespace CodingTracker; + +using System; +using System.IO; +using System.Text.Json; + +public class AppSettings +{ + public string DbPath { get; set; } = string.Empty; + + private static readonly string FilePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + + public static AppSettings LoadConfig() + { + if (!File.Exists(FilePath)) + { + throw new FileNotFoundException($"Configuration file missing at: {FilePath}"); + } + + string jsonContent = File.ReadAllText(FilePath); + + return JsonSerializer.Deserialize(jsonContent) + ?? throw new InvalidOperationException("Failed to deserialize configuration file."); + } + +} diff --git a/CodingTracker/CodingSession.cs b/CodingTracker/CodingSession.cs new file mode 100644 index 00000000..59467b37 --- /dev/null +++ b/CodingTracker/CodingSession.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace CodingTracker; + +public class CodingSession +{ + public long? Id { get; set; } + public DateTime StartTime { get; set; } + public DateTime? EndTime { get; set; } + public long? DurationTicks { get; set; } + // We use Duration to use TimeSpan in the code even if SQLite doesn't support it + [NotMapped] + public TimeSpan Duration + { + get => TimeSpan.FromTicks(DurationTicks ?? 0); + set => DurationTicks = value.Ticks; + } + + public CodingSession() { } + + public CodingSession(DateTime startTime, DateTime? endTime, TimeSpan duration, long? id = null) + { + Id = id; + StartTime = startTime; + EndTime = endTime; + Duration = duration; + } + + public override string ToString() + { + return $@"Coding Session {{start: {StartTime}, end: {EndTime}, duration: {Duration} }}"; + } +} \ No newline at end of file diff --git a/CodingTracker/CodingTracker.csproj b/CodingTracker/CodingTracker.csproj new file mode 100644 index 00000000..c966c113 --- /dev/null +++ b/CodingTracker/CodingTracker.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + + + + + PreserveNewest + + + + + + + + + + diff --git a/CodingTracker/ConfigSettings.cs b/CodingTracker/ConfigSettings.cs new file mode 100644 index 00000000..02daede7 --- /dev/null +++ b/CodingTracker/ConfigSettings.cs @@ -0,0 +1,30 @@ +namespace CodingTracker; + +using System; +using System.IO; +using System.Text.Json; + +public class ConfigSettings +{ + public string DbPath { get; set; } = string.Empty; +} + +public static class ReadConfig +{ + public static ConfigSettings Config { get; set; } + + private static readonly string FilePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + + static ReadConfig() + { + if (!File.Exists(FilePath)) + { + throw new FileNotFoundException($"Configuration file missing at: {FilePath}"); + } + + string jsonContent = File.ReadAllText(FilePath); + + Config = JsonSerializer.Deserialize(jsonContent) + ?? throw new InvalidOperationException("Failed to deserialize configuration file."); + } +} \ No newline at end of file diff --git a/CodingTracker/Controller.cs b/CodingTracker/Controller.cs new file mode 100644 index 00000000..7b2b9e94 --- /dev/null +++ b/CodingTracker/Controller.cs @@ -0,0 +1,124 @@ +namespace CodingTracker; + +public static class Controller +{ + public static DateTime MonthToShow = Utils.GetFirstDayMonth(DateTime.Now); + private static UserOption.PromptMenus _promptMenu = UserOption.PromptMenus.Main; + private static bool _endApp = false; + + public static async Task Start() + { + do + { + var sessions = await DataBaseInterface.ViewCodingSessions(MonthToShow, Utils.NextMonth(MonthToShow)); + var sessionsPerDay = AmountCodingPerDay(sessions); + sessionsPerDay = AddEmptyCodingSessionIfEmpty(sessionsPerDay, MonthToShow); + + var previousMonth = Utils.PreviousMonth(MonthToShow); + var sessionsPrevious = await DataBaseInterface.ViewCodingSessions(previousMonth, MonthToShow); + var sessionsPreviousPerDay = AmountCodingPerDay(sessionsPrevious); + sessionsPreviousPerDay = AddEmptyCodingSessionIfEmpty(sessionsPreviousPerDay, previousMonth); + + UserInterface.ClearAndTitle(); + TableVisualisationEngine.ShowCalendarWithCaption(sessionsPreviousPerDay, sessionsPerDay); + await Prompt(); + + } while (!_endApp); + } + + private static async Task Prompt() + { + switch (_promptMenu) + { + case UserOption.PromptMenus.Main: + var choiceMainMenu = UserInterface.MainMenuPrompt(); + MainMenu(choiceMainMenu); + break; + case UserOption.PromptMenus.View: + var choiceViewMenu = UserInterface.ViewMenuPrompt(); + ViewMenu(choiceViewMenu); + break; + case UserOption.PromptMenus.AddLive: + var (startDateLive, endDateLive) = UserInterface.AskDateRangeLive(); + await AddMenu(startDateLive, endDateLive); + _promptMenu = UserOption.PromptMenus.Main; + break; + case UserOption.PromptMenus.Add: + var (startDate, endDate) = UserInterface.AskDateRange(); + await AddMenu(startDate, endDate); + _promptMenu = UserOption.PromptMenus.Main; + break; + default: + throw new ArgumentOutOfRangeException($"Not valid promptMenu"); + } + } + + private static void MainMenu(UserOption.MainMenu choice) + { + switch (choice) + { + case UserOption.MainMenu.Insert: + _promptMenu = UserOption.PromptMenus.Add; + break; + case UserOption.MainMenu.InsertLive: + _promptMenu = UserOption.PromptMenus.AddLive; + break; + case UserOption.MainMenu.View: + _promptMenu = UserOption.PromptMenus.View; + break; + case UserOption.MainMenu.Quit: + _endApp = true; + break; + default: + throw new ArgumentOutOfRangeException(nameof(choice), choice, null); + } + } + + private static void ViewMenu(UserOption.ViewMenu choice) + { + switch (choice) + { + case UserOption.ViewMenu.Previous: + MonthToShow = Utils.PreviousMonth(MonthToShow); + break; + case UserOption.ViewMenu.Next: + MonthToShow = Utils.NextMonth(MonthToShow); + break; + case UserOption.ViewMenu.Quit: + _promptMenu = UserOption.PromptMenus.Main; + break; + default: + throw new ArgumentOutOfRangeException(nameof(choice), choice, null); + } + } + + private static async Task AddMenu(DateTime start, DateTime end) + { + var duration = Utils.Duration(start, end); + var codingSession = new CodingSession(start, end, duration); + bool confirmation = UserInterface.ConfirmationPrompt($"add {codingSession}"); + + if (confirmation) await DataBaseInterface.AddCodingSession(codingSession); + } + + /// Aggregates coding sessions by calendar day, returning one session per day with summed durations + /// We take this postulate: Sessions that happens past midnight are attributed entirely to their start date + public static List AmountCodingPerDay(List codingSessions) + { + return codingSessions + .GroupBy(codingSession => codingSession.StartTime.Date) // Only take the day of the startTime + .Select(codingSession => + new CodingSession( + codingSession.Key, + codingSession.Key.AddDays(1), + TimeSpan.FromTicks(codingSession.Sum(s => s.Duration.Ticks)))) + .ToList(); + } + + private static List AddEmptyCodingSessionIfEmpty(List sessions, DateTime monthToShow) + { + if (sessions.Count > 0) return sessions; + return [new CodingSession(monthToShow, monthToShow, new TimeSpan(0))]; + } + +} \ No newline at end of file diff --git a/CodingTracker/DataBaseInterface.cs b/CodingTracker/DataBaseInterface.cs new file mode 100644 index 00000000..b4e132b6 --- /dev/null +++ b/CodingTracker/DataBaseInterface.cs @@ -0,0 +1,64 @@ +using Dapper; +using Microsoft.Data.Sqlite; + +namespace CodingTracker; + +public static class DataBaseInterface +{ + private static SqliteConnection? _dbConnection; + private static bool _isInitialized = false; + + private static void InitializeDb() + { + if (_isInitialized && _dbConnection != null) return; + string dbPath = ReadConfig.Config.DbPath; + string connectionString = "Data Source=" + dbPath; + _dbConnection = new SqliteConnection(connectionString); + _dbConnection.Open(); + CreateTable(); + _isInitialized = true; + } + + private static void CreateTable() + { + string createTableQuery = @" + CREATE TABLE IF NOT EXISTS CodingSessions ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + StartTime TEXT NOT NULL, + EndTime TEXT NOT NULL, + DurationTicks INTEGER NOT NULL);"; + + using var command = new SqliteCommand(createTableQuery, _dbConnection); + command.ExecuteNonQuery(); + } + + public static async Task AddCodingSession(CodingSession codingSession) + { + InitializeDb(); + if (_dbConnection == null) throw new Exception("Database connection couldn't initialize"); + + string addCodingSessionQuery = @" + INSERT INTO CodingSessions (StartTime, EndTime, DurationTicks) + VALUES (@StartTime, @EndTime, @DurationTicks);"; + + await _dbConnection.ExecuteAsync(addCodingSessionQuery, codingSession); + } + + public static async Task> ViewCodingSessions(DateTime? minStartDate = null, DateTime? maxStartDate = null) + { + InitializeDb(); + if (_dbConnection == null) throw new Exception("Database connection couldn't initialize"); + + var conditions = new List(); + if (minStartDate.HasValue) conditions.Add("StartTime >= @MinDate"); + if (maxStartDate.HasValue) conditions.Add("StartTime <= @MaxDate"); + + string whereClauses = conditions.Count > 0 ? " WHERE " + string.Join(" AND ", conditions) : ""; + + string viewCodingSessionsQuery = $"SELECT * FROM CodingSessions{whereClauses} ORDER BY StartTime;"; + + return (await _dbConnection.QueryAsync(viewCodingSessionsQuery, + new { MinDate = minStartDate, MaxDate = maxStartDate })).ToList(); + } + +} \ No newline at end of file diff --git a/CodingTracker/Program.cs b/CodingTracker/Program.cs new file mode 100644 index 00000000..0548acb6 --- /dev/null +++ b/CodingTracker/Program.cs @@ -0,0 +1,9 @@ +namespace CodingTracker; + +static class Program +{ + public static async Task Main(string[] args) + { + await Controller.Start(); + } +} \ No newline at end of file diff --git a/CodingTracker/TableVisualisationEngine.cs b/CodingTracker/TableVisualisationEngine.cs new file mode 100644 index 00000000..65320fde --- /dev/null +++ b/CodingTracker/TableVisualisationEngine.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using Spectre.Console; +using Calendar = Spectre.Console.Calendar; + +namespace CodingTracker; + +public record ColorLevel(double MaxHours, Color Color); + +public static class TableVisualisationEngine +{ + private static readonly ColorLevel[] ColorLevels = + [ + new(1, Color.Grey), + new(3, Color.IndianRed), + new(6, Color.Red), + new(9, Color.Red3), + new(double.MaxValue, Color.DarkRed) + ]; + + public static void ShowCalendarWithCaption(List sessionsPrevious, List sessions) + { + var caption = GetCaptionForCalendar(); + var alignedCaption = Align.Left(caption, VerticalAlignment.Middle).Height(10); + + var grid = new Grid().AddColumn().AddColumn().AddColumn(); + grid.AddRow(GetCalendar(sessionsPrevious), GetCalendar(sessions), alignedCaption); + + AnsiConsole.Write(grid); + } + + private static Calendar GetCalendar(List sessions) + { + if (sessions.Count == 0) return new Calendar(Controller.MonthToShow.Year, Controller.MonthToShow.Month); + + int year = sessions[0].StartTime.Year; + int month = sessions[0].StartTime.Month; + var calendar = new Calendar(year, month) + .Border(TableBorder.Rounded) // Gives it a nice, smooth border + .HeaderStyle(new Style(foreground: Color.Cyan1, decoration: Decoration.Bold)) + .HighlightStyle(new Style(foreground: Color.White, background: Color.Blue)) + .Culture(CultureInfo.CurrentCulture); + + foreach (var session in sessions) + { + if (session.Duration.Ticks == 0) break; + calendar.AddCalendarEvent(year, month, session.StartTime.Day, GetStyle(session.Duration)); + } + return calendar; + } + + private static Style GetStyle(TimeSpan duration) + { + var color = ColorLevels.First(level => duration.TotalHours < level.MaxHours).Color; + return new Style(background: color); + } + + private static Grid GetCaptionForCalendar() + { + var captionTable = new Grid() + .AddColumn() + .AddColumn(); + + double previousMax = 0; + + foreach (var colorLevel in ColorLevels) + { + string rangeString = $"{previousMax}h " + + (colorLevel.MaxHours > 24 ? $"or more" : $"to {colorLevel.MaxHours}h"); + string coloredShape = $"[{colorLevel.Color}]██[/]"; + captionTable.AddRow(coloredShape, rangeString); + + previousMax = colorLevel.MaxHours; + } + + return captionTable; + } +} \ No newline at end of file diff --git a/CodingTracker/UserInterface.cs b/CodingTracker/UserInterface.cs new file mode 100644 index 00000000..c3e250a1 --- /dev/null +++ b/CodingTracker/UserInterface.cs @@ -0,0 +1,95 @@ +using Spectre.Console; + +namespace CodingTracker; + +public static class UserInterface +{ + private const string TitleStyle = "[green]"; + private const string TitleString = "Coding Session"; + private const string MainMenuTitle = $"{TitleStyle}What do you want to do ?[/]"; + private const string ViewMenuTitle = $"{TitleStyle}Navigate through the months[/]"; + + private static readonly string ConfirmationBaseMessage = $"Are you sure you want to {{0}} ?"; + + private static readonly string DatePromptMessage = + $"Enter the date ({Validation.DateFormat}) of the {{0}}, nothing for right now"; + + private static readonly string DatePromptLiveMessage = + $"You started the live session at {{0}}, press enter to end the session"; + + private static readonly string DatePromptErrorEndBeforeStart = + "[red]End date cannot be earlier than start date. Try again.[/]"; + + public static void ClearAndTitle() + { + var title = new Text(TitleString, new Style(foreground: Color.Green1)); + var alignedTitle = Align.Center(title); + + AnsiConsole.Clear(); + AnsiConsole.Write(alignedTitle); + } + public static UserOption.MainMenu MainMenuPrompt() + { + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title(MainMenuTitle) + .PageSize(Enum.GetNames().Length) + .AddChoices(Enum.GetValues()) + .UseConverter(UserOption.MainMenuToString)); + } + + public static UserOption.ViewMenu ViewMenuPrompt() + { + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title(ViewMenuTitle) + .PageSize(Enum.GetNames().Length) + .AddChoices(Enum.GetValues()) + .UseConverter(UserOption.ViewMenuToString)); + } + + public static (DateTime, DateTime) AskDateRange() + { + var startDate = AskDate("start date"); + var endDate = AskDate("end date"); + + while (endDate < startDate) + { + AnsiConsole.MarkupLine(DatePromptErrorEndBeforeStart); + endDate = AskDate("end date"); + } + + return (startDate, endDate); + } + + public static (DateTime, DateTime) AskDateRangeLive() + { + var startDate = DateTime.Now; + AnsiConsole.MarkupLine(string.Format(DatePromptLiveMessage, startDate)); + + // Wait user input without showing it in console + Console.ReadKey(intercept: true); + + var endDate = DateTime.Now; + return (startDate, endDate); + } + + public static bool ConfirmationPrompt(string message) + { + return AnsiConsole.Confirm(string.Format(ConfirmationBaseMessage, message)); + } + + private static DateTime AskDate(string message) + { + string formattedMessage = string.Format(DatePromptMessage, message); + string defaultValue = Validation.DateToString(DateTime.Now); + Validation.DateTryParser(defaultValue, out DateTime parsedDate); + + AnsiConsole.Prompt( + new TextPrompt(formattedMessage) + .DefaultValue(defaultValue) + .Validate(input => Validation.DateTryParser(input, out parsedDate)) + .ValidationErrorMessage(Validation.DateErrorMessage)); + return parsedDate; + } +} \ No newline at end of file diff --git a/CodingTracker/UserOption.cs b/CodingTracker/UserOption.cs new file mode 100644 index 00000000..586245be --- /dev/null +++ b/CodingTracker/UserOption.cs @@ -0,0 +1,53 @@ +namespace CodingTracker; + +public static class UserOption +{ + // Option for the main menu selection prompt + public enum MainMenu + { + Insert, + InsertLive, + View, + Quit + } + + // Option for the view menu selection prompt + public enum ViewMenu + { + Previous, + Next, + Quit + } + + // Different prompt that can be shown + public enum PromptMenus + { + Main, + Add, + AddLive, + View + } + + public static string MainMenuToString(MainMenu option) + { + return option switch + { + MainMenu.Insert => "Insert a new coding session", + MainMenu.InsertLive => "Start a live recording session", + MainMenu.View => "View other months in the calendar", + MainMenu.Quit => "Quit the app", + _ => option.ToString() + }; + } + + public static string ViewMenuToString(ViewMenu option) + { + return option switch + { + ViewMenu.Previous => "Previous month", + ViewMenu.Next => "Next month", + ViewMenu.Quit => "Go back to the menu", + _ => option.ToString() + }; + } +} \ No newline at end of file diff --git a/CodingTracker/Utils.cs b/CodingTracker/Utils.cs new file mode 100644 index 00000000..0343953a --- /dev/null +++ b/CodingTracker/Utils.cs @@ -0,0 +1,27 @@ +namespace CodingTracker; + +public static class Utils +{ + + public static TimeSpan Duration(DateTime start, DateTime end) + { + if (start.CompareTo(end) > 0) throw new Exception("Start must be earlier than end"); + + return (end - start); + } + + public static DateTime GetFirstDayMonth(DateTime time) + { + return new DateTime(time.Date.Year, time.Date.Month, 1); + } + + public static DateTime PreviousMonth(DateTime time) + { + return time.AddMonths(-1); + } + + public static DateTime NextMonth(DateTime time) + { + return time.AddMonths(1); + } +} \ No newline at end of file diff --git a/CodingTracker/Validation.cs b/CodingTracker/Validation.cs new file mode 100644 index 00000000..f924c909 --- /dev/null +++ b/CodingTracker/Validation.cs @@ -0,0 +1,20 @@ +using System.Globalization; + +namespace CodingTracker; + +public static class Validation +{ + public const string DateFormat = "yyyy-MM-dd: HH-mm"; + public const string DateErrorMessage = "Invalid date format !"; + + public static string DateToString(DateTime time) + { + return time.ToString(DateFormat); + } + + public static bool DateTryParser(string input, out DateTime parsedDate) + { + return (DateTime.TryParseExact(input?.Trim(), DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, + out parsedDate)); + } +} \ No newline at end of file diff --git a/CodingTrackerTests/AppSettingsTests.cs b/CodingTrackerTests/AppSettingsTests.cs new file mode 100644 index 00000000..a1fd7d86 --- /dev/null +++ b/CodingTrackerTests/AppSettingsTests.cs @@ -0,0 +1,20 @@ +namespace CodingTrackerTests; +using CodingTracker; + +public class Tests +{ + private AppSettings validAppSettings; + + [SetUp] + public void Setup() + { + validAppSettings = AppSettings.LoadConfig(); + } + + [Test] + public void Test1() + { + string expectedDbPath = "CodingTracker.db"; + Assert.That(validAppSettings.DbPath, Is.EqualTo(expectedDbPath)); + } +} \ No newline at end of file diff --git a/CodingTrackerTests/CodingSessionTests.cs b/CodingTrackerTests/CodingSessionTests.cs new file mode 100644 index 00000000..6dc5c270 --- /dev/null +++ b/CodingTrackerTests/CodingSessionTests.cs @@ -0,0 +1,40 @@ +using CodingTracker; + +namespace CodingTrackerTests; + +public class CodingSessionTests +{ + [Test] + public void DefaultConstructorCheck() + { + var session = new CodingSession(); + + Assert.That(session.Id, Is.Null); + Assert.That(session.StartTime, Is.EqualTo(default(DateTime))); + Assert.That(session.EndTime, Is.Null); + Assert.That(session.DurationTicks, Is.Null); + Assert.That(session.Duration, Is.EqualTo(TimeSpan.Zero)); + } + + [Test] + public void Duration_UpdatesDurationTicks() + { + var session = new CodingSession(); + var duration = TimeSpan.FromMinutes(45); + session.Duration = duration; + + Assert.That(session.DurationTicks, Is.EqualTo(duration.Ticks)); + Assert.That(session.Duration, Is.EqualTo(duration)); + } + + [Test] + public void Duration_ReturnsZero_WhenDurationTicksIsNull() + { + var session = new CodingSession + { + DurationTicks = null + }; + + Assert.That(session.Duration, Is.EqualTo(TimeSpan.Zero)); + } +} \ No newline at end of file diff --git a/CodingTrackerTests/CodingTrackerTests.csproj b/CodingTrackerTests/CodingTrackerTests.csproj new file mode 100644 index 00000000..bcc4d159 --- /dev/null +++ b/CodingTrackerTests/CodingTrackerTests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/CodingTrackerTests/UtilsTests.cs b/CodingTrackerTests/UtilsTests.cs new file mode 100644 index 00000000..bfc37f08 --- /dev/null +++ b/CodingTrackerTests/UtilsTests.cs @@ -0,0 +1,56 @@ +using CodingTracker; + +namespace CodingTrackerTests; + +public class UtilsTests +{ + [Test] + public void Duration_WithValidDates() + { + var start = new DateTime(2026, 10, 12, 10, 0, 0); + var end = new DateTime(2026, 10, 12, 12, 30, 0); + TimeSpan result = Utils.Duration(start, end); + + Assert.That(result, Is.EqualTo(TimeSpan.FromHours(2.5))); + } + + [Test] + public void Duration_ThrowsException() + { + var start = new DateTime(2026, 10, 12, 14, 0, 0); + var end = new DateTime(2026, 10, 12, 12, 0, 0); + + var ex = Assert.Throws(() => Utils.Duration(start, end)); + Assert.That(ex.Message, Is.EqualTo("Start must be earlier than end")); + } + + [Test] + public void GetFirstDayMonth() + { + var date = new DateTime(2026, 10, 12, 15, 45, 30); + DateTime result = Utils.GetFirstDayMonth(date); + + var expected = new DateTime(2026, 10, 1, 0, 0, 0); + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void PreviousMonth() + { + var date = new DateTime(2026, 10, 12); + DateTime result = Utils.PreviousMonth(date); + + var expected = new DateTime(2026, 9, 12); + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void NextMonth() + { + var date = new DateTime(2026, 10, 12); + DateTime result = Utils.NextMonth(date); + + var expected = new DateTime(2026, 11, 12); + Assert.That(result, Is.EqualTo(expected)); + } +} \ No newline at end of file diff --git a/CodingTrackerTests/ValidationTests.cs b/CodingTrackerTests/ValidationTests.cs new file mode 100644 index 00000000..d8dd321d --- /dev/null +++ b/CodingTrackerTests/ValidationTests.cs @@ -0,0 +1,49 @@ +using CodingTracker; + +namespace CodingTrackerTests; + +public class ValidationTests +{ + [Test] + public void DateToString_FormatsDateTime_AccordingToDateFormatConstant() + { + var testDate = new DateTime(2026, 10, 9, 14, 30, 0); + string expectedOutput = "2026-10-09: 14-30"; + + string result = Validation.DateToString(testDate); + + Assert.That(result, Is.EqualTo(expectedOutput)); + } + + [Test] + public void DateToString_MatchesExpectedFormatPattern() + { + var testDate = DateTime.Now; + + string result = Validation.DateToString(testDate); + + Assert.That(result, Does.Match(@"^\d{4}-\d{2}-\d{2}: \d{2}-\d{2}$")); + } + + + [TestCase("2026-10-12: 18-30", true)] + [TestCase(" 2026-10-12: 18-30 ", true)] + public void DateTryParser_WithValidInput_ReturnsTrueAndParsedDate(string input, bool expectedResult) + { + bool success = Validation.DateTryParser(input, out DateTime parsedDate); + + Assert.That(success, Is.EqualTo(expectedResult)); + Assert.That(parsedDate, Is.Not.EqualTo(default(DateTime))); + } + + [TestCase("12-10-2026")] + [TestCase("not-a-date")] + [TestCase("")] + public void DateTryParser_WithInvalidInput_ReturnsFalse(string input) + { + bool success = Validation.DateTryParser(input, out DateTime parsedDate); + + Assert.That(success, Is.False); + } + +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..e2314442 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Coding Tracker +This is a console app to record your coding sessions \ +And see your activity in a calendar. + +## How to run +In the solution folder of the project run the folowing to execute: \ +`dotnet run --project CodingTracker/CodingTracker.csproj` + +You can personalize the database name in appsettings.json to handle several tracker at the same time \ No newline at end of file diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 00000000..656a9189 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,3 @@ +{ + "DbPath": "CodingTracker.db" +} \ No newline at end of file diff --git a/identifier.sqlite b/identifier.sqlite new file mode 100644 index 00000000..e69de29b